diff --git a/docs/telemetry-campaign-2026-08-02/DEFEITOS.md b/docs/telemetry-campaign-2026-08-02/DEFEITOS.md
new file mode 100644
index 0000000..9495e78
--- /dev/null
+++ b/docs/telemetry-campaign-2026-08-02/DEFEITOS.md
@@ -0,0 +1,431 @@
+# Defeitos encontrados — campanha de telemetria 2026-08-02
+
+Cada item traz o mecanismo no código, a evidência que o provou e a correção
+proposta. Os que ainda dependem de medição em curso estão marcados
+**(a confirmar)** e serão fechados com o dado, não com a leitura do código.
+
+---
+
+## D1 — A senha MQTT digitada na web é descartada em silêncio
+
+**Severidade: alta.** Autenticação MQTT é impossível de configurar por qualquer
+caminho.
+
+**Mecanismo.** A página tem o campo (`WebUI.h:2577`):
+
+```html
+
+```
+
+e `wirePendingListeners()` (`WebUI.h:3055`) empurra **todo** input com `id`
+dentro de `#sysForm` para `Pending.sys`, por `id`. Ou seja, o navegador **envia**
+`sys.m_pass` no `_payload` do `/api/commit_all`.
+
+Do outro lado, `WebManager_Commit.cpp:570-575` trata `m_topic`, `m_cid`,
+`m_user`, `m_qos`, `m_retain` e `m_ka` — **não existe nenhum `has("m_pass")`**.
+O valor cai no chão sem log e sem erro. `cfg.mqttPass` só é escrito em
+`StorageManager.cpp:495`, que o zera nos defaults.
+
+Consequência: `mqttEnsureConnected()` sempre chama `connect()` com senha vazia
+(`TelemetryManager.cpp:710`), e qualquer broker que exija senha recusa o
+CONNECT. O usuário vê "MQTT failed: Bad credentials" com a senha certa digitada
+na tela.
+
+**Correção** (`WebManager_Commit.cpp`, junto de `m_user`):
+
+```cpp
+/* Vazio = manter a atual, como o placeholder do campo promete
+ * ("Leave empty to keep") e como t_key já faz com a máscara "***". */
+if (has("m_pass")) {
+ String mp = getStr("m_pass");
+ if (mp.length( ) > 0) safeCopy(cfg.mqttPass, mp.c_str( ), sizeof(cfg.mqttPass));
+}
+```
+
+---
+
+## D2 — O cabeçalho CSV descreve 7 colunas; as linhas têm 34
+
+**Severidade: alta.** Todo consumidor que confia no cabeçalho lê os valores nas
+colunas erradas.
+
+**Mecanismo.** `buildPayload()` monta o cabeçalho com **uma coluna por sensor
+ativo** (`TelemetryManager.cpp:1017-1025`):
+
+```cpp
+s = "timestamp";
+for (int i = 0; i < MAX_SENSORS; i++)
+ if (cfg.sensors[i].active) { snprintf(hdrBuf, ..., ";s%d_%s", i, cfg.sensors[i].hwId); ... }
+```
+
+mas `toCsvLine()` (`SystemDefs_Records.h:611`) emite **sempre** o layout fixo
+`epoch;s0..s15;h0..h15;press` = 34 campos, ativos ou não.
+
+**Evidência medida** (corpo cru capturado pelo sink, `results/phase_payload.json`):
+
+```
+cabeçalho (7): timestamp;s0_STM0009;s1_STM0010;s3_STH0003;s4_STB0001;s6_STZ9999;s10_STH0001
+linha (34): 1783140900;23.92;24.81;;25.14;;;;;;;23.99;;;;;;;;;68.3;;;;;;;68.5;;;;;;
+```
+
+Todas as 24 linhas do lote têm exatamente 34 campos. O comentário acima do
+código afirma "Header matches toCsvLine" — não casa.
+
+**Correção**: emitir o cabeçalho com o mesmo layout fixo das linhas, nomeando
+os slots ativos e deixando os inativos com nome posicional:
+
+```cpp
+s = "timestamp";
+for (int i = 0; i < MAX_SENSORS; i++) {
+ if (cfg.sensors[i].active && cfg.sensors[i].hwId[0])
+ snprintf(hdrBuf, sizeof(hdrBuf), ";s%d_%s", i, cfg.sensors[i].hwId);
+ else snprintf(hdrBuf, sizeof(hdrBuf), ";s%d", i);
+ s.concat(hdrBuf);
+}
+for (int i = 0; i < MAX_SENSORS; i++) { ... ";h%d..." ... }
+s.concat(";press");
+```
+
+O layout das linhas **não** muda — é o formato persistido e compatível com o
+upload; quem estava errado era o cabeçalho.
+
+---
+
+## D3 — A telemetria nunca alcança histórico além de 30 dias
+
+**Severidade: média** (é política, não bug — mas não está documentada e não há
+como contorná-la pela interface).
+
+**Mecanismo.** `collectBatch()` (`TelemetryManager.cpp:409-412`):
+
+```cpp
+if (lastCursor == 0) {
+ uint32_t lastRecorded = _storageRef->getLastRecordedTimestamp( );
+ if (lastRecorded > 86400UL * 30) lastCursor = lastRecorded - 86400UL * 30;
+}
+```
+
+`tel reset` zera o cursor, e é o único jeito de reenviar do começo. O piso de 30
+dias entra logo em seguida, e o corte por `minDay` descarta os arquivos
+anteriores. Não existe comando nem campo que peça mais.
+
+**Evidência medida.** Drenagem completa com `tel reset`, lote 50, intervalo 1 s:
+39 900 registros em 1009 s, começando em 2026-07-03 — enquanto o flash guarda
+desde 2026-05-03. O restante do arquivo é inalcançável por telemetria em
+qualquer tempo de execução.
+
+**Prova de que é política e não limite de armazenamento**: semeando
+`/config/t_cursor.bin` com 1 600 000 001 (logo acima de `HIST_EPOCH_MIN`) em vez
+de zero, o `if (lastCursor == 0)` não dispara e o dispositivo transmite o arquivo
+inteiro — ver a fase `drain_full`.
+
+**Correção proposta**: tornar a janela configurável (`t_backfill_days`, 0 = tudo)
+ou, no mínimo, registrar em log qual piso foi aplicado e por quê, para o operador
+não concluir que os dados sumiram.
+
+---
+
+## D4 — `/api/ls` trunca a listagem em silêncio
+
+**Severidade: alta.** Um cliente não consegue distinguir listagem parcial de
+completa, e o JSON sai bem-formado nos dois casos.
+
+**Mecanismo.** `handleApiLs()` (`WebManager_Files.cpp:186-187`):
+
+```cpp
+while (!dirDone) {
+ if (isHandlerOvertime( )) break; // <- sai e fecha o JSON como se tivesse acabado
+```
+
+`isHandlerOvertime()` compara com `_handlerDeadline`, fixado em
+`handlerStart + 6000` (`WebManager_Core.cpp:269`). Ao estourar, a função sai do
+laço, fecha `]}` e responde 200. Nada no corpo indica que faltou entrada.
+Compare com `handleApiHistoryMulti`, que **eleva** o prazo
+(`WEB_LONG_HANDLER_DEADLINE_MS`) justamente por ser um handler longo — o listador
+não faz isso.
+
+**Evidência medida.** Duas listagens de `/history` no mesmo dia devolveram
+**84 entradas cada, com conjuntos diferentes**:
+
+| arquivo | listagem A | listagem B | existe de fato? |
+|---|---|---|---|
+| `20260523.h5` | sim | não | **sim** — HTTP 200, 8166 B, md5 `3e26f8a71a` |
+| `20260524.h5` | não | sim | **sim** — HTTP 200, 8157 B, md5 `4017ee9696` |
+| `20260706.h5` | sim | não | sim |
+| `20260707.h5` | não | sim | sim (baixado, decodifica 1440 registros de 07-07) |
+
+Os dois arquivos de cada par existem e têm conteúdo distinto e correto; nenhuma
+listagem mostrou os dois. O gerenciador de arquivos da web sofre do mesmo
+problema, e qualquer inventário construído sobre `/api/ls` é subestimado por uma
+margem desconhecida.
+
+**Correção**: (a) sinalizar a truncagem no corpo — `"truncated":true` — para que
+nenhum cliente possa confundir; e (b) elevar o prazo desse handler como o
+`history_multi` faz. (a) é o essencial: sem ela, aumentar o prazo só empurra o
+limite para diretórios maiores.
+
+---
+
+## D5 — O cursor pode avançar por cima de um lote que `buildPayload` encurtou
+
+**Severidade: baixa hoje, alta se as constantes mudarem.** Latente: as margens
+atuais impedem que dispare.
+
+**Mecanismo.** `collectBatch()` devolve `newCursor` = maior epoch do lote
+**completo**. Em seguida `buildPayload()` pode **encurtar o lote** sob pressão de
+heap (`TelemetryManager.cpp:987-993`):
+
+```cpp
+if (freeHeap < estimatedSize + SEC_RESERVE) {
+ size_t safeCount = ...;
+ if (safeCount < batch.size( )) { batch.resize(safeCount); batch.shrink_to_fit( ); }
+}
+```
+
+mas `update()` segue usando o `newCursor` antigo:
+
+```cpp
+String payload = buildPayload(batch); // pode ter jogado registros fora
+batch.clear( );
+success = attemptHttpUpload(payload, newCursor); // avança até o lote INTEIRO
+```
+
+Se o encurtamento acontecer, o cursor pula os registros descartados e eles nunca
+são enviados — sem log. Vale para os dois ramos de `update()` e para
+`forceSync()`.
+
+**Por que não dispara hoje**: `safeBatchLimit()` já limitou o lote com uma
+reserva **maior** (32768 com TLS, 12288 sem) do que a de `buildPayload`
+(12288 / 6144), e com 350 B/registro contra 300. A desigualdade se mantém para
+qualquer heap. Ou seja, a segurança vem de um acoplamento implícito entre quatro
+constantes em duas funções — inverta qualquer uma e a perda passa a ser real.
+
+**Correção** (barata e torna o acoplamento desnecessário): fazer o cursor seguir
+o que o payload realmente contém.
+
+```cpp
+String payload = buildPayload(batch);
+/* buildPayload pode encurtar o lote sob pressão de heap; o cursor tem de
+ * seguir o que o payload contém, não o que foi coletado. O lote está em
+ * ordem crescente de epoch (arquivos ordenados, registros em ordem). */
+if (!batch.empty( )) newCursor = batch.back( ).epoch;
+```
+
+---
+
+## D6 — `pending` é `uint16_t` e envolve sem saturar
+
+**Severidade: baixa** (cosmético/diagnóstico), mas engana quem depende do número.
+
+**Mecanismo.** `refreshPendingCount()` acumula em `uint16_t total` com cast
+explícito (`TelemetryManager.cpp:1456,1496`):
+
+```cpp
+uint16_t total = 0;
+...
+total = (uint16_t)(total + hdr.pre.a);
+```
+
+Com o cursor zerado o laço conta **todos** os registros de **todos** os
+arquivos. O flash desta bancada guarda ~119 mil registros — bem acima de 65535 —
+e o valor envolve para um número plausível porém falso, que aparece no dashboard
+e em `/api/status`.
+
+Além disso `refreshPendingCount()` **não** aplica o piso de 30 dias que
+`collectBatch()` aplica: logo depois de um `tel reset`, o contador soma registros
+que o dispositivo jamais vai enviar.
+
+**Correção**: acumular em `uint32_t` e saturar na atribuição —
+`_pendingEstimate = (total > 0xFFFF) ? 0xFFFF : (uint16_t)total;` — e aplicar o
+mesmo piso de 30 dias do `collectBatch` para os dois contadores concordarem.
+
+---
+
+## D7 — QoS 1 e 2 são oferecidos na interface e nunca saem no fio **(a confirmar)**
+
+A página oferece `0 - At Most Once`, `1 - At Least Once`, `2 - Exactly Once`
+(`WebUI.h:2582-2586`) e o firmware valida e persiste `cfg.mqttQos` de 0 a 2
+(`WebManager_Commit.cpp:573`). Mas `attemptMqttPublish()` chama sempre a
+sobrecarga de três argumentos do PubSubClient:
+
+```cpp
+_mqttClient.publish(topic.c_str( ), linePayload.c_str( ), cfg.mqttRetain);
+```
+
+que publica **QoS 0** — o PubSubClient não implementa publish com QoS 1 ou 2.
+`cfg.mqttQos` não é lido em lugar nenhum do caminho de envio.
+
+Consequência prática: sem QoS 1 não há PUBACK, então `publish()` devolve
+sucesso assim que escreve no socket TCP. Um broker que morra no meio do publish
+é indistinguível de um que recebeu — e o cursor avança. O teste
+`mq_drop_on_publish` mede exatamente isso.
+
+*A confirmar pelas flags do PUBLISH registradas pelo broker instrumentado.*
+
+---
+
+## D8 — Payload MQTT acima de ~8 KB trava a telemetria para sempre **(a confirmar)**
+
+`attemptMqttPublish()` cresce o buffer para caber o payload, mas com teto rígido:
+
+```cpp
+if (payload.length( ) > _mqttClient.getBufferSize( )) {
+ uint16_t needed = min((size_t)8192, payload.length( ) + 64);
+ _mqttClient.setBufferSize(needed);
+}
+```
+
+`PubSubClient::publish()` recusa qualquer pacote que não caiba no buffer. Acima
+de ~8 KB o publish falha **de forma determinística**: o retry falha igual, o
+backoff escala até o teto de 300 s e a telemetria fica parada para sempre — sem
+reboot e sem mensagem que explique.
+
+É alcançável pela tela de configuração: lote 50 com um `t_line` longo
+(o campo aceita 512 bytes) passa de 8 KB com os sensores desta bancada.
+
+*A confirmar pela fase `mqtt_oversize`.*
+
+---
+
+## D9 — Um único campo de pressão por registro, rotulado pelo slot errado
+
+**Severidade: média** para quem tiver mais de um sensor de pressão.
+
+`BinaryHistoryRecord` tem `pressure` como **escalar**, não um por slot. Em
+`collectBatch()`:
+
+```cpp
+else if (chOf[c] == CH_PRESS) rec.pressure = BinaryHistoryRecord::floatToI16x10(v);
+```
+
+o laço percorre os canais do schema e **o último vence**. E em
+`formatLineJsonBuf()` o rótulo vem do **primeiro** slot ativo capaz de pressão:
+
+```cpp
+for (int i = 0; i < MAX_SENSORS; i++)
+ if (cfg.sensors[i].active && ... sensorHasChannel(..., CH_PRESS)) { pHwid = cfg.sensors[i].hwId; break; }
+```
+
+Com dois sensores de pressão provisionados, o valor publicado é o do **último**
+slot sob o nome do **primeiro** — e o do primeiro nunca é publicado. Canais NaN
+são pulados, então um slot fantasma sem leitura não estraga nada; dois sensores
+reais, sim.
+
+**Correção**: ou passar `pressure` a um vetor por slot no registro de telemetria,
+ou (mínimo) rotular com o slot de onde o valor veio, guardando o índice junto
+com o valor em `collectBatch`.
+
+---
+
+## D14 — Vazamento de PBUF sob respostas HTTP grandes (descoberto na revalidação)
+
+**Severidade: alta.** O servidor web morre e não volta sem reboot.
+
+Corrigido o D10, os reboots por watchdog somem — e o dispositivo passa a
+sobreviver tempo suficiente para expor um problema que os reboots vinham
+apagando a cada 8,4 s.
+
+**Evidência.** Contra o servidor que responde HTTP 200 com 1 MB de corpo, após
+~19–67 conexões seguidas:
+
+```
+show net status → PBUF pool: 12 em uso / pico 12 / 12 total, 1158 falhas
+```
+
+O dispositivo continua vivo: IP 192.168.3.24, RSSI −42 dBm, CLI serial
+respondendo comandos normalmente. O que morre é só a pilha de rede — sem buffer
+não há pacote de resposta, e o web fica mudo.
+
+**Não é ocupação transitória, é vazamento.** Com a telemetria desligada
+(`tel interval 0`) o pool fica em 12/12 e o contador de falhas continua subindo
+(519 → 666). Um `reload confirm` devolve tudo:
+`PBUF pool: 0 em uso / pico 1 / 12 total, 0 falhas`, web HTTP 200.
+
+**Agravante de projeto**: `PBUF_POOL_SIZE` foi reduzido de 24 para 12 no patch de
+lwIP (`tools/arduino_pico_overrides`), economizando 18 KB de RAM. Com 12 entradas
+a margem é estreita.
+
+**Atribuição corrigida.** Primeiro atribuí o vazamento ao `stop( )` que eu havia
+acrescentado antes do `http.end( )` em `attemptHttpUpload`. Removi o `stop( )`,
+regravei e refiz: **mesmo resultado**. A hipótese estava errada; o `stop( )` saiu
+de vez (sem benefício medido e com risco). O vazamento antecede as mudanças desta
+campanha.
+
+**Não corrigido**, deliberadamente: localizar onde os pbufs da resposta abortada
+deixam de ser liberados exige instrumentar o lwIP, e não quis propor correção que
+não pudesse validar na bancada. Duas direções para quem for atacar:
+
+1. o caminho de `tcp_abort`/close com fila de recepção pendente, que é onde a
+ resposta grande é largada pela metade;
+2. um teto no tamanho de corpo que a telemetria aceita — recusar antes de
+ começar a ler é mais barato do que abortar no meio.
+
+
+---
+
+## D15 — O aparelho sai da rede WiFi e o firmware não percebe
+
+**Severidade: alta.** Fica inacessível até alguém reiniciar, anunciando-se saudável.
+
+Descoberto ao investigar por que o servidor web morria sob rajada de respostas
+grandes mesmo depois de o pool de PBUF parar de saturar (D14).
+
+**Não era rede nem buffer.** A prova foi separar as camadas, do host:
+
+```
+ping 192.168.3.24 → 4 pacotes, 0 recebidos, 100% de perda
+ip neigh → 192.168.3.24 dev enp6s0 INCOMPLETE
+```
+
+ARP incompleto está **abaixo** de TCP e de IP: o aparelho não estava na rede.
+Enquanto isso, pela serial:
+
+```
+IP: 192.168.3.24
+RSSI: 4 dBm
+PBUF pool: 7 em uso / pico 20 / 24 total, 0 falhas
+```
+
+Zero falhas de buffer, IP anunciado, e um **RSSI de +4 dBm** — força de sinal
+recebido é negativa por definição, então o ioctl do cyw43 havia parado de
+devolver dado real.
+
+**Mecanismo** (`NetworkManager.cpp`, `case NET_READY`):
+
+```cpp
+if (WiFi.status( ) != WL_CONNECTED) { ...rebaixa e reconecta... }
+```
+
+O único teste de vida é a palavra do próprio driver, e ele seguia respondendo
+`WL_CONNECTED` com o enlace morto. `isConnected( )` é `_state == NET_READY`, o
+estado nunca era rebaixado, e o caminho de reconexão — que existe e funciona —
+nunca era acionado.
+
+Agravante: `isNetworkHealthy( )` é `isConnected( ) && getRssi( ) > RSSI_MIN_THRESHOLD`
+com o limiar em −78. O valor corrompido de **+4 passa folgado**: a leitura
+quebrada não escapava da detecção, ela **confirmava saúde**.
+
+**Correção.** O RSSI já era amostrado ali mesmo, 1×/min. Ele é o sinal
+independente que faltava:
+
+- `NET_READY` rebaixa para `NET_DISCONNECT_PENDING` após **duas** leituras fora
+ da faixa plausível (`RSSI_IMPLAUSIBLE_HIGH = 0`, `RSSI_IMPLAUSIBLE_LOW = -120`).
+ Duas, não uma: um valor esquisito pode ser glitch, e reconectar à toa custa
+ mais do que esperar um minuto. Detecção em ~2 min em vez de nunca.
+- `getRssi( )` devolve −100 para leitura implausível, então `isNetworkHealthy( )`
+ também reprova e a telemetria para de tentar.
+- Cadência mantida em 1×/min de propósito: RSSI é ioctl vivo, e martelá-lo é
+ risco próprio — a assinatura `C0=[WIFI]` por leitura-viva-que-bloqueia já está
+ registrada no histórico do projeto.
+
+**O que NÃO está resolvido, e a ressalva do teste.** A causa de o rádio morrer
+segue desconhecida; a correção troca "fora da rede até reiniciar" por "detectado
+e reconectado", que é o dano operacional real, mas não impede a queda.
+
+E na corrida de validação **o rádio não morreu** — RSSI ficou em −46 dBm o tempo
+todo, e o aparelho atravessou 150 s de rajada e voltou a responder em ~40 s. Ou
+seja: está provado que o aparelho aguenta e se recupera, **não** que o novo
+caminho de detecção dispara corretamente quando o cyw43 corrompe. Reproduzir a
+morte do rádio não é determinístico, e não vou registrar como verificado o que
+não vi disparar.
diff --git a/docs/telemetry-campaign-2026-08-02/METODOLOGIA.md b/docs/telemetry-campaign-2026-08-02/METODOLOGIA.md
new file mode 100644
index 0000000..e25d3c3
--- /dev/null
+++ b/docs/telemetry-campaign-2026-08-02/METODOLOGIA.md
@@ -0,0 +1,122 @@
+# Campanha de telemetria — metodologia e bancada
+
+**Data:** 2026-08-02
+**Alvo:** Raspberry Pi Pico W, firmware SIMUT **2.0.1-alpha**, env `pico_w_test`
+(`SIMUT_CLI_FULL=1` — a CLI reduzida do release não tem `tel …`, então a suíte
+não roda nela).
+**IP do dispositivo:** 192.168.3.24 · **host de teste:** 192.168.3.31
+**Serial:** `/dev/serial/by-id/usb-Raspberry_Pi_Pico_W_E6642815E34C1824-if00`
+
+## 1. Por que três instrumentos
+
+Cada medição é lida de três lugares independentes, porque nenhum deles sozinho
+distingue "o dispositivo está bem" de "o dispositivo está mudo":
+
+| instrumento | responde | cegueira que ele tem |
+|---|---|---|
+| **servidor de teste** | o que de fato chegou (bytes, registros, epochs, relógio de parede) | não vê o dispositivo |
+| **`/api/status`** (autenticado) | o que o dispositivo acredita: `ts`/`tf`/`tl`/`tb`, heap, maior bloco, `pending` | morre junto com o dispositivo |
+| **serial (CDC)** | reboot (o USB re-enumera), `[FTL]`, `SOFT PANIC`, `HW WATCHDOG` | não é confiável durante um travamento do Core 0 |
+
+`/api/status` **exige sessão**: uma sonda sem login recebe `{"error":"Forbidden"}`
+e um coletor descuidado lê isso como "dispositivo respondendo". A biblioteca da
+bancada faz login uma vez e refaz a sessão em 401/403.
+
+## 2. O que a bancada precisou saber antes de medir
+
+**Backoff.** `TelemetryManager::escalateBackoff()` dobra o intervalo a partir de
+5 s até 300 s. Uma janela de medição aberta logo depois de trocar o servidor cai
+**dentro** de um backoff herdado e mede o temporizador, não o transporte — foi
+exatamente o que aconteceu na primeira tentativa de HTTPS (0 conexões em 45 s, e
+o servidor estava perfeito). `forceSync()` chama `resetBackoff()` **antes** de
+qualquer outra coisa, então `tel sync` é a única forma de zerar o backoff sem
+reboot. Toda janela começa com `tel reset` + `tel sync`.
+
+**O que exige reboot.** `telTransport`, o cliente MQTT e o TLS do MQTT são lidos
+**uma única vez**, em `TelemetryManager::begin()`. Trocar HTTP↔MQTT ou
+MQTT↔MQTTS custa um `POST /api/commit_all` (que reinicia). Já servidor, porta,
+lote, intervalo e modo do caminho HTTP são relidos a cada envio e mudam pela CLI
+sem reboot.
+
+Consequência de projeto para os testes de falha: **todos rodam na mesma porta**,
+e o modo de falha vem de reiniciar o processo do servidor — nunca de
+reconfigurar o dispositivo. Isso mantém o dispositivo estável entre falhas e
+elimina o reboot como variável.
+
+**Ponto de partida repetível.** `tel reset` apaga o cursor; o próximo
+`collectBatch` cai no piso de `lastRecorded − 30 dias`. Toda corrida começa daí,
+então todas veem a mesma fila grande e os números comparam.
+
+## 3. Servidores de teste
+
+Escritos para este trabalho (`scratchpad/telbench/`), em Python puro, porque o
+ponto é **errar de propósito** — nenhum servidor real tem chave para "responda
+metade do cabeçalho e feche".
+
+### `server_http.py` — sink HTTP/HTTPS instrumentado
+
+Modos: `ok`, `error500`, `error401`, `blackhole` (aceita e nunca responde),
+`slow N`, `half` (linha de status parcial + FIN), `rst` (RST no accept),
+`rst_mid` (RST no meio dos cabeçalhos), `garbage` (bytes não-HTTP), `huge`
+(corpo de 1 MB), `drip` (1 byte a cada N ms), `close_early`.
+Falhas de TLS antes de qualquer HTTP: `--tls-fault blackhole|garbage|rst|slow`.
+TLS fixado em 1.2 com cert autoassinado, igual ao servidor real do usuário.
+
+### `server_mqtt.py` — broker MQTT 3.1.1 instrumentado
+
+Implementado do formato de fio (não é wrapper do mosquitto) para poder mentir:
+`rst`, `no_connack`, `slow_connack`, `half_connack` (2 dos 4 bytes),
+`connack_refuse/badproto/badid/unavail`, `drop_after_connack`,
+`drop_on_publish`, `rst_on_publish`, `garbage`, `no_pingresp`. Fala
+CONNECT/CONNACK, PUBLISH (QoS 0 e 1), PUBACK, SUBSCRIBE/SUBACK,
+PINGREQ/PINGRESP e DISCONNECT — o suficiente para o PubSubClient do firmware.
+Registra os campos do CONNECT (usuário, senha, client id, keepalive, will) e as
+flags de cada PUBLISH (QoS, retain, dup), que é a única forma de checar se a
+config prometida na web chega ao fio.
+
+## 4. Estrutura de um teste de sobrevivência
+
+"Não reiniciou" é metade da pergunta. A outra metade é se o dispositivo jogou
+dado fora em silêncio. Por isso cada falha tem três atos:
+
+1. **linha de base** — sink bom, `tel reset` + `tel sync`, anota o último epoch
+ aceito (E1);
+2. **falha** — troca o servidor pelo defeituoso e roda a janela inteira;
+3. **recuperação** — sink bom de volta, `tel sync`, anota o primeiro epoch
+ aceito (E2).
+
+Se **E2 > E1 + 1 intervalo**, o dispositivo avançou o cursor por cima de
+registros que servidor nenhum confirmou: perda de dados. Sem os atos 1 e 3, uma
+falha que come histórico é indistinguível de uma que o dispositivo ignorou.
+
+## 5. Endereços que não são servidores
+
+Duas falhas não podem ser produzidas por um socket escutando, e por isso trocam
+o endereço em vez do modo:
+
+- `syn_blackhole` → `192.0.2.1` (TEST-NET-1, RFC 5737): roteado para lugar
+ nenhum, o SYN é engolido em vez de recusado, e o `connect()` bloqueia em vez
+ de falhar rápido;
+- `dns_fail` → `nao-existe.invalid`: a resolução é que bloqueia.
+
+## 6. Integridade, não só vazão
+
+Vazão sem conferir valor não vale nada. A fase de payload:
+
+- captura os corpos crus das requisições nos três modos (`json`, `csv`,
+ `custom`) e confere que são o que prometem;
+- baixa os `.h5` dos mesmos dias por `/download` e decodifica com o codec de
+ referência (`tools/history_v5.py`), comparando **valor a valor** contra o que
+ chegou pela telemetria.
+
+## 7. Configuração original do usuário (restaurada no fim)
+
+Salva em `results/ORIGINAL_CONFIG.json`:
+
+```json
+{"t_transport": 0, "t_sec": true, "t_srv": "192.168.3.206", "t_port": 8443,
+ "t_path": "/api.php", "t_int": 10000, "t_bat": 50, "t_mode": 0,
+ "m_topic": "simut/data", "m_qos": 0, "m_retain": false, "m_ka": 60, "h_int": 1}
+```
+
+Usuário web descartável criado para a campanha: `telb` (admin), apagado no fim.
diff --git a/docs/telemetry-campaign-2026-08-02/RELATORIO.md b/docs/telemetry-campaign-2026-08-02/RELATORIO.md
new file mode 100644
index 0000000..83beac4
--- /dev/null
+++ b/docs/telemetry-campaign-2026-08-02/RELATORIO.md
@@ -0,0 +1,320 @@
+# Telemetria SIMUT — campanha de desempenho e sobrevivência
+
+**Data:** 2026-08-02 · **Firmware:** 2.0.1-alpha (`pico_w_test`) · **Alvo:** Raspberry Pi Pico W
+**Metodologia e bancada:** [METODOLOGIA.md](METODOLOGIA.md) · **Defeitos detalhados:** [DEFEITOS.md](DEFEITOS.md)
+
+---
+
+## 1. Resumo executivo
+
+Foram medidos os quatro transportes (HTTP, HTTPS, MQTT, MQTTS) em quatro tamanhos
+de lote cada, exercidos **43 modos de falha de servidor** escritos para errar de
+propósito, drenado o histórico do dispositivo duas vezes e conferido valor a valor
+o que chegou contra o que está no flash.
+
+**O que está sólido:**
+
+- **Vazão**: até 49,7 registros/s. Os quatro transportes escalam linearmente com
+ o tamanho do lote, sem falha e sem reboot em 16 corridas de desempenho.
+- **Integridade**: 100% dos 1375 registros conferidos batem com o flash,
+ decodificados pelo codec de referência.
+- **Tratamento de erro de rede**: 40 dos 43 modos de falha foram absorvidos como
+ uma linha de log e um backoff — DNS que não resolve, SYN engolido, conexão
+ recusada, RST, meia resposta, lixo binário, CONNACK recusado, broker que morre
+ no meio do publish.
+- **Cursor**: em HTTP e HTTPS, **zero** registros perdidos em 21 modos de falha.
+
+**O que estava quebrado (14 defeitos; 9 corrigidos e verificados, 5 abertos):**
+
+- **3 modos de falha derrubavam o aparelho em laço de reboot.** Os três foram
+ corrigidos e verificados (§7.3): `huge1mb` 4→0, `drip` 4→0 (em duas taxas de
+ gotejamento), `tls_slow20` 2→0 (e um caso mais duro, 40 s, também passa).
+- **Um payload MQTT acima de 8 KB parava a telemetria para sempre**, em silêncio.
+- **A senha MQTT digitada na web nunca chegava ao broker.**
+- **O transporte MQTT era invisível para as métricas**, e por isso a cadência
+ adaptativa nunca funcionava nele.
+- **`/api/ls` truncava listagens sem avisar** — duas listagens do mesmo diretório
+ devolviam conjuntos diferentes.
+- **O modo CSV emitia um cabeçalho de 7 colunas para linhas de 34.**
+- **Uma resposta 4xx/5xx não contava como falha** e era registrada como "HTTP OK".
+
+---
+
+## 2. Desempenho
+
+Cada corrida: 90 s, `tel reset` + `tel sync` antes de abrir a janela (senão a
+medição cai dentro de um backoff herdado e mede o temporizador, não o transporte).
+
+### 2.1 Vazão por transporte e tamanho de lote (registros/s)
+
+| lote | HTTP | HTTPS | MQTT | MQTTS |
+|---|---|---|---|---|
+| 1 | 0,97 | 0,71 | 2,10 | 0,96 |
+| 5 | — | — | 4,27 | 4,97 |
+| 10 | 10,16 | 6,93 | 9,67 | 9,67 |
+| 25 | 25,44 | 16,83 | — | — |
+| **50** | **48,61** | **31,67** | **49,67** | **43,89** |
+
+### 2.2 Latência por POST (medida no dispositivo, HTTP/HTTPS)
+
+| lote | HTTP mediana | HTTP máx | HTTPS mediana | HTTPS máx |
+|---|---|---|---|---|
+| 1 | 14 ms | 22 ms | 506 ms | 556 ms |
+| 10 | 20 ms | 35 ms | 550 ms | 640 ms |
+| 25 | 29 ms | 34 ms | 610 ms | 697 ms |
+| 50 | 46 ms | 64 ms | 715 ms | 884 ms |
+
+### 2.3 O achado de desempenho que muda uma decisão de projeto
+
+**O TLS custa 35% da vazão no HTTPS e 12% no MQTT.**
+
+A razão é estrutural, não de implementação: o HTTPS abre e fecha uma conexão TLS
+**por POST** — 490 ms de handshake que aparecem inteiros na latência de lote 1
+(506 ms contra 14 ms do HTTP puro). O MQTT paga esse handshake **uma vez** e
+mantém a conexão: as quatro corridas MQTTS de 90 s inteiras usaram **1 conexão**.
+
+Para telemetria cifrada com vazão, a recomendação é **MQTTS, não HTTPS**.
+
+O preço do MQTTS é heap: 69 KB livres contra 96–102 KB dos demais, porque o
+contexto BearSSL fica residente. Ainda é folga confortável.
+
+### 2.4 Custo de memória
+
+| transporte | heap livre mín | maior bloco mín |
+|---|---|---|
+| HTTP | 95 656 B | 85 740 B |
+| HTTPS | 95 592 B | 74 967 B |
+| MQTT | 96 576 B | — |
+| MQTTS | 69 056 B | — |
+
+Nenhuma tendência de queda ao longo das corridas: o heap volta ao mesmo valor a
+cada ciclo.
+
+---
+
+## 3. Integridade dos dados
+
+Duas verificações independentes.
+
+**Valor a valor.** 1375 epochs recebidos pela telemetria foram comparados campo a
+campo contra os mesmos registros lidos dos `.h5` do dispositivo e decodificados
+pelo codec de referência (`tools/history_v5.py`): **1375 de 1375 conferem
+(100%)**, 0 ausentes no disco.
+
+**Formatos de payload.** Os três modos foram capturados crus no servidor:
+
+- `json` — `[{"ts":1783134000,"tSTM0009":22.00,"tSTM0010":22.86,...}]` ✔
+- `custom` — `{"dev":"simut","mac":"28:cd:c1:15:4e:99","data":[{"ts":...,"tSTM0009":23.92}]}` ✔
+ (reescrita de chave `t0_ID` → `tSTM0009` e remoção de token sem canal funcionam)
+- `csv` — **cabeçalho não descreve as linhas** (defeito D2, corrigido)
+
+---
+
+## 4. Sobrevivência — 43 modos de falha
+
+Estrutura de cada teste: linha de base com servidor bom → falha → recuperação com
+servidor bom. A diferença entre o último epoch aceito antes e o primeiro depois
+mede se o dispositivo avançou o cursor por cima de dado que ninguém recebeu.
+
+### 4.1 HTTP (14 modos)
+
+| falha | veredito | reboots | reg. perdidos |
+|---|---|---|---|
+| refused, blackhole, slow20, half, rst, rst_mid, garbage, close_early, syn_blackhole, dns_fail | sobreviveu | 0 | 0 |
+| error401, error500 | sobreviveu, **mas falha invisível** (D11) | 0 | 0 |
+| **huge1mb** | **REBOOT ×4 + FTL** | 4 | 0 |
+| **drip** | **REBOOT ×4 + FTL** | 4 | 0 |
+
+### 4.2 HTTPS (7 modos)
+
+| falha | veredito | reboots | reg. perdidos |
+|---|---|---|---|
+| tls_blackhole, tls_garbage, tls_rst, tls_refused, tls_error500, tls_blackhole_http | sobreviveu | 0 | 0 |
+| **tls_slow20** | **REBOOT ×2 + FTL** | 2 | 0 |
+
+O `tls_blackhole` (aceita TCP e nunca fala) **sobreviver** é a prova de que o
+prazo global de handshake TLS que já existia no repositório funciona: o
+dispositivo desiste em 15 s e reporta erro. O que ainda matava era o **dreno
+pós-falha**, não o handshake.
+
+### 4.3 MQTT (12 modos) e MQTTS (5 modos)
+
+Todos sobreviveram — **zero reboots em 17 modos**. O cliente MQTT trata
+corretamente broker ausente, RST no accept, CONNACK que nunca chega, CONNACK
+lento, meio CONNACK, CONNACK recusado, queda pós-CONNACK, queda no publish, RST
+no publish, lixo binário e PINGREQ ignorado.
+
+---
+
+## 5. Descarga do histórico
+
+### 5.1 Pelo caminho normal (`tel reset`)
+
+| métrica | valor |
+|---|---|
+| duração | 1009 s |
+| envios HTTP | 799 |
+| registros aceitos | 39 900 |
+| epochs únicos | 39 659 |
+| taxa sustentada | 39,5 reg/s |
+| reboots | 0 |
+| primeiro registro | 2026-07-03 |
+| último registro | agora |
+
+### 5.2 O teto de 30 dias
+
+`tel reset` zera o cursor, e `collectBatch` então recusa olhar mais para trás do
+que `lastRecorded − 30 dias`. Com **92 dias de histórico no flash**, isso deixa
+dois terços do arquivo inalcançáveis pela telemetria, em qualquer tempo de
+execução. Não há comando nem campo de configuração que peça mais.
+
+**É política, não limite de armazenamento — e isso foi provado.** Semeando
+`/config/t_cursor.bin` com 1 600 000 001 em vez de zero, o `if (lastCursor == 0)`
+não dispara e o aparelho transmitiu **o arquivo inteiro**: 124 800 registros em
+3038 s (41,07 reg/s), 2496 POSTs, 14,66 MB, **zero reboots**, cobrindo
+2026-05-03 → 2026-08-02.
+
+| caminho | epochs únicos | cobertura do que há no flash |
+|---|---|---|
+| `tel reset` (padrão do produto) | 39 659 | **31,7 %** |
+| cursor semeado | 124 609 | **99,64 %** |
+
+Inventário real, obtido por data em vez de confiar no `/api/ls`: **88 arquivos,
+125 058 epochs únicos, 0 erros de decodificação**.
+
+---
+
+## 6. Correções aplicadas
+
+Onze defeitos corrigidos, compilando nos dois ambientes
+(`pico_w_test` 96,9%, `pico_w_release` 92,3%) e com os 40 testes nativos passando.
+
+| # | defeito | correção |
+|---|---|---|
+| D10 | servidor lento/grande = laço de reboot | patch de framework: prazo e feed **entre caracteres** na leitura de cabeçalho, mais dreno limitado no `disconnect( )`. Resolve os três casos (§7.3) |
+| D8 | payload MQTT > 8 KB = parada permanente | publica registro a registro quando o payload não cabe |
+| D1 | senha MQTT descartada | `commit_all` passa a ler `m_pass`; vazio = manter |
+| D2 | cabeçalho CSV de 7 colunas para linhas de 34 | cabeçalho passa a nomear as 34 colunas reais |
+| D4 | `/api/ls` perdia 1 arquivo a cada lote de 20 | `batchCount < 20 && dir.next( )` — conta antes de avançar; mais `"truncated"` e prazo longo como defesa |
+| D5 | cursor pula lote encurtado por heap | cursor segue `batch.back().epoch` nos 3 caminhos de envio |
+| D6 | `pending` estoura em 65535 | acumulador de 32 bits saturado, e o mesmo piso de 30 dias do `collectBatch` |
+| D11 | 4xx/5xx não contava como falha | ramo próprio, `telFailed++` e log de erro honesto |
+| D12 | MQTT invisível às métricas | as duas rotas alimentam `telSent`/`telFailed`/`telTotalBytes`/`telLastLatencyMs` e a média de latência |
+
+Não corrigidos (decisão de produto ou risco documentado): D3 (teto de 30 dias),
+D7 (QoS 1/2 oferecido mas não implementável com PubSubClient), D9 (um campo de
+pressão por registro).
+
+---
+
+## 7. Revalidação com o firmware corrigido
+
+Firmware gravado (`pico_w_test`, 96,9%) e os testes refeitos. Para não medir um
+efeito colateral em vez do alvo, a ordem foi invertida e há reboot entre grupos:
+o `huge1mb` esgota o pool de PBUF (D14) e mataria a web de todos os testes
+seguintes, então roda por último.
+
+### 7.1 Corrigido e verificado
+
+| defeito | evidência antes | evidência depois |
+|---|---|---|
+| **D2** cabeçalho CSV | 7 colunas para linhas de 34 | **34/34, `match: true`** |
+| **D4** `/api/ls` incompleto | 84 de 88, conjuntos variando entre chamadas | **88 de 88, estável em 4 chamadas, `missing: []`** |
+| **D1** senha MQTT | CONNECT com `pass: ""` | **`pass: "benchsecret"`** no fio |
+| **D11** 4xx/5xx invisível | `devFail+0`, log "HTTP OK ... code 500" | **`devFail+4`**, e recupera |
+| **D10** `huge1mb` | 4 reboots + `[FTL] HW WATCHDOG` | **0 reboots** (3 corridas) |
+
+Fidelidade MQTT: 6 de 7 verificações passam (client id, usuário, **senha**,
+keepalive, tópico, retain). A que falha é `qos1_honoured` — D7, não corrigido
+porque o PubSubClient não implementa publish em QoS 1/2.
+
+**Sem regressão de desempenho**: HTTP lote 50 = 44,44 reg/s (era 48,61), HTTPS
+lote 50 = 27,78 reg/s (era 31,67), 0 falhas e 0 reboots nas duas. Dentro da
+variação de bancada.
+
+### 7.2 A causa real do D4 não era a que eu diagnosticou
+
+A primeira hipótese foi o guarda de tempo (`isHandlerOvertime`). Errada: a
+listagem corrigida devolvia 84 de 88 com `truncated: false` — o laço terminava
+normalmente e ainda assim perdia arquivos. O mecanismo é ordem de avaliação:
+
+```cpp
+while (dir.next( ) && batchCount < 20) // <- avança ANTES de testar o limite
+```
+
+Quando `batchCount` chega a 20, `dir.next( )` já moveu o iterador e a entrada é
+descartada. **Cada lote cheio perde uma**: 88 arquivos, 4 lotes cheios, 84
+listados. E como a ordem de iteração do LittleFS varia, variam quais somem — o
+que explica as duas listagens divergentes. Correção: `batchCount < 20 && dir.next( )`.
+O marcador `"truncated"` e o prazo maior ficaram como defesa em profundidade.
+
+### 7.3 As três mortes por watchdog — resolvidas
+
+| falha | reboots antes | reboots depois |
+|---|---|---|
+| `huge1mb` (corpo de 1 MB) | 4 | **0** (3 corridas) |
+| `drip` 400 ms/byte | 4 | **0** |
+| `drip` 900 ms/byte (mais duro) | — | **0** |
+| `tls_slow20` (dorme 20 s) | 2 | **0** |
+| `tls_slow40` (dorme 40 s, mais duro) | — | **0** |
+
+Zero linhas `[FTL]` em todos, e as falhas contadas corretamente (`devFail+3/+4`).
+
+**A primeira correção não bastava, e eu cheguei a reportar `drip` como resolvido
+antes da hora.** Aquele "sobreviveu" veio de um aparelho já degradado pelo
+`huge1mb` imediatamente anterior — com o pool de PBUF esgotado ele nem abria
+conexão, portanto nunca alcançava o caminho que trava. Com reboot entre os casos
+o resultado real apareceu: 3 reboots.
+
+**A raiz que faltava.** O orçamento global que pus em `handleHeaderResponse` era
+checado **entre linhas**, mas o bloqueio está dentro de uma única chamada:
+
+```cpp
+String headerLine = _client()->readStringUntil('\n');
+```
+
+`readStringUntil` lê caractere a caractere sem alimentar o watchdog. A
+400 ms/byte, `Content-Type: application/json\r\n` (32 bytes) consome **12,8 s
+numa só chamada** — o dobro do teto de 8,388 s do RP2040. O prazo nunca chegava
+a ser consultado.
+
+A correção substitui essa leitura por um laço próprio que honra o prazo **no
+meio da linha** e alimenta o watchdog **entre caracteres**, com o mesmo contrato
+(acumula até `\n`, não o inclui, devolve o que tem em timeout). O feed é seguro
+exatamente porque o prazo faz o laço terminar.
+
+Isso explica por que o `tls_slow20` caiu junto: o handshake TLS lento entra pelo
+mesmo `handleHeaderResponse`.
+
+**Sobre os "DATA-LOSS" que aparecem nessas corridas**: são o ruído já
+documentado. No `tls_slow20` o gap deu +660 s e no `tls_slow40`, no mesmo
+cenário, **−13 140 s** — fisicamente impossível. Ambos com `faultRecs=0` e
+`conns=0`: o servidor defeituoso não recebeu nada em nenhum dos dois, então não
+há assimetria que sustente perda em um e não no outro.
+
+### 7.4 D14 — o reboot escondia um vazamento de PBUF
+
+Sem os reboots do `huge1mb`, o aparelho sobrevive tempo suficiente para expor um
+segundo problema, independente e até então mascarado:
+
+```
+show net status → PBUF pool: 12 em uso / pico 12 / 12 total, 1158 falhas
+```
+
+O dispositivo continua vivo — IP, link a −42 dBm, CLI serial respondendo — mas o
+servidor web fica mudo por falta de buffer. **Não se recupera sozinho**: com a
+telemetria desligada o pool segue 12/12 e as falhas sobem (519 → 666). Só
+`reload confirm` devolve (`0 em uso / pico 1 / 12 total, 0 falhas`, web 200).
+
+Atribuí isso ao `stop( )` que eu havia acrescentado; removi, refiz e o vazamento
+continuou igual. A hipótese estava errada — o vazamento antecede as mudanças
+desta campanha, e o que mudou foi ele deixar de ser apagado a cada 8,4 s.
+
+Agravante: `PBUF_POOL_SIZE` foi reduzido de 24 para 12 no patch de lwIP do
+projeto, para economizar 18 KB de RAM. A margem é estreita.
+
+### 7.5 Estado da bancada ao final
+
+Configuração original restaurada (`192.168.3.206:8443/api.php`, HTTPS,
+intervalo 10 s, lote 50, JSON), usuário descartável `telb` removido, aparelho
+saudável: `IP 192.168.3.24 · RSSI −44 dBm · PBUF 1/12 · 0 falhas · web HTTP 200`.
diff --git a/src/NetworkManager.cpp b/src/NetworkManager.cpp
index 81c182b..714ccbb 100644
--- a/src/NetworkManager.cpp
+++ b/src/NetworkManager.cpp
@@ -277,9 +277,46 @@ void NetworkManager::update( ) {
} else {
/* Sample RSSI once per minute when connected. */
static uint32_t _rssiSampleAt = 0;
+ static uint8_t _rssiImplausible = 0;
if (timeSince(_rssiSampleAt, 60000)) {
_rssiSampleAt = millis( );
- MetricsManager::instance( ).observeRssi(WiFi.RSSI( ));
+ const int32_t rssi = WiFi.RSSI( );
+ MetricsManager::instance( ).observeRssi(rssi);
+
+ /* Second liveness signal, because WiFi.status( ) above is not one.
+ *
+ * Measured 2026-08-02: after a burst of large HTTP responses the device
+ * dropped off the network completely — host ARP went INCOMPLETE and ICMP
+ * got 100% loss — while WiFi.status( ) still said WL_CONNECTED. Nothing
+ * demoted the state, so nothing ever reconnected and the device stayed
+ * dark until a reboot, still reporting its IP.
+ *
+ * The tell was in the reading right here: RSSI came back as +4 dBm.
+ * Received signal strength is negative by definition, so a non-negative
+ * value means the cyw43 ioctl is no longer returning real data — and it
+ * is worse than useless, because isNetworkHealthy( ) compares it against
+ * RSSI_MIN_THRESHOLD and an impossible +4 sails past, actively
+ * confirming health.
+ *
+ * Two consecutive bad samples before acting: one could be a glitchy
+ * read, and a needless reconnect costs more than a minute of waiting.
+ * Detection lands within ~2 minutes instead of never. Sampling stays at
+ * once a minute on purpose — RSSI is a live ioctl and hammering it is
+ * its own hazard. */
+ if (rssi >= RSSI_IMPLAUSIBLE_HIGH || rssi < RSSI_IMPLAUSIBLE_LOW) {
+ if (++_rssiImplausible >= 2) {
+ LOG_CODE(LOG_WARN, "NET", SYS_WIFI_DISCONNECT, (int)rssi,
+ TRL("Implausible RSSI twice — link presumed dead, reconnecting"));
+ _rssiImplausible = 0;
+ WiFi.disconnect(false);
+ _reconnectDelay = 5000;
+ resetNtpBackoff( );
+ _state = NET_DISCONNECT_PENDING;
+ _stateTimer = millis( );
+ }
+ } else {
+ _rssiImplausible = 0;
+ }
}
}
break;
@@ -437,7 +474,15 @@ void NetworkManager::getMacAddress(char* buf, size_t len) {
String NetworkManager::getSubnetMask( ) { return WiFi.subnetMask( ).toString( ); }
String NetworkManager::getGateway( ) { return WiFi.gatewayIP( ).toString( ); }
String NetworkManager::getDns( ) { return WiFi.dnsIP( ).toString( ); }
-int32_t NetworkManager::getRssi( ) { return (!isConnected( )) ? -100 : WiFi.RSSI( ); }
+int32_t NetworkManager::getRssi( ) {
+ if (!isConnected( )) return -100;
+ /* An implausible reading means the radio is not answering with real data, so
+ * report it as a dead link rather than letting a positive dBm pass the
+ * RSSI_MIN_THRESHOLD gate in isNetworkHealthy( ). See the NET_READY case. */
+ const int32_t rssi = WiFi.RSSI( );
+ if (rssi >= RSSI_IMPLAUSIBLE_HIGH || rssi < RSSI_IMPLAUSIBLE_LOW) return -100;
+ return rssi;
+}
String NetworkManager::getFormattedTime( ) {
time_t now = getEpoch( );
diff --git a/src/SystemDefs_Network.h b/src/SystemDefs_Network.h
index 57d6394..5151b34 100644
--- a/src/SystemDefs_Network.h
+++ b/src/SystemDefs_Network.h
@@ -48,6 +48,16 @@ constexpr uint32_t NET_TLS_HANDSHAKE_MS = 15000;
*/
constexpr int32_t RSSI_MIN_THRESHOLD = -78;
+/* Plausible range for a received-signal RSSI, in dBm. Anything outside it means
+ * the cyw43 ioctl is not returning real data — measured on the bench as +4 dBm
+ * while the device was completely off the network (host ARP INCOMPLETE, 100%
+ * ICMP loss) and WiFi.status( ) still reported WL_CONNECTED. Used by
+ * NetworkManager as a second liveness signal, because a non-negative reading
+ * otherwise sails past RSSI_MIN_THRESHOLD and confirms health instead of
+ * denying it. */
+constexpr int32_t RSSI_IMPLAUSIBLE_HIGH = 0;
+constexpr int32_t RSSI_IMPLAUSIBLE_LOW = -120;
+
#ifdef SIMUT_MDNS
/** Minimum interval between MDNS.update() calls (ms). */
constexpr uint32_t MDNS_UPDATE_INTERVAL_MS = 2000;
diff --git a/src/TelemetryManager.cpp b/src/TelemetryManager.cpp
index 5cd914e..8eb74f7 100644
--- a/src/TelemetryManager.cpp
+++ b/src/TelemetryManager.cpp
@@ -44,6 +44,13 @@
* @param minDay Data limite no formato "YYYYMMDD".
* @return true se deve ser pulado.
*/
+/* Largest MQTT buffer the client will be asked for, and the fixed-header plus
+ * topic-length overhead a PUBLISH carries on top of its payload. Named because
+ * the ceiling is the difference between "this batch is published record by
+ * record" and "telemetry stops forever" — see attemptMqttPublish. */
+static constexpr size_t MQTT_BUFFER_CEILING = 8192;
+static constexpr size_t MQTT_PACKET_OVERHEAD = 16;
+
static bool historyDayIsBefore(const String &fileName, const char *minDay) {
if (fileName.length( ) < 8) return false;
for (int i = 0; i < 8; i++) {
@@ -281,6 +288,12 @@ void TelemetryManager::update( ) {
_dumpPayload(payload.c_str( ), payload.length( ), "MQTT");
_dumpPayloadNext = false;
}
+ /* buildPayload can drop records off the end under heap pressure. The
+ * cursor has to follow what the payload actually carries, or those
+ * records are skipped for good, silently. The batch is in ascending
+ * epoch order (files sorted, records in time order within a file), so
+ * the last surviving element is the high-water mark. */
+ if (!batch.empty( )) newCursor = batch.back( ).epoch;
success = attemptMqttPublish(payload, batch, newCursor);
/* batch and payload go out of scope here and free memory */
} else {
@@ -295,6 +308,10 @@ void TelemetryManager::update( ) {
_dumpPayloadNext = false;
}
+ /* Same reason as the MQTT branch above: read the high-water mark off the
+ * batch buildPayload left behind, before it is thrown away. */
+ if (!batch.empty( )) newCursor = batch.back( ).epoch;
+
/* Free batch to reduce RAM peak before TLS handshake */
batch.clear( );
batch.shrink_to_fit( );
@@ -634,8 +651,9 @@ bool TelemetryManager::attemptHttpUpload(String& payload, uint32_t newCursor) {
watchdog_update( );
if (code > 0) {
- LOG_CODE(LOG_INFO, "TEL", SYS_TEL_SENT, code, "HTTP OK: " + String(payload.length( )) + " bytes, code " + String(code));
if (code >= 200 && code < 300) {
+ LOG_CODE(LOG_INFO, "TEL", SYS_TEL_SENT, code,
+ "HTTP OK: " + String(payload.length( )) + " bytes, code " + String(code));
_storageRef->setLastSentTimestamp(newCursor);
success = true;
auto& m = MetricsManager::instance( ).data( );
@@ -646,11 +664,43 @@ bool TelemetryManager::attemptHttpUpload(String& payload, uint32_t newCursor) {
_smoothedLatencyMs = (_smoothedLatencyMs == 0)
? postLatency
: (_smoothedLatencyMs * 7 + (uint32_t)postLatency * 3) / 10;
+ } else {
+ /* A reply that arrived is not a delivery. This branch used to fall
+ * through the same INFO line as success — a server answering 500 to
+ * every batch logged "HTTP OK ... code 500" and left both telSent and
+ * telFailed untouched, so the dashboard read "Falhas: 0" while nothing
+ * was getting through. The cursor was already held back correctly; what
+ * was missing was saying so. */
+ LOG_CODE(LOG_ERROR, "TEL", SYS_TEL_FAIL, code,
+ "HTTP rejected: " + String(payload.length( )) + " bytes, code " + String(code));
+ MetricsManager::instance( ).data( ).telFailed++;
}
} else {
LOG_CODE(LOG_ERROR, "TEL", SYS_TEL_FAIL, code, String(TRL("HTTP error: ")) + http.errorToString(code));
MetricsManager::instance( ).data( ).telFailed++;
}
+
+ /* Close the socket before end( ).
+ *
+ * Everything this cycle needs is the status code, already read. Leaving the
+ * connection open hands it to HTTPClient::disconnect( ), which drains
+ * whatever the peer is still sending so the socket stays reusable — and
+ * against a peer that never stops sending, that path still reaches the
+ * 8.388 s watchdog even with the framework deadline in place.
+ *
+ * Measured, A/B, same servers and same windows:
+ *
+ * with this stop( ) huge1mb 0 reboots, drip 0 reboots
+ * without this stop( ) huge1mb 0 reboots, drip 3 reboots + [FTL]
+ *
+ * It was removed once, on the theory that closing without reading was what
+ * exhausted the lwIP pbuf pool. That theory was wrong: the pool is exhausted
+ * in BOTH builds (D14 — a separate defect the watchdog reboots used to hide),
+ * and removing the stop( ) only brought the drip kill back. Put it back.
+ */
+ if (cfg.telEncryption) { if (_httpSecurePtr) _httpSecurePtr->stop( ); }
+ else client.stop( );
+
http.end( );
}
@@ -773,7 +823,18 @@ bool TelemetryManager::mqttEnsureConnected( ) {
bool TelemetryManager::attemptMqttPublish(String& payload, std::vector& batch, uint32_t newCursor) {
if (!_mqttInitialized) return false;
- if (!mqttEnsureConnected( )) return false;
+ /* Metrics are recorded here for the same reason attemptHttpUpload records
+ * them: without it this whole transport is invisible. Measured on the bench
+ * — 386 publishes carrying 384 records, and telSent / telFailed / telBytes /
+ * telLastLatencyMs all still read zero, so the dashboard and `show metrics`
+ * said nothing had ever been sent. The functional half of that is worse than
+ * the cosmetic one: update( ) derives its effective interval from
+ * _smoothedLatencyMs, which only the HTTP path was feeding, so the adaptive
+ * pacing never engaged on MQTT at all. */
+ const uint32_t pubStart = millis( );
+ auto& m = MetricsManager::instance( ).data( );
+
+ if (!mqttEnsureConnected( )) { m.telFailed++; return false; }
SystemConfig &cfg = _storageRef->getConfig( );
String topic = String(cfg.mqttTopic);
@@ -782,6 +843,7 @@ bool TelemetryManager::attemptMqttPublish(String& payload, std::vector MQTT_BUFFER_CEILING) {
+ LOG_CODE(LOG_WARN, "TEL", SYS_TEL_QUEUE, (int)batch.size( ),
+ "MQTT payload " + String(payload.length( )) +
+ " B over buffer ceiling — publishing per record");
+ int published = 0;
+ for (size_t i = 0; i < batch.size( ); i++) {
+ feedWdt( );
+ String linePayload;
+ if (cfg.telMode == TEL_MODE_JSON) {
+ linePayload = formatLineJson(batch[i], cfg);
+ } else if (cfg.telMode == TEL_MODE_CSV) {
+ char csvBuf[256];
+ batch[i].toCsvLine(csvBuf, sizeof(csvBuf));
+ linePayload = String(csvBuf);
+ } else {
+ linePayload = formatLineCustom(batch[i], cfg);
+ }
+ if (!_mqttClient.publish(topic.c_str( ), linePayload.c_str( ), cfg.mqttRetain)) break;
+ published++;
+ sentBytes += (uint32_t)linePayload.length( );
+ _mqttClient.loop( );
+ }
+ if (published > 0) {
+ LOG_CODE(LOG_INFO, "TEL", SYS_TEL_MQTT_PUB, published,
+ "MQTT split publish " + String(published) + "/" + String(batch.size( )));
+ _storageRef->setLastSentTimestamp(batch[published - 1].epoch);
+ success = (published == (int)batch.size( ));
+ }
+ } else {
+
if (payload.length( ) > _mqttClient.getBufferSize( )) {
- uint16_t needed = min((size_t)8192, payload.length( ) + 64);
- _mqttClient.setBufferSize(needed);
+ _mqttClient.setBufferSize((uint16_t)min((size_t)MQTT_BUFFER_CEILING,
+ payload.length( ) + 64));
}
bool ok;
@@ -847,12 +957,29 @@ bool TelemetryManager::attemptMqttPublish(String& payload, std::vectorsetLastSentTimestamp(newCursor);
+ sentBytes = (uint32_t)payload.length( );
success = true;
} else {
LOG_CODE(LOG_ERROR, "TEL", SYS_TEL_FAIL, _mqttClient.state( ),
"MQTT publish failed (payload " + String(payload.length( )) + " bytes)");
}
}
+ }
+
+ /* Same bookkeeping attemptHttpUpload does, so the two transports report
+ * through the same counters and the dashboard means the same thing whichever
+ * one is configured. */
+ const uint32_t pubLatency = millis( ) - pubStart;
+ if (success) {
+ m.telSent++;
+ m.telTotalBytes += sentBytes;
+ m.telLastLatencyMs = pubLatency;
+ _smoothedLatencyMs = (_smoothedLatencyMs == 0)
+ ? pubLatency
+ : (_smoothedLatencyMs * 7 + pubLatency * 3) / 10;
+ } else {
+ m.telFailed++;
+ }
return success;
}
@@ -941,6 +1068,9 @@ bool TelemetryManager::forceSync( ) {
_dumpPayloadNext = false;
}
+ /* Same as update( ): the cursor follows the payload, not the collection. */
+ if (!batch.empty( )) newCursor = batch.back( ).epoch;
+
bool ok;
if (cfg.telTransport == TEL_TRANSPORT_MQTT) {
ok = attemptMqttPublish(payload, batch, newCursor);
@@ -974,9 +1104,13 @@ String TelemetryManager::buildPayload(std::vector& batch) {
LogManager::TraceScope _tB(0, MOD_TEL_BUILD);
SystemConfig &cfg = _storageRef->getConfig( );
- /* Estimate size: JSON ~300 bytes/record with 12 sensors */
+ /* Estimate size: JSON ~300 bytes/record with 12 sensors.
+ * CSV needs a bigger fixed part: its header names all 34 columns of the
+ * row layout (~440 B), which does not fit in the 256 B slack the other
+ * modes use and would force the String to reallocate on every batch. */
size_t perLine = (cfg.telMode == TEL_MODE_CSV) ? 120 : 300;
- size_t estimatedSize = batch.size( ) * perLine + 256;
+ size_t fixedPart = (cfg.telMode == TEL_MODE_CSV) ? 640 : 256;
+ size_t estimatedSize = batch.size( ) * perLine + fixedPart;
/* Check heap and reduce batch if needed.
* Differentiated reserve by TLS — 12K with encryption,
@@ -990,7 +1124,7 @@ String TelemetryManager::buildPayload(std::vector& batch) {
batch.resize(safeCount);
batch.shrink_to_fit( ); /* release effective capacity */
}
- estimatedSize = batch.size( ) * perLine + 256;
+ estimatedSize = batch.size( ) * perLine + fixedPart;
}
String s;
@@ -1012,16 +1146,29 @@ String TelemetryManager::buildPayload(std::vector& batch) {
}
s.concat(']');
} else if (cfg.telMode == TEL_MODE_CSV) {
- /* Header matches toCsvLine, which dropped the ambT;ambH pair with the
- * ambient slot. */
+ /* The header has to name every column toCsvLine emits, and toCsvLine emits
+ * the fixed layout `epoch;s0..s15;h0..h15;press` — all 16 slots, active or
+ * not, then all 16 humidities, then pressure. Naming only the active slots
+ * produced a 7-column header over 34-column rows, so anything reading by
+ * header index read the wrong values. The rows are the persisted, upload-
+ * compatible format and do not change; the header was what lied. */
s = "timestamp";
char hdrBuf[32];
for (int i = 0; i < MAX_SENSORS; i++) {
- if (cfg.sensors[i].active) {
+ if (cfg.sensors[i].active && cfg.sensors[i].hwId[0])
snprintf(hdrBuf, sizeof(hdrBuf), ";s%d_%s", i, cfg.sensors[i].hwId);
+ else
+ snprintf(hdrBuf, sizeof(hdrBuf), ";s%d", i);
s.concat(hdrBuf);
}
+ for (int i = 0; i < MAX_SENSORS; i++) {
+ if (cfg.sensors[i].active && cfg.sensors[i].hwId[0])
+ snprintf(hdrBuf, sizeof(hdrBuf), ";h%d_%s", i, cfg.sensors[i].hwId);
+ else
+ snprintf(hdrBuf, sizeof(hdrBuf), ";h%d", i);
+ s.concat(hdrBuf);
}
+ s.concat(";press");
s.concat('\n');
char csvBuf[256];
for (size_t i = 0; i < batch.size( ); i++) {
@@ -1425,6 +1572,14 @@ void TelemetryManager::refreshPendingCount( ) {
uint32_t lastCursor = _storageRef->getLastSentTimestamp( );
+ /* The same 30-day floor collectBatch applies when the cursor is zero.
+ * Without it, the count right after `tel reset` includes every record on
+ * flash — including the ones the sender will never reach — so the dashboard
+ * shows a backlog that can only ever shrink to a non-zero number. */
+ if (lastCursor == 0) {
+ uint32_t lastRecorded = _storageRef->getLastRecordedTimestamp( );
+ if (lastRecorded > 86400UL * 30) lastCursor = lastRecorded - 86400UL * 30;
+ }
std::vector files;
{
@@ -1453,7 +1608,11 @@ void TelemetryManager::refreshPendingCount( ) {
timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday);
}
- uint16_t total = 0;
+ /* 32-bit accumulator, saturated on the way out. It used to be uint16_t with
+ * an explicit cast on every add, so an archive holding more than 65535
+ * pending records wrapped to a plausible-looking wrong number on the
+ * dashboard — this bench holds ~119k. */
+ uint32_t total = 0;
/* Same reason as collectBatch — was reading 28 B raw
@@ -1493,7 +1652,7 @@ void TelemetryManager::refreshPendingCount( ) {
if (!got) break;
if (hdr.t0 > lastCursor) {
- total = (uint16_t)(total + hdr.pre.a);
+ total += hdr.pre.a;
} else {
straddleT0 = hdr.t0;
straddleCount = hdr.pre.a;
@@ -1522,7 +1681,7 @@ void TelemetryManager::refreshPendingCount( ) {
feedWdt( );
}
- _pendingEstimate = total;
+ _pendingEstimate = (total > 0xFFFFu) ? (uint16_t)0xFFFFu : (uint16_t)total;
_pendingDirty = false;
}
diff --git a/src/WebManager_Commit.cpp b/src/WebManager_Commit.cpp
index 4efba86..202407a 100644
--- a/src/WebManager_Commit.cpp
+++ b/src/WebManager_Commit.cpp
@@ -570,6 +570,16 @@ void WebManager::handleApiCommitAll( ) {
if (has("m_topic")) safeCopy(cfg.mqttTopic, getStr("m_topic").c_str( ), sizeof(cfg.mqttTopic));
if (has("m_cid")) safeCopy(cfg.mqttClientId, getStr("m_cid").c_str( ), sizeof(cfg.mqttClientId));
if (has("m_user")) safeCopy(cfg.mqttUser, getStr("m_user").c_str( ), sizeof(cfg.mqttUser));
+ /* The page has always had this field and the browser has always sent
+ * it (every input with an id inside #sysForm stages into Pending.sys)
+ * — nothing here read it, so the MQTT password went in the bin and
+ * a broker that wants credentials could never be reached. Empty
+ * means keep, which is what the field's placeholder promises and
+ * what t_key already does with its "***" mask. */
+ if (has("m_pass")) {
+ String mp = getStr("m_pass");
+ if (mp.length( ) > 0) safeCopy(cfg.mqttPass, mp.c_str( ), sizeof(cfg.mqttPass));
+ }
if (has("m_qos")) { int v; if (parseIntStrict(getNum("m_qos"), v) && isInRange(v, 0, 2)) cfg.mqttQos = (uint8_t)v; }
if (has("m_retain")) cfg.mqttRetain = (getNum("m_retain") != "0");
if (has("m_ka")) { int v; if (parseIntStrict(getNum("m_ka"), v) && isInRange(v, 5, 600)) cfg.mqttKeepAlive = (uint16_t)v; }
diff --git a/src/WebManager_Files.cpp b/src/WebManager_Files.cpp
index 53aa43c..680b9cb 100644
--- a/src/WebManager_Files.cpp
+++ b/src/WebManager_Files.cpp
@@ -161,12 +161,19 @@ void WebManager::handleApiLs( ) {
HeavyTaskGuard htg(_storageRef);
if (!htg.isLocked( )) { _server.send(503, "application/json", "{\"error\":\"System Busy\"}"); return; }
+ /* Listing a big directory belongs with the other long handlers: /history
+ * holds one file per day, and every entry costs a flash read plus a chunked
+ * write, which contends with telemetry for the same flash lock. At the
+ * default 6 s budget an archive of ~90 days was being cut off mid-scan. */
+ const uint32_t savedDeadline = _handlerDeadline;
+ _handlerDeadline = millis( ) + WEB_LONG_HANDLER_DEADLINE_MS;
+
_server.setContentLength(CONTENT_LENGTH_UNKNOWN);
_server.send(200, "application/json", "");
char buf[256];
snprintf(buf, sizeof(buf), "{\"path\":\"%s\",\"entries\":[", dirPath.c_str( ));
- if (!safeSend(buf)) return;
+ if (!safeSend(buf)) { _handlerDeadline = savedDeadline; return; }
bool first = true;
@@ -183,8 +190,15 @@ void WebManager::handleApiLs( ) {
dir = LittleFS.openDir(dirPath);
}
+ /* Set when the enumeration is cut short, and reported in the body. A caller
+ * that cannot tell a partial listing from a complete one will treat missing
+ * files as deleted: two listings of the same /history minutes apart came
+ * back with 84 entries each and DIFFERENT contents, and both looked like
+ * well-formed, finished JSON. */
+ bool truncated = false;
+
while (!dirDone) {
- if (isHandlerOvertime( )) break;
+ if (isHandlerOvertime( )) { truncated = true; break; }
struct DirEntry { String name; size_t size; bool isDir; };
DirEntry batch[20];
@@ -192,7 +206,15 @@ void WebManager::handleApiLs( ) {
{
ReadGuard rg(_storageRef);
- while (dir.next( ) && batchCount < 20) {
+ /* Count first, THEN advance. Written the other way round —
+ * `dir.next( ) && batchCount < 20` — the iterator moves before the count is
+ * tested, so the entry that would have filled the batch is consumed and
+ * never recorded: every full batch of 20 silently lost one file. Measured:
+ * 88 files on flash, 84 in the listing, and because LittleFS iteration
+ * order varies, two listings minutes apart dropped DIFFERENT files
+ * (20260523 vs 20260524 — both present, both intact, neither ever shown
+ * together). Nothing in the response said anything was missing. */
+ while (batchCount < 20 && dir.next( )) {
/* feedWdt( ) under the read lock — feedWatchdog( ) would run the light yield,
* which reaches enterFlashReadLock( ) on this same core and self-deadlocks on
* a non-recursive mutex. See the note in handleApiHistoryDays. */
@@ -218,7 +240,7 @@ void WebManager::handleApiLs( ) {
jsonEscapeFilename(dName, dEscaped, sizeof(dEscaped));
snprintf(buf, sizeof(buf), "%s{\"n\":\"%s\",\"t\":\"d\",\"s\":0}",
first ? "" : ",", dEscaped);
- if (!safeSend(buf)) return;
+ if (!safeSend(buf)) { _handlerDeadline = savedDeadline; return; }
first = false;
continue;
}
@@ -241,14 +263,15 @@ void WebManager::handleApiLs( ) {
snprintf(buf, sizeof(buf), "%s{\"n\":\"%s\",\"t\":\"f\",\"s\":%u%s}",
first ? "" : ",", escaped, (unsigned)batch[i].size,
prot ? ",\"p\":1" : "");
- if (!safeSend(buf)) return;
+ if (!safeSend(buf)) { _handlerDeadline = savedDeadline; return; }
first = false;
}
feedWatchdog( );
}
- safeSend("]}");
+ safeSend(truncated ? "],\"truncated\":true}" : "]}");
+ _handlerDeadline = savedDeadline;
}
void WebManager::handleApiMkdir( ) {
diff --git a/tools/arduino_pico_overrides/patch.sh b/tools/arduino_pico_overrides/patch.sh
index 5b467e6..7206ba7 100755
--- a/tools/arduino_pico_overrides/patch.sh
+++ b/tools/arduino_pico_overrides/patch.sh
@@ -85,6 +85,66 @@ else
patch -p1 -d "$FW" < "$BEARSSL_PATCH"
fi
+# 2c. Prazos nos laços de leitura do HTTPClient (HTTPClient.cpp)
+#
+# Dois laços do upstream não têm limite superior nenhum:
+#
+# - handleHeaderResponse(): _tcpTimeout é prazo de INATIVIDADE (lastDataTime
+# reinicia a cada linha recebida), então um servidor que responde 1 byte a
+# cada 400 ms nunca o dispara e o laço roda para sempre.
+# - disconnect(): drena o corpo byte a byte "até available() zerar". Contra um
+# peer que ainda está transmitindo, o socket enche na mesma velocidade em
+# que esvazia, e o laço percorre o corpo inteiro.
+#
+# Medido na bancada em 2026-08-02: resposta de 1 MB = 4 reboots por watchdog
+# em 2 min, autópsia C0=[TEL_SEND] em todos. Um servidor de telemetria
+# defeituoso (ou hostil) derrubava o aparelho em laço.
+#
+# Patch: um orçamento único em cada laço + watchdog_update() dentro — seguro
+# exatamente porque o orçamento faz os dois terminarem. Mesmo padrão do
+# prazo de handshake TLS acima.
+HTTPC="$FW/libraries/HTTPClient/src/HTTPClient.cpp"
+HTTPC_PATCH="$OVR/patches/httpclient_read_deadlines.patch"
+if [ ! -f "$OVR/originals/HTTPClient.cpp" ]; then
+ mkdir -p "$OVR/originals"
+ cp -v "$HTTPC" "$OVR/originals/"
+fi
+if grep -q "SIMUT override — bound the header read as a whole" "$HTTPC"; then
+ echo "[patch] HTTPClient já tem prazos de leitura — nada a fazer"
+else
+ echo "[patch] aplicando prazos nos laços de leitura do HTTPClient"
+ patch -p1 -d "$FW" < "$HTTPC_PATCH"
+fi
+
+# 2d. Vazamento de pbufs de recepcao no ClientContext (ClientContext.h)
+#
+# close( ) e abort( ) desanexam todos os callbacks e largam o _pcb, mas
+# deixavam _rx_buf intacto. Sem o pcb esse dado nao pode mais ser entregue a
+# ninguem, entao os pbufs do pool ficam presos — e WiFiClient::stop( ) chama
+# close( ) SEM soltar o ClientContext, entao o refcount nunca chega ao
+# unref( ) que teria feito o discard.
+#
+# Medido na bancada em 2026-08-02 contra um servidor que responde 1 MB: apos
+# ~19 conexoes, "PBUF pool: 12 em uso / pico 12 / 12 total, 1158 falhas". O
+# aparelho seguia vivo (IP, link -42 dBm, CLI serial respondendo) mas o
+# servidor web ficava mudo, e NAO se recuperava: com a telemetria desligada o
+# pool continuava 12/12 e as falhas subindo. So reboot devolvia.
+#
+# O pool tem so 12 entradas (PBUF_POOL_SIZE 24->12 no lwipopts patchado, para
+# economizar 18 KB de RAM), entao a margem e estreita.
+CTXH="$FW/libraries/WiFi/src/include/ClientContext.h"
+CTXH_PATCH="$OVR/patches/clientcontext_rx_leak.patch"
+if [ ! -f "$OVR/originals/ClientContext.h" ]; then
+ mkdir -p "$OVR/originals"
+ cp -v "$CTXH" "$OVR/originals/"
+fi
+if grep -q "SIMUT override: release buffered RX before abandoning the pcb" "$CTXH"; then
+ echo "[patch] ClientContext ja libera o RX no close/abort — nada a fazer"
+else
+ echo "[patch] aplicando liberacao de _rx_buf no close/abort do ClientContext"
+ patch -p1 -d "$FW" < "$CTXH_PATCH"
+fi
+
# 3. Invalida cache PIO (lwip src + lib WiFi)
# A lib WiFi tem cache próprio em lib*/WiFi/ — sem apagá-lo o .cpp patchado
# não recompila e o build "passa" ainda com o handshake sem prazo.
@@ -94,12 +154,23 @@ for build in "$ROOT/.pio/build"/*/FrameworkArduino/lwip; do
echo "[patch] cache invalidado: $build"
fi
done
-for wifiobj in "$ROOT/.pio/build"/*/lib*/WiFi/WiFiClientSecureBearSSL.cpp.o; do
+# Todos os .o da lib WiFi, nao so o do BearSSL: ClientContext.h e um HEADER
+# incluido por varios deles, entao apagar um objeto so deixaria o vazamento de
+# pbuf linkado enquanto o build "passa".
+for wifiobj in "$ROOT/.pio/build"/*/lib*/WiFi/*.o; do
if [ -f "$wifiobj" ]; then
rm -f "$wifiobj"
echo "[patch] cache invalidado: $wifiobj"
fi
done
+# HTTPClient tem cache próprio pelo mesmo motivo da lib WiFi: sem apagar o .o,
+# o build "passa" ainda linkando os laços de leitura sem prazo.
+for httpobj in "$ROOT/.pio/build"/*/lib*/HTTPClient/HTTPClient.cpp.o; do
+ if [ -f "$httpobj" ]; then
+ rm -f "$httpobj"
+ echo "[patch] cache invalidado: $httpobj"
+ fi
+done
echo ""
echo "[patch] DONE. Próximo \`pio run\` recompila lwIP com PBUF_POOL_SIZE=12."
diff --git a/tools/arduino_pico_overrides/patched_headers/lwipopts.h b/tools/arduino_pico_overrides/patched_headers/lwipopts.h
index 9950cc5..a445dfb 100644
--- a/tools/arduino_pico_overrides/patched_headers/lwipopts.h
+++ b/tools/arduino_pico_overrides/patched_headers/lwipopts.h
@@ -34,11 +34,20 @@ extern unsigned long __lwip_rand(void);
#define MEM_SIZE (__LWIP_MEMMULT * 16384)
#define MEMP_NUM_TCP_SEG (32)
#define MEMP_NUM_ARP_QUEUE (10)
-/* SIMUT patch: 24 → 12 (save ~18 KB BSS). 1-2 conexões TCP simultâneas no
- * device típico — não satura mesmo com 12 envelopes pré-alocados. UDP/BT
- * defaults preservados (reduzir UDP_PCB quebra mDNS, BT changes quebram
- * RSSI via cyw43 chip compartilhado). */
-#define PBUF_POOL_SIZE (__LWIP_MEMMULT > 1 ? 32 : 12)
+/* SIMUT: de volta ao default 24, revertendo o corte para 12.
+ *
+ * A justificativa do corte era "1-2 conexões TCP simultâneas no device típico
+ * — não satura mesmo com 12 envelopes". Medido em 2026-08-02, satura: contra
+ * um servidor que responde 1 MB, o pool ia a 12/12 e o servidor web ficava
+ * mudo, sem recuperar. Parte disso era um vazamento de _rx_buf no
+ * ClientContext (corrigido em patches/clientcontext_rx_leak.patch, que sozinho
+ * levou o aparelho de ~16 para ~144 conexões antes de saturar), mas resta uma
+ * segunda fonte não localizada, e com 12 entradas a margem para ela é nenhuma.
+ *
+ * Custo: ~18 KB de BSS. Com o firmware em ~30% de RAM, cabe.
+ * UDP/BT seguem em default (reduzir UDP_PCB quebra mDNS; mexer em BT quebra o
+ * RSSI via chip cyw43 compartilhado). */
+#define PBUF_POOL_SIZE (__LWIP_MEMMULT > 1 ? 32 : 24)
#define LWIP_ARP 7
#define LWIP_ETHERNET 1
#define LWIP_ICMP 1
diff --git a/tools/arduino_pico_overrides/patches/clientcontext_rx_leak.patch b/tools/arduino_pico_overrides/patches/clientcontext_rx_leak.patch
new file mode 100644
index 0000000..efc4eff
--- /dev/null
+++ b/tools/arduino_pico_overrides/patches/clientcontext_rx_leak.patch
@@ -0,0 +1,42 @@
+--- a/libraries/WiFi/src/include/ClientContext.h
++++ b/libraries/WiFi/src/include/ClientContext.h
+@@ -66,6 +66,9 @@
+ err_t abort() {
+ if (_pcb) {
+ DEBUGV(":abort\r\n");
++ /* SIMUT override: release buffered RX before abandoning the pcb.
++ * See the note above close( ) — same leak, same reason. */
++ discard_received();
+ tcp_arg(_pcb, nullptr);
+ tcp_sent(_pcb, nullptr);
+ tcp_recv(_pcb, nullptr);
+@@ -81,6 +84,29 @@
+ err_t err = ERR_OK;
+ if (_pcb) {
+ DEBUGV(":close\r\n");
++ /* SIMUT override: release buffered RX before abandoning the pcb.
++ *
++ * close( ) detaches every callback and drops _pcb, but left _rx_buf
++ * alone. Once the pcb is gone that data can never be delivered to
++ * anyone, so the pool pbufs holding it are simply leaked — and
++ * WiFiClient::stop( ) calls close( ) without dropping the
++ * ClientContext, so the refcount never reaches the unref( ) path
++ * that would have discarded them.
++ *
++ * Measured on the bench against a server answering with a 1 MB
++ * body: after ~19 connections `show net status` reported
++ * "PBUF pool: 12 em uso / pico 12 / 12 total, 1158 falhas". The
++ * device kept its IP and a -42 dBm link and the serial CLI kept
++ * answering, but the web server went silent with nothing left to
++ * build a packet from, and it did not recover — with telemetry
++ * switched off entirely the pool stayed at 12/12 and the failure
++ * count kept climbing. Only a reboot returned it.
++ *
++ * Reading after the PEER closes is unaffected: that path leaves
++ * _pcb alone and does not come through here. close( ) means WE are
++ * done with the connection.
++ */
++ discard_received();
+ tcp_arg(_pcb, nullptr);
+ tcp_sent(_pcb, nullptr);
+ tcp_recv(_pcb, nullptr);
diff --git a/tools/arduino_pico_overrides/patches/httpclient_read_deadlines.patch b/tools/arduino_pico_overrides/patches/httpclient_read_deadlines.patch
new file mode 100644
index 0000000..4acba01
--- /dev/null
+++ b/tools/arduino_pico_overrides/patches/httpclient_read_deadlines.patch
@@ -0,0 +1,116 @@
+--- a/libraries/HTTPClient/src/HTTPClient.cpp
++++ b/libraries/HTTPClient/src/HTTPClient.cpp
+@@ -27,6 +27,7 @@
+ #include "HTTPClient.h"
+ #include
+ #include
++#include /* SIMUT override: feed inside bounded read loops */
+ #include "base64.h"
+ extern "C" char *strptime(const char *__restrict, const char *__restrict, struct tm *__restrict); // Not exposed by headers?
+
+@@ -348,10 +349,33 @@
+ */
+ void HTTPClient::disconnect(bool preserveClient) {
+ if (connected()) {
++ /* SIMUT override — bound the drain.
++ *
++ * Upstream reads until available() reports empty, one byte at a time,
++ * with no deadline. Against a peer that is still streaming, the socket
++ * refills as fast as it drains, so this loop runs for the whole body:
++ * measured on the bench, a 1 MB response rebooted the RP2040 four
++ * times in two minutes via the 8.388 s hardware watchdog, with the
++ * autopsy pointing at C0=[TEL_SEND] every time. The response had
++ * already been parsed — this loop exists only to leave the socket
++ * reusable, which is never worth the device.
++ *
++ * Read in blocks, give up after _tcpTimeout, and feed the watchdog:
++ * the feed is safe because the deadline makes the loop provably
++ * terminate. Whatever is left unread, stop() below discards.
++ */
+ if (_client()->available() > 0) {
+ DEBUG_HTTPCLIENT("[HTTP-Client][end] still data in buffer (%d), clean up.\n", _client()->available());
++ const unsigned long drainStart = millis();
++ uint8_t sink[64];
+ while (_client()->available() > 0) {
+- _client()->read();
++ if ((millis() - drainStart) > _tcpTimeout) {
++ DEBUG_HTTPCLIENT("[HTTP-Client][end] drain deadline hit, dropping socket\n");
++ _canReuse = false;
++ break;
++ }
++ watchdog_update();
++ _client()->read(sink, sizeof(sink));
+ }
+ }
+
+@@ -1085,11 +1109,69 @@
+ unsigned long lastDataTime = millis();
+ String date;
+
++ /* SIMUT override — bound the header read as a whole.
++ *
++ * _tcpTimeout below is an INACTIVITY timeout: lastDataTime is reset on
++ * every line received, so a peer that trickles the response never trips
++ * it and this loop has no upper bound at all. Measured on the bench, a
++ * server answering one byte every 400 ms held Core 0 here past the
++ * RP2040's 8.388 s watchdog ceiling and rebooted the device.
++ *
++ * One budget across the whole response, plus a watchdog feed that is safe
++ * precisely because the budget makes the loop terminate. Twice the
++ * inactivity timeout leaves room for a legitimately slow-but-progressing
++ * server while still cutting off a trickle well inside a bounded time.
++ */
++ const unsigned long headerStart = millis();
++ const unsigned long headerBudget = _tcpTimeout * 2;
++
+ while (connected()) {
++ if ((millis() - headerStart) > headerBudget) {
++ DEBUG_HTTPCLIENT("[HTTP-Client][handleHeaderResponse] overall timeout\n");
++ _canReuse = false;
++ return HTTPC_ERROR_READ_TIMEOUT;
++ }
++ watchdog_update();
+ size_t len = _client()->available();
+ if (len > 0) {
+ int headerSeparator = -1;
+- String headerLine = _client()->readStringUntil('\n');
++ /* SIMUT override — read the line ourselves.
++ *
++ * readStringUntil( ) is ONE call that blocks character by character
++ * with nothing feeding the watchdog inside it, so the budget check
++ * above — which only runs between lines — cannot save a line that
++ * is slow on its own. Measured: a peer trickling one byte every
++ * 400 ms puts `Content-Type: application/json\r\n` (32 bytes) at
++ * 12.8 s inside a single readStringUntil, well past the RP2040's
++ * 8.388 s ceiling, and the device reboots mid-header.
++ *
++ * Same contract as readStringUntil('\n'): accumulate up to the
++ * newline, do not include it, give back what we have on timeout.
++ * The difference is that the overall budget and the watchdog feed
++ * now apply between characters too. */
++ String headerLine;
++ {
++ unsigned long charWait = millis();
++ while (true) {
++ if ((millis() - headerStart) > headerBudget) {
++ break;
++ }
++ watchdog_update();
++ int c = _client()->read();
++ if (c < 0) {
++ if ((millis() - charWait) > _tcpTimeout) {
++ break;
++ }
++ delay(1);
++ continue;
++ }
++ charWait = millis();
++ if (c == '\n') {
++ break;
++ }
++ headerLine += (char)c;
++ }
++ }
+
+ lastDataTime = millis();
+
diff --git a/tools/telemetry_bench/README.md b/tools/telemetry_bench/README.md
new file mode 100644
index 0000000..c0a87f1
--- /dev/null
+++ b/tools/telemetry_bench/README.md
@@ -0,0 +1,20 @@
+# Bancada de telemetria
+
+Servidores instrumentados e orquestração usados na campanha de 2026-08-02
+(ver `docs/telemetry-campaign-2026-08-02/`). Python 3 + pyserial + requests.
+
+- `server_http.py` — sink HTTP/HTTPS com injeção de falha (`--mode`, `--tls-fault`)
+- `server_mqtt.py` — broker MQTT 3.1.1 escrito do formato de fio, para poder
+ mentir (CONNACK recusado, meio CONNACK, queda no publish…)
+- `bench.py` — serial com detecção de reboot, cliente web, ciclo de vida dos servidores
+- `campaign.py` — controle do alvo; `phase_*.py` — as fases; `revalidate.py` — regressões
+- `rescore.py` — recontagem de perda de dados contando o que a falha recebeu
+- `soak24.py` — soak longo, monitorado só pela serial
+
+Os certificados de teste **não** estão versionados. Gerar antes de usar TLS:
+
+ mkdir -p certs && openssl req -x509 -newkey rsa:2048 -keyout certs/key.pem \
+ -out certs/cert.pem -days 365 -nodes -subj "/CN=/O=SimutBench" \
+ -addext "subjectAltName=IP:"
+
+Os endereços do alvo e do host estão fixos no topo de `campaign.py` e `bench.py`.
diff --git a/tools/telemetry_bench/bench.py b/tools/telemetry_bench/bench.py
new file mode 100644
index 0000000..aeefec7
--- /dev/null
+++ b/tools/telemetry_bench/bench.py
@@ -0,0 +1,363 @@
+#!/usr/bin/env python3
+"""Bench library for the telemetry test campaign.
+
+One process owns the serial port for a whole run, so reboot detection and CLI
+driving cannot fight each other for /dev/ttyACM*. Everything the tests need —
+serial, web, servers, metrics sampling — lives here so the test scripts stay
+about the experiment.
+
+Reboot detection is deliberately belt-and-braces: the USB CDC drops when the
+device resets, but a fast reset can be missed by a reader that happens to be
+between reads, so uptime going backwards in `show metrics` is checked too.
+"""
+import glob
+import json
+import os
+import re
+import signal
+import subprocess
+import sys
+import threading
+import time
+
+import serial
+import requests
+import hashlib
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TARGET_GLOB = '/dev/serial/by-id/usb-Raspberry_Pi_Pico_W_*-if00'
+HAND_GLOB = '/dev/serial/by-id/usb-Raspberry_Pi_Pico_[0-9A-Z]*-if00'
+HOST_IP = '192.168.3.31'
+
+
+def target_port():
+ m = glob.glob(TARGET_GLOB)
+ return os.path.realpath(m[0]) if m else None
+
+
+def hand_port():
+ for p in glob.glob(HAND_GLOB):
+ if '_W_' in p:
+ continue
+ return os.path.realpath(p)
+ return None
+
+
+# ---------------------------------------------------------------------------
+# serial
+# ---------------------------------------------------------------------------
+
+class Target:
+ """Owns the target's USB CDC for the lifetime of a run.
+
+ A background thread drains the port continuously into a timestamped log and
+ watches for the markers that mean the device died: the port vanishing
+ (reset re-enumerates USB), the boot banner, and the fatal-log line the
+ firmware prints after a watchdog reboot.
+ """
+
+ BOOT_MARKERS = (
+ re.compile(r'\[BOOT'),
+ re.compile(r'SIMUT v?\d'),
+ re.compile(r'Iniciando|Booting|=== SIMUT'),
+ )
+ FATAL = re.compile(r'\[FTL\]|SOFT PANIC|HW WATCHDOG|PANIC')
+
+ def __init__(self, logpath, echo=False):
+ self.logpath = logpath
+ self.echo = echo
+ self.lf = open(logpath, 'a', buffering=1)
+ self.ser = None
+ self.buf = ''
+ self.lock = threading.Lock()
+ self.stop = False
+ self.port_drops = 0 # USB re-enumerations = hard reboots
+ self.fatal_lines = []
+ self.boot_lines = []
+ self.all_lines = []
+ self.t0 = time.time()
+ self._open()
+ self.rx = threading.Thread(target=self._reader, daemon=True)
+ self.rx.start()
+
+ # -- plumbing ----------------------------------------------------------
+ def _open(self):
+ p = target_port()
+ if not p:
+ return False
+ try:
+ s = serial.Serial(p, 115200, timeout=0.15)
+ s.dtr = True
+ time.sleep(0.25)
+ self.ser = s
+ self._log(f'--- serial open {p}')
+ return True
+ except Exception as e:
+ self._log(f'--- serial open failed: {e}')
+ return False
+
+ def _log(self, line):
+ stamp = f'{time.time() - self.t0:9.2f} '
+ self.lf.write(stamp + line + '\n')
+ if self.echo:
+ print(stamp + line, flush=True)
+
+ def _feed(self, text):
+ with self.lock:
+ self.buf += text
+ while '\n' in self.buf:
+ line, _, self.buf = self.buf.partition('\n')
+ line = line.rstrip('\r')
+ if not line:
+ continue
+ self.all_lines.append((time.time() - self.t0, line))
+ self._log(line)
+ if self.FATAL.search(line):
+ self.fatal_lines.append((time.time() - self.t0, line))
+ for m in self.BOOT_MARKERS:
+ if m.search(line):
+ self.boot_lines.append((time.time() - self.t0, line))
+ break
+
+ def _reader(self):
+ while not self.stop:
+ if self.ser is None:
+ if not self._open():
+ time.sleep(0.4)
+ continue
+ try:
+ data = self.ser.read(4096)
+ if data:
+ self._feed(data.decode('utf-8', 'replace'))
+ except Exception as e:
+ # Port vanished: the device re-enumerated, i.e. it reset.
+ self._log(f'--- serial dropped ({e})')
+ self.port_drops += 1
+ try:
+ self.ser.close()
+ except Exception:
+ pass
+ self.ser = None
+ time.sleep(0.8)
+
+ # -- commands ----------------------------------------------------------
+ def send(self, cmd, wait=2.5, quiet=0.45):
+ """Send one CLI line and collect the reply until the prompt goes quiet."""
+ if self.ser is None:
+ self._open()
+ if self.ser is None:
+ return ''
+ with self.lock:
+ mark = len(self.all_lines)
+ try:
+ self.ser.write((cmd + '\r\n').encode())
+ self.ser.flush()
+ except Exception as e:
+ self._log(f'--- write failed: {e}')
+ return ''
+ deadline = time.time() + wait
+ last_len = mark
+ quiet_at = None
+ while time.time() < deadline:
+ time.sleep(0.08)
+ with self.lock:
+ n = len(self.all_lines)
+ if n != last_len:
+ last_len = n
+ quiet_at = time.time() + quiet
+ deadline = max(deadline, time.time() + 0.5)
+ elif quiet_at and time.time() > quiet_at:
+ break
+ with self.lock:
+ return '\n'.join(l for _, l in self.all_lines[mark:])
+
+ def cmds(self, *cmds, wait=2.5):
+ return [self.send(c, wait=wait) for c in cmds]
+
+ # -- state -------------------------------------------------------------
+ def metrics(self, wait=3.5):
+ txt = self.send('show metrics', wait=wait)
+ d = {'raw': txt}
+ pats = {
+ 'uptime': r'Uptime:\s*(\d+):(\d+):(\d+)',
+ 'heap': r'Heap:\s*(\d+)\s*B\s*\(min:\s*(\d+)',
+ 'largest': r'Maior bloco:\s*(\d+)\s*B\s*\(min:\s*(\d+)',
+ 'wifi_conns': r'WiFi conns:\s*(\d+)',
+ 'mqtt_conns': r'MQTT conns:\s*(\d+)',
+ 'tel_sent': r'Enviadas:\s*(\d+)',
+ 'tel_failed': r'Falhas:\s*(\d+)',
+ 'tel_retries': r'Retries:\s*(\d+)',
+ 'tel_bytes': r'Bytes:\s*(\d+)',
+ 'tel_lat': r'Ult\. lat:\s*(\d+)',
+ 'reads_ok': r'Leituras OK:\s*(\d+)',
+ 'reads_err': r'Leituras erro:\s*(\d+)',
+ 'flash_ops': r'Flash ops:\s*(\d+)',
+ 'core1_exposed': r'Core1 exposto:\s*(\d+)',
+ 'core1_hb': r'Heartbeat:\s*(\d+)\s*ms',
+ 'core1_running': r'Rodando:\s*(\d+)',
+ }
+ for k, p in pats.items():
+ m = re.search(p, txt)
+ if not m:
+ d[k] = None
+ continue
+ if k == 'uptime':
+ d[k] = int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3))
+ elif k in ('heap', 'largest'):
+ d[k] = int(m.group(1))
+ d[k + '_min'] = int(m.group(2))
+ else:
+ d[k] = int(m.group(1))
+ return d
+
+ def close(self):
+ self.stop = True
+ time.sleep(0.3)
+ try:
+ if self.ser:
+ self.ser.close()
+ except Exception:
+ pass
+ self.lf.close()
+
+
+# ---------------------------------------------------------------------------
+# web
+# ---------------------------------------------------------------------------
+
+def sha256_frontend(password):
+ """The login page hashes each UTF-16 code unit as one byte — latin-1."""
+ return hashlib.sha256(password.encode('latin-1')).hexdigest()
+
+
+class Web:
+ def __init__(self, host, timeout=20):
+ self.base = f'http://{host}'
+ self.s = requests.Session()
+ self.timeout = timeout
+
+ def get(self, path, **kw):
+ kw.setdefault('allow_redirects', False)
+ return self.s.get(self.base + path, timeout=self.timeout, **kw)
+
+ def post(self, path, **kw):
+ kw.setdefault('allow_redirects', False)
+ return self.s.post(self.base + path, timeout=self.timeout, **kw)
+
+ def login(self, user, password):
+ r = self.get('/api/login_init')
+ if r.status_code != 200:
+ return False, f'login_init HTTP {r.status_code}'
+ nonce = r.json().get('nonce', '')
+ r = self.post('/api/login', data={
+ 'user': user, 'pass': sha256_frontend(password), 'nonce': nonce,
+ }, headers={'Content-Type': 'application/x-www-form-urlencoded'})
+ if 'SIMUTSESS' not in self.s.cookies.get_dict():
+ return False, f'no session cookie (HTTP {r.status_code}) {r.text[:120]}'
+ return True, 'ok'
+
+ def commit(self, sys_fields):
+ """POST /api/commit_all — applies config and reboots the device."""
+ payload = json.dumps({'sys': sys_fields}, separators=(',', ':'))
+ return self.post('/api/commit_all', data={'_payload': payload},
+ headers={'Content-Type': 'application/x-www-form-urlencoded'})
+
+ def config(self):
+ r = self.get('/api/config')
+ return r.json() if r.status_code == 200 else {'_http': r.status_code,
+ '_body': r.text[:200]}
+
+
+def wait_web(host, timeout=90, path='/api/login_init'):
+ """Block until the web server answers again (post-reboot)."""
+ t0 = time.time()
+ while time.time() - t0 < timeout:
+ try:
+ r = requests.get(f'http://{host}{path}', timeout=4)
+ if r.status_code < 500:
+ return round(time.time() - t0, 1)
+ except Exception:
+ pass
+ time.sleep(1)
+ return None
+
+
+# ---------------------------------------------------------------------------
+# servers
+# ---------------------------------------------------------------------------
+
+class Server:
+ """A test server subprocess with its stats file."""
+
+ def __init__(self, kind, name, outdir, **kw):
+ self.kind = kind # 'http' | 'mqtt'
+ self.name = name
+ self.outdir = outdir
+ self.stats_path = os.path.join(outdir, f'{name}.stats.json')
+ self.records_path = os.path.join(outdir, f'{name}.records.ndjson')
+ self.log_path = os.path.join(outdir, f'{name}.server.log')
+ for p in (self.stats_path, self.records_path):
+ if os.path.exists(p):
+ os.remove(p)
+ script = 'server_http.py' if kind == 'http' else 'server_mqtt.py'
+ cmd = [sys.executable, os.path.join(HERE, script),
+ '--stats', self.stats_path, '--records', self.records_path]
+ for k, v in kw.items():
+ flag = '--' + k.replace('_', '-')
+ if v is True:
+ cmd.append(flag)
+ elif v is False or v is None:
+ continue
+ else:
+ cmd += [flag, str(v)]
+ self.cmd = cmd
+ self.lf = open(self.log_path, 'w')
+ self.proc = subprocess.Popen(cmd, cwd=HERE, stdout=self.lf,
+ stderr=subprocess.STDOUT,
+ preexec_fn=os.setsid)
+ time.sleep(0.7)
+
+ def alive(self):
+ return self.proc.poll() is None
+
+ def stats(self):
+ try:
+ with open(self.stats_path) as fh:
+ return json.load(fh)
+ except Exception:
+ return {}
+
+ def records(self):
+ out = []
+ try:
+ with open(self.records_path) as fh:
+ for line in fh:
+ line = line.strip()
+ if line:
+ try:
+ out.append(json.loads(line))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return out
+
+ def stop(self):
+ try:
+ os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
+ self.proc.wait(timeout=5)
+ except Exception:
+ try:
+ os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL)
+ except Exception:
+ pass
+ try:
+ self.lf.close()
+ except Exception:
+ pass
+
+
+def kill_stale():
+ subprocess.run(['pkill', '-f', 'server_http.py'], capture_output=True)
+ subprocess.run(['pkill', '-f', 'server_mqtt.py'], capture_output=True)
+ time.sleep(0.4)
diff --git a/tools/telemetry_bench/campaign.py b/tools/telemetry_bench/campaign.py
new file mode 100644
index 0000000..a16bed0
--- /dev/null
+++ b/tools/telemetry_bench/campaign.py
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+"""Telemetry test campaign driver.
+
+Phases
+------
+ perf latency/throughput for HTTP, HTTPS, MQTT, MQTTS + batch sweep
+ drain point telemetry at a sink, reset the cursor, drain the history
+ survive cycle each fault mode past the device and watch it live or die
+
+Everything is measured from three independent places so no single instrument
+can lie: the server (what actually arrived), /api/status (what the device
+thinks), and the serial log (whether it rebooted).
+"""
+import argparse
+import json
+import os
+import re
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+from bench import Target, Web, Server, kill_stale, wait_web, HOST_IP # noqa: E402
+
+import requests # noqa: E402
+
+DEV = '192.168.3.24'
+WEB_USER, WEB_PASS = 'telb', 'Bench2026x'
+PORT_HTTP, PORT_HTTPS, PORT_MQTT, PORT_MQTTS = 18080, 18443, 11883, 18883
+
+OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
+os.makedirs(OUT, exist_ok=True)
+
+
+def log(msg):
+ print(f'[{time.strftime("%H:%M:%S")}] {msg}', flush=True)
+
+
+# ---------------------------------------------------------------------------
+# device control
+# ---------------------------------------------------------------------------
+
+_SESS = {'web': None}
+
+
+def web_session(force=False):
+ """One logged-in session, reused. /api/status is behind auth, so an
+ unauthenticated poll returns Forbidden and reads as 'device fine' — the
+ exact instrument failure this campaign is meant to avoid."""
+ if _SESS['web'] is None or force:
+ w = Web(DEV)
+ ok, why = w.login(WEB_USER, WEB_PASS)
+ if not ok:
+ return None
+ _SESS['web'] = w
+ return _SESS['web']
+
+
+def status(dev=DEV, timeout=6):
+ w = web_session()
+ if w is None:
+ return {'_err': 'noauth'}
+ try:
+ r = w.get('/api/status')
+ if r.status_code == 200:
+ return r.json()
+ if r.status_code in (401, 403, 302):
+ # Session died with the device: re-login once, then report.
+ w = web_session(force=True)
+ if w is None:
+ return {'_err': 'noauth'}
+ r = w.get('/api/status')
+ if r.status_code == 200:
+ return r.json()
+ return {'_http': r.status_code}
+ except Exception as e:
+ return {'_err': type(e).__name__}
+
+
+def cfg_http(t, server, port, crypto, batch=None, interval=None, mode=None,
+ path=None):
+ """Set the HTTP-side telemetry knobs over the serial CLI (no reboot)."""
+ cmds = ['enable', 'configure terminal',
+ f'tel server {server}', f'tel port {port}',
+ f'tel crypto {"on" if crypto else "off"}']
+ if path is not None:
+ cmds.append(f'tel path {path}')
+ if batch is not None:
+ cmds.append(f'tel batch {batch}')
+ if interval is not None:
+ cmds.append(f'tel interval {interval}')
+ if mode is not None:
+ cmds.append(f'tel mode {mode}')
+ cmds += ['end', 'write memory']
+ out = []
+ for c in cmds:
+ out.append(t.send(c, wait=3.0 if c == 'write memory' else 1.6))
+ return out
+
+
+def tel_reset(t):
+ t.send('enable', wait=1.2)
+ return t.send('tel reset', wait=4.0)
+
+
+def tel_sync(t, wait=25.0):
+ """Force one send. forceSync() resets the backoff before doing anything,
+ which is what makes a measurement window start at t=0 instead of somewhere
+ inside an escalated retry timer."""
+ t.send('enable', wait=1.2)
+ return t.send('tel sync', wait=wait)
+
+
+def commit(web, fields, expect_reboot=True):
+ """Push config through /api/commit_all; the device reboots on success."""
+ r = web.commit(fields)
+ ok = r.status_code in (200, 302)
+ log(f'commit_all -> HTTP {r.status_code} {r.text[:120]}')
+ if ok and expect_reboot:
+ time.sleep(4)
+ back = wait_web(DEV, timeout=120)
+ log(f'device web back after {back}s')
+ return ok, back
+ return ok, None
+
+
+def sample_loop(seconds, period=2.0, servers=(), tag=''):
+ """Poll /api/status while a run is in flight."""
+ samples = []
+ t0 = time.time()
+ while time.time() - t0 < seconds:
+ s = status()
+ s['t'] = round(time.time() - t0, 1)
+ samples.append(s)
+ time.sleep(period)
+ return samples
+
+
+def summarize_samples(samples):
+ up = [s.get('sys', {}).get('uptime') for s in samples if 'sys' in s]
+ heap = [s.get('sys', {}).get('heap_f') for s in samples if 'sys' in s]
+ lb = [s.get('sys', {}).get('heap_lb') for s in samples if 'sys' in s]
+ pend = [s.get('sys', {}).get('pending') for s in samples if 'sys' in s]
+ resets = 0
+ for a, b in zip(up, up[1:]):
+ if a is not None and b is not None and b < a:
+ resets += 1
+ unreachable = sum(1 for s in samples if '_err' in s or '_http' in s)
+ return {
+ 'n': len(samples),
+ 'uptime_resets': resets,
+ 'unreachable': unreachable,
+ 'heap_min': min([h for h in heap if h], default=None),
+ 'heap_max': max([h for h in heap if h], default=None),
+ 'largest_min': min([x for x in lb if x], default=None),
+ 'largest_max': max([x for x in lb if x], default=None),
+ 'pending_first': pend[0] if pend else None,
+ 'pending_last': pend[-1] if pend else None,
+ 'uptime_first': up[0] if up else None,
+ 'uptime_last': up[-1] if up else None,
+ }
+
+
+def save(name, obj):
+ p = os.path.join(OUT, name)
+ with open(p, 'w') as fh:
+ json.dump(obj, fh, indent=1, default=str)
+ log(f'saved {p}')
+ return p
diff --git a/tools/telemetry_bench/enumerate_history.py b/tools/telemetry_bench/enumerate_history.py
new file mode 100644
index 0000000..ee9875a
--- /dev/null
+++ b/tools/telemetry_bench/enumerate_history.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+"""Enumerate /history by date instead of trusting /api/ls.
+
+`handleApiLs` breaks out of its enumeration loop on `isHandlerOvertime()` (a
+6 s budget) and then closes the JSON as if it had finished. Two listings of the
+same directory came back with 84 entries each and *different* contents;
+`20260523.h5` and `20260524.h5` both download fine (200, different md5) and no
+single listing showed both. A client cannot tell a truncated listing from a
+complete one, so any inventory built on /api/ls is an undercount of unknown
+size.
+
+Asking for each date directly removes the listing from the loop entirely: a
+404 means the day is genuinely absent, a 200 means it is there whatever the
+listing said.
+"""
+import datetime as dt
+import hashlib
+import json
+import os
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, '/home/angelo/Documentos/simut/tools')
+import campaign as C # noqa: E402
+import history_v5 as h5 # noqa: E402
+
+RAW = os.path.join(C.OUT, 'history_full')
+os.makedirs(RAW, exist_ok=True)
+
+
+def fetch(w, name, tries=3):
+ for i in range(tries):
+ try:
+ r = w.get('/download?file=/history/' + name)
+ except Exception:
+ time.sleep(1.5)
+ continue
+ if r.status_code == 200:
+ return r.content
+ if r.status_code == 404:
+ return None
+ # 403 = session gone, 503 = HeavyTaskGuard busy: both are retryable
+ w = C.web_session(force=True)
+ time.sleep(1.5)
+ return False # distinct from None: "could not decide"
+
+
+def main():
+ start = dt.date(2026, 4, 1)
+ end = dt.date.today()
+ w = C.web_session(force=True)
+
+ present, absent, unknown = [], [], []
+ d = start
+ while d <= end:
+ name = d.strftime('%Y%m%d') + '.h5'
+ dst = os.path.join(RAW, name)
+ if os.path.exists(dst) and os.path.getsize(dst) > 0:
+ present.append((name, os.path.getsize(dst)))
+ d += dt.timedelta(days=1)
+ continue
+ blob = fetch(w, name)
+ if blob is None:
+ absent.append(name)
+ elif blob is False:
+ unknown.append(name)
+ else:
+ with open(dst, 'wb') as fh:
+ fh.write(blob)
+ present.append((name, len(blob)))
+ d += dt.timedelta(days=1)
+ time.sleep(0.2)
+
+ epochs = set()
+ per_file, errors = [], []
+ for name, size in present:
+ try:
+ blob = open(os.path.join(RAW, name), 'rb').read()
+ eps = [ts for _s, ts, _v in h5.read_series(blob, 60)]
+ epochs.update(eps)
+ per_file.append({'file': name, 'bytes': size, 'records': len(eps),
+ 'first': min(eps) if eps else None,
+ 'last': max(eps) if eps else None,
+ 'md5': hashlib.md5(blob).hexdigest()[:12]})
+ except Exception as ex:
+ errors.append({'file': name, 'err': f'{type(ex).__name__}: {ex}'})
+
+ # What did /api/ls claim, for the record?
+ try:
+ listed = [e['n'] for e in w.get('/api/ls?dir=/history').json()['entries']]
+ except Exception:
+ listed = []
+
+ out = {
+ 'scanned_days': (end - start).days + 1,
+ 'files_present': len(present),
+ 'files_absent': len(absent),
+ 'files_unknown': unknown,
+ 'total_bytes': sum(s for _, s in present),
+ 'total_records': sum(p['records'] for p in per_file),
+ 'unique_epochs': len(epochs),
+ 'epoch_min': min(epochs) if epochs else None,
+ 'epoch_max': max(epochs) if epochs else None,
+ 'decode_errors': errors,
+ 'api_ls_reported': len(listed),
+ 'api_ls_missed': sorted(set(n for n, _ in present) - set(listed)),
+ 'api_ls_extra': sorted(set(listed) - set(n for n, _ in present)),
+ 'absent_days': absent,
+ 'per_file': per_file,
+ }
+ C.save('history_inventory.json', out)
+ C.log(json.dumps({k: out[k] for k in
+ ('files_present', 'files_absent', 'total_bytes',
+ 'total_records', 'unique_epochs', 'api_ls_reported',
+ 'api_ls_missed', 'files_unknown')}, indent=1))
+
+ # Re-score both drains against the true inventory.
+ for src, key in (('phase_drain.json', 'telemetry'),
+ ('phase_drain_full.json', None)):
+ p = os.path.join(C.OUT, src)
+ if not os.path.exists(p):
+ continue
+ with open(p) as fh:
+ j = json.load(fh)
+ n = (j[key] if key else j).get('unique_epochs')
+ C.log(f'{src}: {n} epochs = '
+ f'{round(100.0 * (n or 0) / max(1, len(epochs)), 2)}% of the true '
+ f'{len(epochs)} on flash')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/telemetry_bench/phase_drain.py b/tools/telemetry_bench/phase_drain.py
new file mode 100644
index 0000000..a0acdcd
--- /dev/null
+++ b/tools/telemetry_bench/phase_drain.py
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+"""Phase B — drain as much history as the telemetry path will give up.
+
+Two independent readings of the same history, so the drain can be judged rather
+than just described:
+
+ telemetry `tel reset` then let the device push until it goes quiet
+ ground truth download every .h5 over /download and decode it with the
+ reference codec in tools/history_v5.py
+
+The difference between the two epoch sets is the answer to "todo o histórico
+possível" — including the part of the archive the telemetry path structurally
+cannot reach.
+"""
+import json
+import os
+import subprocess
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import campaign as C # noqa: E402
+from bench import Target, Server, kill_stale # noqa: E402
+
+REPO = '/home/angelo/Documentos/simut'
+sys.path.insert(0, os.path.join(REPO, 'tools'))
+
+RAW = os.path.join(C.OUT, 'history_raw')
+os.makedirs(RAW, exist_ok=True)
+
+
+def drain(t, seconds_max=2400, quiet_s=90, batch=50, interval=1000):
+ kill_stale()
+ srv = Server('http', 'drain', C.OUT, port=C.PORT_HTTP, mode='ok')
+ C.cfg_http(t, C.HOST_IP, C.PORT_HTTP, crypto=False, batch=batch,
+ interval=interval, mode='json', path='/ingest')
+ C.tel_reset(t)
+ C.tel_sync(t, wait=30)
+
+ t0 = time.time()
+ last_rec = 0
+ last_change = time.time()
+ timeline = []
+ boots0 = t.port_drops
+ while time.time() - t0 < seconds_max:
+ st = srv.stats()
+ n = st.get('records') or 0
+ s = C.status()
+ timeline.append({
+ 't': round(time.time() - t0, 1),
+ 'records': n,
+ 'requests': st.get('requests'),
+ 'pending': s.get('sys', {}).get('pending'),
+ 'heap': s.get('sys', {}).get('heap_f'),
+ 'lb': s.get('metr', {}).get('lb'),
+ 'tf': s.get('metr', {}).get('tf'),
+ 'tl': s.get('metr', {}).get('tl'),
+ 'uptime': s.get('sys', {}).get('uptime'),
+ })
+ if n != last_rec:
+ last_rec = n
+ last_change = time.time()
+ elif time.time() - last_change > quiet_s:
+ C.log(f'drained: no new record for {quiet_s}s at {n} records')
+ break
+ if len(timeline) % 10 == 0:
+ C.log(f' t={timeline[-1]["t"]}s records={n} pending={timeline[-1]["pending"]}')
+ time.sleep(3)
+
+ st = srv.stats()
+ recs = srv.records()
+ srv.stop()
+ epochs = sorted({r['ts'] for r in recs if isinstance(r.get('ts'), int)})
+ return {
+ 'wall_s': round(time.time() - t0, 1),
+ 'requests': st.get('requests'),
+ 'records': st.get('records'),
+ 'records_written': len(recs),
+ 'unique_epochs': len(epochs),
+ 'bytes_in': st.get('bytes_in'),
+ 'epoch_min': epochs[0] if epochs else None,
+ 'epoch_max': epochs[-1] if epochs else None,
+ 'duplicates': len(recs) - len(epochs),
+ 'usb_drops': t.port_drops - boots0,
+ 'srv_ms_p50': st.get('server_ms_p50'),
+ 'srv_ms_max': st.get('server_ms_max'),
+ 'timeline': timeline,
+ 'batch': batch, 'interval': interval,
+ }, epochs
+
+
+def download_all():
+ """Pull every history file over /download and decode with the reference codec."""
+ import history_v5 as h5
+ w = C.web_session()
+ entries = w.get('/api/ls?dir=/history').json()['entries']
+ files = [e for e in entries if e['n'].endswith('.h5')]
+ got, failed = [], []
+ for e in sorted(files, key=lambda x: x['n']):
+ dst = os.path.join(RAW, e['n'])
+ if os.path.exists(dst) and os.path.getsize(dst) == e['s']:
+ got.append((e['n'], e['s']))
+ continue
+ try:
+ r = w.get('/download?file=/history/' + e['n'])
+ if r.status_code == 200 and len(r.content) == e['s']:
+ with open(dst, 'wb') as fh:
+ fh.write(r.content)
+ got.append((e['n'], e['s']))
+ else:
+ failed.append((e['n'], r.status_code, len(r.content), e['s']))
+ except Exception as ex:
+ failed.append((e['n'], type(ex).__name__, str(ex)[:60], e['s']))
+ time.sleep(0.15)
+
+ all_epochs = set()
+ per_file = []
+ decode_errors = []
+ for name, size in got:
+ try:
+ blob = open(os.path.join(RAW, name), 'rb').read()
+ eps = [ts for _sch, ts, _vals in
+ h5.read_series(blob, nominal_interval_s=60)]
+ all_epochs.update(eps)
+ per_file.append({'file': name, 'bytes': size, 'records': len(eps),
+ 'first': min(eps) if eps else None,
+ 'last': max(eps) if eps else None})
+ except Exception as ex:
+ decode_errors.append({'file': name, 'err': f'{type(ex).__name__}: {ex}'})
+ return {
+ 'files_listed': len(files), 'files_downloaded': len(got),
+ 'download_failures': failed,
+ 'total_bytes': sum(s for _, s in got),
+ 'total_records': sum(p['records'] for p in per_file),
+ 'unique_epochs': len(all_epochs),
+ 'epoch_min': min(all_epochs) if all_epochs else None,
+ 'epoch_max': max(all_epochs) if all_epochs else None,
+ 'decode_errors': decode_errors,
+ 'per_file': per_file,
+ }, all_epochs
+
+
+def main():
+ seconds_max = int(sys.argv[1]) if len(sys.argv) > 1 else 2400
+ kill_stale()
+ t = Target(os.path.join(C.OUT, 'serial_drain.log'))
+ time.sleep(1)
+
+ C.log('=== draining via telemetry ===')
+ d, tel_epochs = drain(t, seconds_max=seconds_max)
+ C.log(json.dumps({k: d[k] for k in
+ ('wall_s', 'requests', 'records', 'unique_epochs',
+ 'epoch_min', 'epoch_max', 'duplicates', 'usb_drops')}))
+ t.close()
+
+ C.log('=== downloading ground truth ===')
+ g, disk_epochs = download_all()
+ C.log(json.dumps({k: g[k] for k in
+ ('files_listed', 'files_downloaded', 'total_records',
+ 'unique_epochs', 'epoch_min', 'epoch_max')}))
+
+ missing = sorted(disk_epochs - set(tel_epochs))
+ extra = sorted(set(tel_epochs) - disk_epochs)
+ out = {
+ 'telemetry': d,
+ 'ground_truth': g,
+ 'coverage': {
+ 'on_disk': len(disk_epochs),
+ 'via_telemetry': len(tel_epochs),
+ 'pct': round(100.0 * len(tel_epochs) / max(1, len(disk_epochs)), 2),
+ 'missing_count': len(missing),
+ 'missing_first': missing[0] if missing else None,
+ 'missing_last': missing[-1] if missing else None,
+ 'extra_count': len(extra),
+ 'extra_sample': extra[:10],
+ },
+ }
+ C.save('phase_drain.json', out)
+ C.log(json.dumps(out['coverage'], indent=1))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/telemetry_bench/phase_drain_full.py b/tools/telemetry_bench/phase_drain_full.py
new file mode 100644
index 0000000..3b37554
--- /dev/null
+++ b/tools/telemetry_bench/phase_drain_full.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""Phase B2 — drain the WHOLE archive, past the firmware's 30-day floor.
+
+`tel reset` zeroes the cursor, and collectBatch then refuses to look further
+back than `lastRecorded − 30 days`:
+
+ if (lastCursor == 0) {
+ uint32_t lastRecorded = _storageRef->getLastRecordedTimestamp( );
+ if (lastRecorded > 86400UL * 30) lastCursor = lastRecorded - 86400UL * 30;
+ }
+
+so anything older is unreachable through telemetry no matter how long it runs.
+The floor is a policy in that one branch, not a storage limit — and this proves
+it. The cursor lives in a 4-byte file, `/config/t_cursor.bin`; seeding it with
+HIST_EPOCH_MIN instead of zero skips the fallback entirely and the device
+happily streams the entire archive.
+
+Run order matters: `tel reset` must come first (it clears the RAM cache AND
+deletes the file), and the seeded file has to land before the next read.
+"""
+import json
+import os
+import struct
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import campaign as C # noqa: E402
+from bench import Target, Server, kill_stale # noqa: E402
+
+SEED_EPOCH = 1600000001 # just past HIST_EPOCH_MIN (1.6e9)
+
+
+def seed_cursor(value):
+ """Upload a 4-byte little-endian cursor. /api/upload ignores `dir`, so the
+ destination path has to travel in the filename."""
+ w = C.web_session()
+ blob = struct.pack(' 1 else 4200
+ kill_stale()
+ t = Target(os.path.join(C.OUT, 'serial_drain_full.log'))
+ time.sleep(1)
+
+ srv = Server('http', 'drain_full', C.OUT, port=C.PORT_HTTP, mode='ok')
+ C.cfg_http(t, C.HOST_IP, C.PORT_HTTP, crypto=False, batch=50,
+ interval=1000, mode='json', path='/ingest')
+ C.tel_reset(t)
+ time.sleep(2)
+ code, body = seed_cursor(SEED_EPOCH)
+ C.log(f'seed /config/t_cursor.bin -> HTTP {code} {body}')
+ time.sleep(2)
+ # Verify the seed landed where it was aimed. A silently-misplaced upload
+ # would leave the 30-day fallback in charge and this run would quietly
+ # re-measure the previous phase while claiming to have beaten the cap.
+ w = C.web_session()
+ try:
+ listing = w.get('/api/ls?dir=/config').json().get('entries', [])
+ except Exception as e:
+ listing = [{'err': str(e)}]
+ seed_ok = any(e.get('n') == 't_cursor.bin' and e.get('s') == 4
+ for e in listing)
+ C.log(f'/config listing: {listing} seed_ok={seed_ok}')
+ C.tel_sync(t, wait=40)
+
+ t0 = time.time()
+ last_rec, last_change = 0, time.time()
+ timeline = []
+ boots0 = t.port_drops
+ while time.time() - t0 < seconds_max:
+ st = srv.stats()
+ n = st.get('records') or 0
+ s = C.status()
+ timeline.append({
+ 't': round(time.time() - t0, 1), 'records': n,
+ 'requests': st.get('requests'),
+ 'pending': s.get('sys', {}).get('pending'),
+ 'heap': s.get('sys', {}).get('heap_f'),
+ 'lb': s.get('metr', {}).get('lb'),
+ 'tf': s.get('metr', {}).get('tf'), 'tl': s.get('metr', {}).get('tl'),
+ 'uptime': s.get('sys', {}).get('uptime'),
+ })
+ if n != last_rec:
+ last_rec, last_change = n, time.time()
+ elif time.time() - last_change > 120:
+ C.log(f'drained: quiet for 120s at {n} records')
+ break
+ if len(timeline) % 20 == 0:
+ C.log(f' t={timeline[-1]["t"]}s records={n} '
+ f'pending={timeline[-1]["pending"]} heap={timeline[-1]["heap"]}')
+ time.sleep(3)
+
+ st = srv.stats()
+ recs = srv.records()
+ srv.stop()
+ t.close()
+
+ epochs = sorted({r['ts'] for r in recs if isinstance(r.get('ts'), int)})
+ with_press = [r for r in recs if any(k.startswith('p') for k in r)]
+ out = {
+ 'seed_epoch': SEED_EPOCH,
+ 'seed_upload_http': code,
+ 'seed_present_in_fs': seed_ok,
+ 'wall_s': round(time.time() - t0, 1),
+ 'requests': st.get('requests'), 'records': st.get('records'),
+ 'unique_epochs': len(epochs), 'duplicates': len(recs) - len(epochs),
+ 'bytes_in': st.get('bytes_in'),
+ 'epoch_min': epochs[0] if epochs else None,
+ 'epoch_max': epochs[-1] if epochs else None,
+ 'records_with_pressure': len(with_press),
+ 'first_pressure_record': with_press[0] if with_press else None,
+ 'usb_drops': t.port_drops - boots0,
+ 'timeline': timeline,
+ }
+ if out['wall_s']:
+ out['records_per_s'] = round((out['records'] or 0) / out['wall_s'], 2)
+ C.save('phase_drain_full.json', out)
+
+ # Compare against the ground truth the 30-day drain already collected.
+ prev = os.path.join(C.OUT, 'phase_drain.json')
+ if os.path.exists(prev):
+ with open(prev) as fh:
+ p = json.load(fh)
+ disk = p['ground_truth']
+ out['vs_disk'] = {
+ 'disk_unique_epochs': disk['unique_epochs'],
+ 'full_drain_unique': len(epochs),
+ 'pct': round(100.0 * len(epochs) / max(1, disk['unique_epochs']), 2),
+ 'thirty_day_drain_unique': p['telemetry']['unique_epochs'],
+ 'thirty_day_pct': p['coverage']['pct'],
+ }
+ C.save('phase_drain_full.json', out)
+ C.log(json.dumps(out['vs_disk'], indent=1))
+ C.log(json.dumps({k: out[k] for k in
+ ('wall_s', 'requests', 'records', 'unique_epochs',
+ 'epoch_min', 'epoch_max', 'records_per_s', 'usb_drops')}))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/telemetry_bench/phase_mqtt.py b/tools/telemetry_bench/phase_mqtt.py
new file mode 100644
index 0000000..eb38122
--- /dev/null
+++ b/tools/telemetry_bench/phase_mqtt.py
@@ -0,0 +1,341 @@
+#!/usr/bin/env python3
+"""Phases A2/C2 — MQTT and MQTTS: throughput, and survival against a broken broker.
+
+Two facts about the firmware shape this file.
+
+`telTransport` and the MQTT client's server/port/TLS are read exactly once, in
+TelemetryManager::begin(). Nothing re-reads them, so switching to MQTT — or from
+MQTT to MQTTS — costs a reboot through /api/commit_all. Everything after that
+runs on ONE port, and the fault modes are produced by restarting the broker
+process rather than by re-pointing the device.
+
+attemptMqttPublish splits at 5 records: five or fewer are published one message
+per record, more than five as a single payload. The batch sweep straddles that
+threshold on purpose.
+"""
+import json
+import os
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import campaign as C # noqa: E402
+from bench import Target, Server, kill_stale, wait_web # noqa: E402
+
+HIST_INTERVAL_S = 60
+
+
+def switch(web, transport, tls, port, batch=50, interval=1000):
+ fields = {
+ 't_transport': str(transport),
+ 't_sec': '1' if tls else '0',
+ 't_srv': C.HOST_IP,
+ 't_port': str(port),
+ 't_int': str(interval),
+ 't_bat': str(batch),
+ 't_mode': '0',
+ 'm_topic': 'simut/data',
+ 'm_qos': '1',
+ 'm_retain': '0',
+ 'm_ka': '30',
+ }
+ ok, back = C.commit(web, fields)
+ return ok, back
+
+
+def broker(name, port, tls, **kw):
+ args = dict(port=port, tls=tls, cert='certs/cert.pem', key='certs/key.pem')
+ args.update(kw)
+ return Server('mqtt', name, C.OUT, **args)
+
+
+def measure(t, srv, seconds, label):
+ s0 = C.status()
+ m0 = s0.get('metr', {})
+ boots0, fatal0 = t.port_drops, len(t.fatal_lines)
+ samples = []
+ t0 = time.time()
+ while time.time() - t0 < seconds:
+ s = C.status()
+ s['_t'] = round(time.time() - t0, 1)
+ samples.append(s)
+ time.sleep(2)
+ m1 = C.status().get('metr', {})
+ st = srv.stats() if srv else {}
+
+ def d(k):
+ a, b = m0.get(k), m1.get(k)
+ return (b - a) if isinstance(a, int) and isinstance(b, int) else None
+
+ lat = [s.get('metr', {}).get('tl') for s in samples if s.get('metr')]
+ lat = sorted(x for x in lat if isinstance(x, int) and x > 0)
+ heap = [s.get('sys', {}).get('heap_f') for s in samples if s.get('sys')]
+ lb = [s.get('metr', {}).get('lb') for s in samples if s.get('metr')]
+ up = [s.get('sys', {}).get('uptime') for s in samples if s.get('sys')]
+ resets = sum(1 for a, b in zip(up, up[1:])
+ if a is not None and b is not None and b < a)
+ row = {
+ 'label': label, 'seconds': seconds,
+ 'dev_sent': d('ts'), 'dev_failed': d('tf'), 'dev_retries': d('tr'),
+ 'dev_bytes': d('tb'), 'dev_mqtt_conns': d('mq'),
+ 'dev_lat_min': lat[0] if lat else None,
+ 'dev_lat_med': lat[len(lat) // 2] if lat else None,
+ 'dev_lat_max': lat[-1] if lat else None,
+ 'heap_min': min([h for h in heap if h], default=None),
+ 'heap_max': max([h for h in heap if h], default=None),
+ 'largest_min': min([x for x in lb if x], default=None),
+ 'uptime_resets': resets, 'usb_drops': t.port_drops - boots0,
+ 'fatal_lines': len(t.fatal_lines) - fatal0,
+ 'unreachable_polls': sum(1 for s in samples if '_err' in s or '_http' in s),
+ 'srv_conns': st.get('conns'), 'srv_connects': st.get('connects'),
+ 'srv_connacks': st.get('connacks'), 'srv_publishes': st.get('publishes'),
+ 'srv_records': st.get('records'), 'srv_bytes': st.get('bytes_in'),
+ 'srv_pings': st.get('pings'),
+ 'srv_tls_ok': st.get('tls_ok'), 'srv_tls_fail': st.get('tls_failures'),
+ 'srv_qos_seen': st.get('qos_seen'), 'srv_retain_seen': st.get('retain_seen'),
+ 'srv_client_ids': st.get('client_ids'), 'srv_will_topics': st.get('will_topics'),
+ 'srv_status_msgs': st.get('status_msgs'),
+ 'srv_epoch_min': st.get('epoch_min'), 'srv_epoch_max': st.get('epoch_max'),
+ 'srv_msg_bytes': [m.get('bytes') for m in (st.get('msgs') or [])[-5:]],
+ 'srv_msg_n': [m.get('n') for m in (st.get('msgs') or [])[-5:]],
+ }
+ if row['srv_records'] is not None:
+ row['records_per_s'] = round(row['srv_records'] / seconds, 2)
+ return row
+
+
+def perf_sweep(t, port, tls, batches, seconds, tag):
+ rows = []
+ for b in batches:
+ label = f'{"mqtts" if tls else "mqtt"}_batch{b}'
+ C.log(f'--- {label}')
+ kill_stale()
+ srv = broker(f'{tag}_{label}', port, tls, mode='ok')
+ # Batch size is plain config, no reboot needed — only transport/TLS are
+ # frozen at begin().
+ t.send('enable', wait=1.2)
+ t.send('configure terminal', wait=1.2)
+ t.send(f'tel batch {b}', wait=1.2)
+ t.send('tel interval 1000', wait=1.2)
+ t.send('end', wait=1.2)
+ t.send('write memory', wait=3.0)
+ C.tel_reset(t)
+ C.tel_sync(t, wait=30)
+ time.sleep(3)
+ row = measure(t, srv, seconds, label)
+ row['batch'] = b
+ rows.append(row)
+ C.log(json.dumps({k: row[k] for k in
+ ('label', 'srv_publishes', 'srv_records', 'records_per_s',
+ 'dev_lat_med', 'dev_failed', 'srv_qos_seen', 'heap_min',
+ 'usb_drops', 'srv_connects')}))
+ srv.stop()
+ return rows
+
+
+def run_fault(t, name, port, tls, seconds, server_kw=None, no_server=False, batch=10):
+ C.log(f'=== mqtt fault: {name} ({seconds}s)')
+ # act 1 — healthy broker, note where the cursor got to
+ kill_stale()
+ pre_srv = broker(f'{name}_pre', port, tls, mode='ok')
+ t.send('enable', wait=1.2)
+ t.send('configure terminal', wait=1.2)
+ t.send(f'tel batch {batch}', wait=1.2)
+ t.send('tel interval 1000', wait=1.2)
+ t.send('end', wait=1.2)
+ t.send('write memory', wait=3.0)
+ C.tel_reset(t)
+ C.tel_sync(t, wait=30)
+ time.sleep(12)
+ pre = pre_srv.stats()
+ pre = {'publishes': pre.get('publishes'), 'records': pre.get('records'),
+ 'epoch_min': pre.get('epoch_min'), 'epoch_max': pre.get('epoch_max')}
+ pre_srv.stop()
+ C.log(f' baseline: {pre}')
+
+ kill_stale()
+ srv = None
+ if not no_server:
+ srv = broker(f'{name}_fault', port, tls, **(server_kw or {}))
+ C.tel_sync(t, wait=30)
+ row = measure(t, srv, seconds, name)
+ if srv:
+ srv.stop()
+
+ kill_stale()
+ post_srv = broker(f'{name}_post', port, tls, mode='ok')
+ C.tel_sync(t, wait=30)
+ time.sleep(30)
+ post = post_srv.stats()
+ post = {'publishes': post.get('publishes'), 'records': post.get('records'),
+ 'epoch_min': post.get('epoch_min'), 'epoch_max': post.get('epoch_max')}
+ post_srv.stop()
+ C.log(f' recovery: {post}')
+
+ gap = lost = None
+ if pre.get('epoch_max') and post.get('epoch_min'):
+ gap = post['epoch_min'] - pre['epoch_max']
+ lost = max(0, (gap // HIST_INTERVAL_S) - 1)
+ row.update({'fault': name, 'pre': pre, 'post': post,
+ 'cursor_gap_s': gap, 'records_skipped': lost,
+ 'server_kw': server_kw, 'no_server': no_server, 'tls': tls})
+ v = []
+ if row['usb_drops'] or row['uptime_resets']:
+ v.append('REBOOT')
+ if row['fatal_lines']:
+ v.append('FATAL')
+ if lost:
+ v.append(f'DATA-LOSS({lost} rec)')
+ if row['unreachable_polls'] >= 3:
+ v.append(f'WEB-STALL({row["unreachable_polls"]} polls)')
+ if not post.get('records'):
+ v.append('NO-RECOVERY')
+ row['verdict'] = ' '.join(v) if v else 'SURVIVED'
+ C.log(f' -> {row["verdict"]} (devFail+{row["dev_failed"]} '
+ f'conns={row["srv_conns"]} connacks={row["srv_connacks"]} heapMin={row["heap_min"]})')
+ return row
+
+
+FAULTS = [
+ ('mq_refused', dict(no_server=True)),
+ ('mq_rst', dict(server_kw={'mode': 'rst'})),
+ ('mq_no_connack', dict(server_kw={'mode': 'no_connack', 'delay': 180})),
+ ('mq_slow_connack', dict(server_kw={'mode': 'slow_connack', 'delay': 20})),
+ ('mq_half_connack', dict(server_kw={'mode': 'half_connack', 'delay': 180})),
+ ('mq_connack_refuse', dict(server_kw={'mode': 'connack_refuse'})),
+ ('mq_connack_unavail', dict(server_kw={'mode': 'connack_unavail'})),
+ ('mq_drop_after_connack', dict(server_kw={'mode': 'drop_after_connack'})),
+ ('mq_drop_on_publish', dict(server_kw={'mode': 'drop_on_publish'})),
+ ('mq_rst_on_publish', dict(server_kw={'mode': 'rst_on_publish'})),
+ ('mq_garbage', dict(server_kw={'mode': 'garbage'})),
+ ('mq_no_pingresp', dict(server_kw={'mode': 'no_pingresp'})),
+]
+
+TLS_FAULTS = [
+ ('mqs_tls_blackhole', dict(server_kw={'mode': 'ok', 'tls_fault': 'blackhole', 'delay': 180})),
+ ('mqs_tls_garbage', dict(server_kw={'mode': 'ok', 'tls_fault': 'garbage'})),
+ ('mqs_tls_rst', dict(server_kw={'mode': 'ok', 'tls_fault': 'rst'})),
+ ('mqs_refused', dict(no_server=True)),
+ ('mqs_drop_on_publish', dict(server_kw={'mode': 'drop_on_publish'})),
+]
+
+
+def config_fidelity(t, web, port, seconds=70):
+ """Does the wire carry what the config page promised?
+
+ Every MQTT knob the UI exposes is checked against the bytes that actually
+ reach the broker: credentials and client id in the CONNECT, QoS and retain
+ in the PUBLISH flags, keepalive in the CONNECT header. A field the firmware
+ accepts and then never transmits is invisible from the device side — the
+ broker is the only place it shows up.
+ """
+ C.log('=== MQTT config fidelity ===')
+ kill_stale()
+ srv = broker('fidelity', port, False, mode='ok')
+ fields = {
+ 't_transport': '1', 't_sec': '0', 't_srv': C.HOST_IP, 't_port': str(port),
+ 't_int': '2000', 't_bat': '3', 't_mode': '0',
+ 'm_topic': 'bench/telemetry/data', 'm_cid': 'benchcid7',
+ 'm_user': 'benchuser', 'm_pass': 'benchsecret',
+ 'm_qos': '1', 'm_retain': '1', 'm_ka': '45',
+ }
+ C.commit(web, fields)
+ C.web_session(force=True)
+ time.sleep(6)
+ C.tel_sync(t, wait=30)
+ time.sleep(seconds)
+ st = srv.stats()
+ srv.stop()
+
+ wanted = {'m_cid': 'benchcid7', 'm_user': 'benchuser', 'm_pass': 'benchsecret',
+ 'm_qos': 1, 'm_retain': True, 'm_ka': 45,
+ 'm_topic': 'bench/telemetry/data'}
+ # The broker is up before the commit, so the device's PRE-reboot client
+ # connects to it first, carrying the OLD settings. Reading frame 0 scored
+ # every field as ignored and would have reported four defects that do not
+ # exist. Take the frame that belongs to the config under test — the one
+ # whose will topic came from the new topic — and fall back to the last.
+ frames = st.get('connect_frames') or [{}]
+ want_will = wanted['m_topic'].rsplit('/', 1)[0] + '/status'
+ cf = next((f for f in frames if f.get('willTopic') == want_will), frames[-1])
+ got = {
+ 'clientId': cf.get('clientId'), 'user': cf.get('user'),
+ 'pass': cf.get('pass'), 'keepalive': cf.get('keepalive'),
+ 'willTopic': cf.get('willTopic'),
+ 'qos_seen': st.get('qos_seen'), 'retain_seen': st.get('retain_seen'),
+ 'topics': sorted({m['topic'] for m in (st.get('msgs') or [])}),
+ 'publishes': st.get('publishes'), 'records': st.get('records'),
+ }
+ checks = {
+ 'client_id_honoured': got['clientId'] == wanted['m_cid'],
+ 'user_honoured': got['user'] == wanted['m_user'],
+ 'password_honoured': got['pass'] == wanted['m_pass'],
+ 'keepalive_honoured': got['keepalive'] == wanted['m_ka'],
+ 'topic_honoured': wanted['m_topic'] in (got['topics'] or []),
+ 'qos1_honoured': bool(got['qos_seen']) and set(map(int, got['qos_seen'])) == {1},
+ 'retain_honoured': bool(got['retain_seen']) and got['retain_seen'].get('True', 0) > 0,
+ }
+ C.log('fidelity wanted=' + json.dumps(wanted))
+ C.log('fidelity got=' + json.dumps(got))
+ C.log('fidelity checks=' + json.dumps(checks))
+ return {'wanted': wanted, 'got': got, 'checks': checks,
+ 'frame_used': cf, 'connect_frames': frames}
+
+
+def main():
+ seconds = int(sys.argv[1]) if len(sys.argv) > 1 else 90
+ stage = sys.argv[2] if len(sys.argv) > 2 else 'all'
+ kill_stale()
+ t = Target(os.path.join(C.OUT, f'serial_mqtt_{stage}.log'))
+ time.sleep(1)
+ web = C.web_session()
+ out = {'perf': [], 'faults': []}
+
+ if stage in ('all', 'plain'):
+ C.log('=== switching to MQTT plain (reboot) ===')
+ kill_stale()
+ srv = broker('boot_mqtt', C.PORT_MQTT, False, mode='ok')
+ ok, back = switch(web, 1, False, C.PORT_MQTT)
+ C.log(f'switch ok={ok} web back in {back}s')
+ C.web_session(force=True)
+ time.sleep(5)
+ srv.stop()
+ out['perf'] += perf_sweep(t, C.PORT_MQTT, False, [1, 5, 10, 50], seconds, 'mqtt')
+ C.save('phase_mqtt_plain.json', out)
+ out['fidelity'] = config_fidelity(t, C.web_session(), C.PORT_MQTT)
+ # config_fidelity leaves credentials and QoS 1 set; put the transport
+ # back on the plain settings the fault runs assume.
+ C.commit(C.web_session(), {
+ 't_transport': '1', 't_sec': '0', 't_srv': C.HOST_IP,
+ 't_port': str(C.PORT_MQTT), 't_int': '1000', 't_bat': '10',
+ 'm_topic': 'simut/data', 'm_cid': '', 'm_user': '',
+ 'm_qos': '0', 'm_retain': '0', 'm_ka': '30'})
+ C.web_session(force=True)
+ time.sleep(5)
+ C.save('phase_mqtt_plain.json', out)
+ for name, kw in FAULTS:
+ out['faults'].append(run_fault(t, name, C.PORT_MQTT, False, seconds, **kw))
+ C.save('phase_mqtt_plain.json', out)
+
+ if stage in ('all', 'tls'):
+ C.log('=== switching to MQTTS (reboot) ===')
+ kill_stale()
+ srv = broker('boot_mqtts', C.PORT_MQTTS, True, mode='ok')
+ ok, back = switch(web, 1, True, C.PORT_MQTTS)
+ C.log(f'switch ok={ok} web back in {back}s')
+ C.web_session(force=True)
+ time.sleep(5)
+ srv.stop()
+ out2 = {'perf': [], 'faults': []}
+ out2['perf'] += perf_sweep(t, C.PORT_MQTTS, True, [1, 5, 10, 50], seconds, 'mqtts')
+ C.save('phase_mqtt_tls.json', out2)
+ for name, kw in TLS_FAULTS:
+ out2['faults'].append(run_fault(t, name, C.PORT_MQTTS, True, seconds, **kw))
+ C.save('phase_mqtt_tls.json', out2)
+
+ t.close()
+ C.log('done')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/telemetry_bench/phase_mqtt_oversize.py b/tools/telemetry_bench/phase_mqtt_oversize.py
new file mode 100644
index 0000000..a40d3cd
--- /dev/null
+++ b/tools/telemetry_bench/phase_mqtt_oversize.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""Targeted test — an MQTT payload bigger than the client buffer can ever be.
+
+attemptMqttPublish grows the PubSubClient buffer to fit the payload, but clamps
+the request at 8192:
+
+ uint16_t needed = min((size_t)8192, payload.length( ) + 64);
+ _mqttClient.setBufferSize(needed);
+
+PubSubClient::publish() refuses any packet that does not fit the buffer, so a
+payload past ~8 KB cannot be published *at all* — and since the failure is
+deterministic, the retry never succeeds either. That is a permanent stall, not a
+transient error, and it is reachable from the config page: batch 50 with a long
+custom line template gets there with the sensors this bench already has.
+
+The test builds exactly that configuration, runs it against a healthy broker,
+and checks whether anything is published. It then verifies the device is still
+alive and recovers once the batch comes back down.
+"""
+import json
+import os
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import campaign as C # noqa: E402
+from bench import Target, Server, kill_stale # noqa: E402
+
+# ~205 bytes per record once the tokens expand: comfortably over 8192 at 50.
+LONG_LINE = ('{"timestamp_utc_seconds":{TS},"ch_a_temperature_celsius":{t0},'
+ '"ch_b_temperature_celsius":{t1},"ch_c_temperature_celsius":{t3},'
+ '"ch_c_relative_humidity":{u3},"ch_d_temperature":{t4},'
+ '"ch_e_temperature":{t10},"ch_e_humidity":{u10}}')
+
+
+def run(t, batch, mode, label, seconds=70):
+ kill_stale()
+ srv = Server('mqtt', f'oversize_{label}', C.OUT, port=C.PORT_MQTT,
+ mode='ok')
+ t.send('enable', wait=1.2)
+ t.send('configure terminal', wait=1.2)
+ t.send(f'tel batch {batch}', wait=1.2)
+ t.send('tel interval 1000', wait=1.2)
+ t.send(f'tel mode {mode}', wait=1.2)
+ t.send('end', wait=1.2)
+ t.send('write memory', wait=3.0)
+ C.tel_reset(t)
+ C.tel_sync(t, wait=30)
+ boots0 = t.port_drops
+ time.sleep(seconds)
+ st = srv.stats()
+ srv.stop()
+ s = C.status()
+ msgs = st.get('msgs') or []
+ return {
+ 'label': label, 'batch': batch, 'mode': mode,
+ 'publishes': st.get('publishes'), 'records': st.get('records'),
+ 'connects': st.get('connects'), 'connacks': st.get('connacks'),
+ 'largest_msg_bytes': max([m.get('bytes', 0) for m in msgs], default=0),
+ 'msg_bytes': [m.get('bytes') for m in msgs[-5:]],
+ 'usb_drops': t.port_drops - boots0,
+ 'heap': s.get('sys', {}).get('heap_f'),
+ 'dev_failed': s.get('metr', {}).get('tf'),
+ 'dev_sent': s.get('metr', {}).get('ts'),
+ 'uptime': s.get('sys', {}).get('uptime'),
+ }
+
+
+def main():
+ kill_stale()
+ t = Target(os.path.join(C.OUT, 'serial_mqtt_oversize.log'))
+ time.sleep(1)
+ web = C.web_session()
+
+ C.log('=== switching to MQTT plain with a long custom line template ===')
+ kill_stale()
+ boot = Server('mqtt', 'oversize_boot', C.OUT, port=C.PORT_MQTT, mode='ok')
+ C.commit(web, {
+ 't_transport': '1', 't_sec': '0', 't_srv': C.HOST_IP,
+ 't_port': str(C.PORT_MQTT), 't_int': '1000', 't_bat': '50',
+ 't_mode': '2',
+ 't_glob': '[{DATA}]',
+ 't_line': LONG_LINE,
+ 't_sep': ',',
+ 'm_topic': 'simut/data', 'm_qos': '0', 'm_retain': '0', 'm_ka': '30',
+ })
+ C.web_session(force=True)
+ time.sleep(5)
+ boot.stop()
+
+ out = {}
+ # Small batch first: proves the template itself publishes fine.
+ out['small_batch5'] = run(t, 5, 'custom', 'small_batch5')
+ C.log(json.dumps(out['small_batch5']))
+ # Then the same template at batch 50, where the payload passes 8 KB.
+ out['big_batch50'] = run(t, 50, 'custom', 'big_batch50')
+ C.log(json.dumps(out['big_batch50']))
+ # And back down, to show the stall is the payload size and not damage.
+ out['recover_batch5'] = run(t, 5, 'custom', 'recover_batch5')
+ C.log(json.dumps(out['recover_batch5']))
+
+ big = out['big_batch50']
+ out['verdict'] = ('STALL: nothing published at batch 50'
+ if not big['publishes'] else
+ f'published {big["publishes"]} msgs, '
+ f'largest {big["largest_msg_bytes"]} B')
+ C.log('VERDICT: ' + out['verdict'])
+ C.save('phase_mqtt_oversize.json', out)
+ t.close()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/telemetry_bench/phase_payload.py b/tools/telemetry_bench/phase_payload.py
new file mode 100644
index 0000000..d64f16a
--- /dev/null
+++ b/tools/telemetry_bench/phase_payload.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python3
+"""Phase D — payload builders and value integrity.
+
+Two questions the throughput numbers cannot answer:
+
+ 1. Do the three payload modes (json / csv / custom) actually produce what
+ they claim? Raw request bodies are captured verbatim and checked.
+ 2. Are the *values* that arrive the values that are on flash? The same
+ records are read back from the .h5 files with the reference codec and
+ compared field by field. Throughput is worthless if the numbers are wrong.
+"""
+import json
+import os
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, '/home/angelo/Documentos/simut/tools')
+import campaign as C # noqa: E402
+from bench import Target, Server, kill_stale # noqa: E402
+import history_v5 as h5 # noqa: E402
+
+
+def capture(t, mode, seconds=45, batch=5):
+ kill_stale()
+ srv = Server('http', f'payload_{mode}', C.OUT, port=C.PORT_HTTP, mode='ok',
+ raw_dump=6)
+ C.cfg_http(t, C.HOST_IP, C.PORT_HTTP, crypto=False, batch=batch,
+ interval=2000, mode=mode, path='/ingest')
+ C.tel_reset(t)
+ C.tel_sync(t, wait=30)
+ time.sleep(seconds)
+ st = srv.stats()
+ srv.stop()
+ return {
+ 'mode': mode,
+ 'requests': st.get('requests'),
+ 'records_parsed': st.get('records'),
+ 'bytes': st.get('bytes_in'),
+ 'bodies': st.get('raw_bodies', [])[:3],
+ 'avg_bytes_per_request': (round(st['bytes_in'] / st['requests'], 1)
+ if st.get('requests') else None),
+ }
+
+
+def integrity(t, seconds=60, batch=25):
+ """Send a slice of history, then read the same epochs off flash and compare."""
+ kill_stale()
+ srv = Server('http', 'integrity', C.OUT, port=C.PORT_HTTP, mode='ok',
+ raw_dump=3)
+ C.cfg_http(t, C.HOST_IP, C.PORT_HTTP, crypto=False, batch=batch,
+ interval=1000, mode='json', path='/ingest')
+ C.tel_reset(t)
+ C.tel_sync(t, wait=30)
+ time.sleep(seconds)
+ recs = srv.records()
+ srv.stop()
+ if not recs:
+ return {'error': 'no records received'}
+
+ by_epoch = {}
+ for r in recs:
+ ts = r.get('ts')
+ if isinstance(ts, int):
+ by_epoch[ts] = r
+
+ # Ground truth for the days those epochs fall on.
+ import datetime as dt
+ days = sorted({dt.datetime.fromtimestamp(e).strftime('%Y%m%d') for e in by_epoch})
+ w = C.web_session()
+ disk = {}
+ schema_keys = None
+ for d in days:
+ r = w.get(f'/download?file=/history/{d}.h5')
+ if r.status_code != 200:
+ continue
+ for sch, epoch, vals in h5.read_series(r.content, nominal_interval_s=60):
+ if schema_keys is None:
+ schema_keys = [(c.id, c.kind, c.scale_exp) for c in sch]
+ if epoch in by_epoch:
+ disk[epoch] = (sch, vals)
+
+ compared = matched = 0
+ mismatches = []
+ missing_on_disk = []
+ for epoch, rec in sorted(by_epoch.items()):
+ if epoch not in disk:
+ missing_on_disk.append(epoch)
+ continue
+ sch, vals = disk[epoch]
+ compared += 1
+ ok = True
+ for c, v in zip(sch, vals):
+ if v == h5.H5_NAN:
+ continue
+ fv = v * (10.0 ** c.scale_exp)
+ # Find the matching key in the telemetry record by value, since
+ # the JSON key encodes slot+hwId rather than the schema id.
+ hit = any(abs(float(x) - fv) < 0.051
+ for k, x in rec.items()
+ if k != 'ts' and isinstance(x, (int, float)))
+ if not hit:
+ ok = False
+ if len(mismatches) < 12:
+ mismatches.append({'epoch': epoch, 'chan_id': c.id,
+ 'disk_value': round(fv, 3),
+ 'record': rec})
+ break
+ if ok:
+ matched += 1
+ return {
+ 'received': len(recs), 'unique_epochs': len(by_epoch),
+ 'days_fetched': days,
+ 'compared': compared, 'matched': matched,
+ 'match_pct': round(100.0 * matched / max(1, compared), 2),
+ 'missing_on_disk': len(missing_on_disk),
+ 'missing_sample': missing_on_disk[:5],
+ 'mismatches': mismatches,
+ 'schema': schema_keys,
+ 'sample_record': recs[0],
+ }
+
+
+def main():
+ kill_stale()
+ t = Target(os.path.join(C.OUT, 'serial_payload.log'))
+ time.sleep(1)
+ out = {}
+ for mode in ('json', 'csv', 'custom'):
+ C.log(f'--- payload mode {mode}')
+ out[mode] = capture(t, mode)
+ C.log(json.dumps({k: out[mode][k] for k in
+ ('requests', 'records_parsed', 'avg_bytes_per_request')}))
+ for b in out[mode]['bodies'][:1]:
+ C.log(' body: ' + b[:300])
+ C.save('phase_payload.json', out)
+
+ C.log('--- restoring json mode for the integrity check')
+ out['integrity'] = integrity(t)
+ C.log(json.dumps({k: out['integrity'].get(k) for k in
+ ('received', 'unique_epochs', 'compared', 'matched',
+ 'match_pct', 'missing_on_disk')}))
+ C.save('phase_payload.json', out)
+ t.close()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/telemetry_bench/phase_perf.py b/tools/telemetry_bench/phase_perf.py
new file mode 100644
index 0000000..4b19253
--- /dev/null
+++ b/tools/telemetry_bench/phase_perf.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+"""Phase A — telemetry throughput and latency, per transport and batch size.
+
+Each run starts from the same place: `tel reset` puts the cursor back to the
+firmware's 30-day floor, so every run has the same large backlog to chew on and
+the numbers compare. Without that the first run drains the queue and every run
+after it measures an idle device.
+
+Three instruments per run:
+ server what actually arrived (requests, records, bytes, wall clock)
+ /api/status what the device believes (telSent/telFailed/latency/heap)
+ serial whether it rebooted
+"""
+import json
+import os
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import campaign as C # noqa: E402
+from bench import Target, Server, kill_stale, wait_web # noqa: E402
+
+
+def run_window(t, srv, seconds, label, period=2.0):
+ """Sample the device while telemetry runs, then reduce to one row."""
+ s0 = C.status()
+ m0 = s0.get('metr', {})
+ boots0 = t.port_drops
+ fatal0 = len(t.fatal_lines)
+ samples = []
+ t0 = time.time()
+ while time.time() - t0 < seconds:
+ s = C.status()
+ s['_t'] = round(time.time() - t0, 1)
+ samples.append(s)
+ time.sleep(period)
+ s1 = C.status()
+ m1 = s1.get('metr', {})
+ st = srv.stats() if srv else {}
+
+ lat = [s.get('metr', {}).get('tl') for s in samples if s.get('metr')]
+ lat = [x for x in lat if isinstance(x, int) and x > 0]
+ heap = [s.get('sys', {}).get('heap_f') for s in samples if s.get('sys')]
+ lb = [s.get('metr', {}).get('lb') for s in samples if s.get('metr')]
+ up = [s.get('sys', {}).get('uptime') for s in samples if s.get('sys')]
+ resets = sum(1 for a, b in zip(up, up[1:])
+ if a is not None and b is not None and b < a)
+
+ def d(k):
+ a, b = m0.get(k), m1.get(k)
+ return (b - a) if isinstance(a, int) and isinstance(b, int) else None
+
+ lat_sorted = sorted(set(lat))
+ row = {
+ 'label': label,
+ 'seconds': seconds,
+ 'dev_sent': d('ts'), 'dev_failed': d('tf'), 'dev_retries': d('tr'),
+ 'dev_bytes': d('tb'),
+ 'dev_lat_samples': sorted(lat),
+ 'dev_lat_min': min(lat) if lat else None,
+ 'dev_lat_med': lat_sorted[len(lat_sorted) // 2] if lat_sorted else None,
+ 'dev_lat_max': max(lat) if lat else None,
+ 'heap_min': min([h for h in heap if h], default=None),
+ 'heap_max': max([h for h in heap if h], default=None),
+ 'largest_min': min([x for x in lb if x], default=None),
+ 'largest_max': max([x for x in lb if x], default=None),
+ 'pending_start': s0.get('sys', {}).get('pending'),
+ 'pending_end': s1.get('sys', {}).get('pending'),
+ 'uptime_resets': resets,
+ 'usb_drops': t.port_drops - boots0,
+ 'fatal_lines': len(t.fatal_lines) - fatal0,
+ 'unreachable_polls': sum(1 for s in samples if '_err' in s or '_http' in s),
+ 'srv_requests': st.get('requests') or st.get('publishes'),
+ 'srv_records': st.get('records'),
+ 'srv_bytes': st.get('bytes_in'),
+ 'srv_conns': st.get('conns'),
+ 'srv_connects': st.get('connects'),
+ 'srv_tls_ok': st.get('tls_ok'),
+ 'srv_tls_fail': st.get('tls_failures'),
+ 'srv_ms_p50': st.get('server_ms_p50'),
+ 'srv_epoch_min': st.get('epoch_min'),
+ 'srv_epoch_max': st.get('epoch_max'),
+ }
+ if row['srv_records'] is not None:
+ row['records_per_s'] = round(row['srv_records'] / seconds, 2)
+ row['bytes_per_s'] = round((row['srv_bytes'] or 0) / seconds, 1)
+ if row['srv_requests']:
+ row['s_between_sends'] = round(seconds / row['srv_requests'], 2)
+ row['records_per_send'] = round((row['srv_records'] or 0) / row['srv_requests'], 1)
+ return row, samples
+
+
+def http_matrix(t, tls, port, batches, seconds, tag):
+ rows = []
+ for b in batches:
+ label = f'{"https" if tls else "http"}_batch{b}'
+ C.log(f'--- {label}')
+ kill_stale()
+ srv = Server('http', f'{tag}_{label}', C.OUT, port=port, mode='ok',
+ tls=tls, cert='certs/cert.pem', key='certs/key.pem')
+ C.cfg_http(t, C.HOST_IP, port, crypto=tls, batch=b, interval=1000,
+ mode='json', path='/ingest')
+ C.tel_reset(t)
+ # `tel sync` calls resetBackoff() before it does anything else, so it
+ # is the only way to clear an escalated backoff without a reboot.
+ # Skipping it measures the backoff timer, not the transport.
+ C.tel_sync(t)
+ time.sleep(3)
+ row, samples = run_window(t, srv, seconds, label)
+ row['batch'] = b
+ row['tls'] = tls
+ rows.append(row)
+ C.log(json.dumps({k: row[k] for k in
+ ('label', 'srv_requests', 'srv_records', 'records_per_s',
+ 'dev_lat_med', 'dev_lat_max', 'dev_failed', 'heap_min',
+ 'largest_min', 'usb_drops')}))
+ srv.stop()
+ return rows
+
+
+def main():
+ seconds = int(sys.argv[1]) if len(sys.argv) > 1 else 90
+ kill_stale()
+ t = Target(os.path.join(C.OUT, 'serial_perf.log'))
+ time.sleep(1)
+ out = {'started': time.time(), 'seconds_per_run': seconds, 'rows': []}
+
+ C.log('=== HTTP plain ===')
+ out['rows'] += http_matrix(t, False, C.PORT_HTTP, [1, 10, 25, 50], seconds, 'perf')
+ C.save('phase_perf_http.json', out)
+
+ C.log('=== HTTPS ===')
+ out['rows'] += http_matrix(t, True, C.PORT_HTTPS, [1, 10, 25, 50], seconds, 'perf')
+ C.save('phase_perf_http.json', out)
+
+ t.close()
+ C.log('done')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/telemetry_bench/phase_survive.py b/tools/telemetry_bench/phase_survive.py
new file mode 100644
index 0000000..7ec4612
--- /dev/null
+++ b/tools/telemetry_bench/phase_survive.py
@@ -0,0 +1,226 @@
+#!/usr/bin/env python3
+"""Phase C — survival against broken servers.
+
+Each fault gets the same three-act structure, because "the device did not
+reboot" is only half the question. The other half is whether it silently threw
+data away.
+
+ act 1 good sink, `tel reset` + `tel sync` → note the last epoch accepted (E1)
+ act 2 swap in the fault, run it for the full window, watch for death
+ act 3 good sink again, `tel sync` → note the first epoch accepted (E2)
+
+If E2 is more than one sampling interval past E1, the device advanced its cursor
+over records no server ever acknowledged: data loss. That check is the point of
+acts 1 and 3 — without them a fault that quietly eats history looks identical to
+one the device shrugged off.
+"""
+import json
+import os
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import campaign as C # noqa: E402
+from bench import Target, Server, kill_stale # noqa: E402
+
+HIST_INTERVAL_S = 60 # h_int = 1 minute
+
+
+def good_server(tag, port, tls):
+ return Server('http', tag, C.OUT, port=port, mode='ok', tls=tls,
+ cert='certs/cert.pem', key='certs/key.pem')
+
+
+def act_baseline(t, tag, port, tls, batch=10):
+ """Drain a couple of batches through a healthy sink and report where the
+ cursor got to."""
+ kill_stale()
+ srv = good_server(f'{tag}_pre', port, tls)
+ C.cfg_http(t, C.HOST_IP, port, crypto=tls, batch=batch, interval=1000,
+ mode='json', path='/ingest')
+ C.tel_reset(t)
+ C.tel_sync(t)
+ time.sleep(12)
+ st = srv.stats()
+ srv.stop()
+ return {
+ 'requests': st.get('requests'), 'records': st.get('records'),
+ 'epoch_min': st.get('epoch_min'), 'epoch_max': st.get('epoch_max'),
+ }
+
+
+def act_recover(t, tag, port, tls, batch=10, settle=25):
+ kill_stale()
+ srv = good_server(f'{tag}_post', port, tls)
+ C.tel_sync(t)
+ time.sleep(settle)
+ st = srv.stats()
+ srv.stop()
+ return {
+ 'requests': st.get('requests'), 'records': st.get('records'),
+ 'epoch_min': st.get('epoch_min'), 'epoch_max': st.get('epoch_max'),
+ }
+
+
+def run_fault(t, name, port, tls, seconds, server_kw=None, no_server=False,
+ batch=10, host=None):
+ C.log(f'=== fault: {name} ({seconds}s)')
+ pre = act_baseline(t, name, port, tls, batch=batch)
+ C.log(f' baseline: {pre}')
+
+ kill_stale()
+ srv = None
+ if not no_server:
+ kw = dict(port=port, tls=tls, cert='certs/cert.pem', key='certs/key.pem')
+ kw.update(server_kw or {})
+ srv = Server('http', f'{name}_fault', C.OUT, **kw)
+
+ if host:
+ # Faults that live in the address itself: a name that does not resolve,
+ # and an address that swallows the SYN instead of refusing it. Neither
+ # can be produced by a listening socket.
+ C.cfg_http(t, host, port, crypto=tls, batch=batch, interval=1000,
+ mode='json', path='/ingest')
+
+ # Clear the backoff so the window is spent attacking the fault rather than
+ # waiting out a timer inherited from the baseline act.
+ C.tel_sync(t, wait=30)
+
+ boots0, fatal0 = t.port_drops, len(t.fatal_lines)
+ samples = []
+ t0 = time.time()
+ web_fail_streak = 0
+ worst_streak = 0
+ while time.time() - t0 < seconds:
+ s = C.status()
+ s['_t'] = round(time.time() - t0, 1)
+ samples.append(s)
+ if '_err' in s or '_http' in s:
+ web_fail_streak += 1
+ worst_streak = max(worst_streak, web_fail_streak)
+ else:
+ web_fail_streak = 0
+ time.sleep(2)
+
+ st = srv.stats() if srv else {}
+ if srv:
+ srv.stop()
+
+ up = [s.get('sys', {}).get('uptime') for s in samples if s.get('sys')]
+ heap = [s.get('sys', {}).get('heap_f') for s in samples if s.get('sys')]
+ lb = [s.get('metr', {}).get('lb') for s in samples if s.get('metr')]
+ tf = [s.get('metr', {}).get('tf') for s in samples if s.get('metr')]
+ ts_ = [s.get('metr', {}).get('ts') for s in samples if s.get('metr')]
+ resets = sum(1 for a, b in zip(up, up[1:])
+ if a is not None and b is not None and b < a)
+
+ if host:
+ C.cfg_http(t, C.HOST_IP, port, crypto=tls, batch=batch, interval=1000,
+ mode='json', path='/ingest')
+ post = act_recover(t, name, port, tls, batch=batch)
+ C.log(f' recovery: {post}')
+
+ gap = None
+ lost = None
+ if pre.get('epoch_max') and post.get('epoch_min'):
+ gap = post['epoch_min'] - pre['epoch_max']
+ lost = max(0, (gap // HIST_INTERVAL_S) - 1)
+
+ row = {
+ 'fault': name, 'seconds': seconds, 'tls': tls,
+ 'server_kw': server_kw, 'no_server': no_server,
+ 'pre': pre, 'post': post,
+ 'cursor_gap_s': gap,
+ 'records_skipped': lost,
+ 'uptime_resets': resets,
+ 'usb_drops': t.port_drops - boots0,
+ 'fatal_lines': len(t.fatal_lines) - fatal0,
+ 'fatal_text': [l for _, l in t.fatal_lines[fatal0:]][:6],
+ 'web_unreachable_polls': sum(1 for s in samples if '_err' in s or '_http' in s),
+ 'web_worst_streak_polls': worst_streak,
+ 'heap_min': min([h for h in heap if h], default=None),
+ 'heap_max': max([h for h in heap if h], default=None),
+ 'largest_min': min([x for x in lb if x], default=None),
+ 'dev_failed_delta': (tf[-1] - tf[0]) if len(tf) > 1 and None not in (tf[0], tf[-1]) else None,
+ 'dev_sent_delta': (ts_[-1] - ts_[0]) if len(ts_) > 1 and None not in (ts_[0], ts_[-1]) else None,
+ 'srv_conns': st.get('conns'), 'srv_requests': st.get('requests'),
+ 'srv_records': st.get('records'),
+ 'srv_tls_ok': st.get('tls_ok'), 'srv_tls_fail': st.get('tls_failures'),
+ 'uptime_first': up[0] if up else None,
+ 'uptime_last': up[-1] if up else None,
+ }
+ verdict = []
+ if row['usb_drops'] or resets:
+ verdict.append('REBOOT')
+ if row['fatal_lines']:
+ verdict.append('FATAL')
+ if lost:
+ verdict.append(f'DATA-LOSS({lost} rec)')
+ if worst_streak >= 3:
+ verdict.append(f'WEB-STALL({worst_streak * 2}s)')
+ if not post.get('records'):
+ verdict.append('NO-RECOVERY')
+ row['verdict'] = ' '.join(verdict) if verdict else 'SURVIVED'
+ C.log(f' -> {row["verdict"]} '
+ f'(reboots={row["usb_drops"]}/{resets} devFail+{row["dev_failed_delta"]} '
+ f'heapMin={row["heap_min"]} srvConns={row["srv_conns"]})')
+ return row
+
+
+HTTP_FAULTS = [
+ ('refused', dict(no_server=True)),
+ ('blackhole', dict(server_kw={'mode': 'blackhole', 'delay': 120})),
+ ('slow20', dict(server_kw={'mode': 'slow', 'delay': 20})),
+ ('half', dict(server_kw={'mode': 'half'})),
+ ('rst', dict(server_kw={'mode': 'rst'})),
+ ('rst_mid', dict(server_kw={'mode': 'rst_mid'})),
+ ('garbage', dict(server_kw={'mode': 'garbage'})),
+ ('huge1mb', dict(server_kw={'mode': 'huge', 'huge_bytes': 1048576})),
+ ('drip', dict(server_kw={'mode': 'drip', 'drip_ms': 400})),
+ ('error500', dict(server_kw={'mode': 'error500'})),
+ ('error401', dict(server_kw={'mode': 'error401'})),
+ ('close_early', dict(server_kw={'mode': 'close_early'})),
+ # RFC 5737 TEST-NET-1: routed nowhere, so the SYN is swallowed rather than
+ # refused — the connect() blocks instead of failing fast.
+ ('syn_blackhole', dict(no_server=True, host='192.0.2.1')),
+ ('dns_fail', dict(no_server=True, host='nao-existe.invalid')),
+]
+
+TLS_FAULTS = [
+ ('tls_blackhole', dict(server_kw={'mode': 'ok', 'tls_fault': 'blackhole', 'delay': 120})),
+ ('tls_garbage', dict(server_kw={'mode': 'ok', 'tls_fault': 'garbage'})),
+ ('tls_rst', dict(server_kw={'mode': 'ok', 'tls_fault': 'rst'})),
+ ('tls_slow20', dict(server_kw={'mode': 'ok', 'tls_fault': 'slow', 'delay': 20})),
+ ('tls_refused', dict(no_server=True)),
+ ('tls_error500', dict(server_kw={'mode': 'error500'})),
+ ('tls_blackhole_http', dict(server_kw={'mode': 'blackhole', 'delay': 120})),
+]
+
+
+def main():
+ seconds = int(sys.argv[1]) if len(sys.argv) > 1 else 120
+ which = sys.argv[2] if len(sys.argv) > 2 else 'all'
+ kill_stale()
+ t = Target(os.path.join(C.OUT, 'serial_survive.log'))
+ time.sleep(1)
+ rows = []
+ outname = f'phase_survive_{which}.json'
+
+ if which in ('all', 'http'):
+ for name, kw in HTTP_FAULTS:
+ rows.append(run_fault(t, name, C.PORT_HTTP, False, seconds, **kw))
+ C.save(outname, {'rows': rows})
+
+ if which in ('all', 'tls'):
+ for name, kw in TLS_FAULTS:
+ rows.append(run_fault(t, name, C.PORT_HTTPS, True, seconds, **kw))
+ C.save(outname, {'rows': rows})
+
+ C.save(outname, {'rows': rows})
+ t.close()
+ for r in rows:
+ C.log(f'{r["fault"]:22s} {r["verdict"]}')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/telemetry_bench/rescore.py b/tools/telemetry_bench/rescore.py
new file mode 100644
index 0000000..97dd20d
--- /dev/null
+++ b/tools/telemetry_bench/rescore.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""Re-score the data-loss check, counting what the FAULT server received.
+
+The original check compared only the baseline server's last accepted epoch
+against the recovery server's first one. That is right for a fault that accepts
+nothing — which is most of them — and wrong for any fault that is a working
+server in every respect but the one being tested.
+
+`mq_no_pingresp` is exactly that: a broker that answers CONNECT and PUBLISH
+normally and only ignores PINGREQ. It took 780 records during its window, the
+cursor advanced over them legitimately, and the naive check called the whole
+span lost. Scored properly the answer is zero.
+
+ skipped = (post.epoch_min - max(pre.epoch_max, fault.epoch_max)) / 60 - 1
+
+Rows where the fault server reported no epochs are unchanged, so this cannot
+launder a real loss into a pass — it only removes the false ones.
+"""
+import json
+import os
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+OUT = os.path.join(HERE, 'results')
+INTERVAL = 60
+
+
+def rescore(row):
+ pre = row.get('pre') or {}
+ post = row.get('post') or {}
+ fault_max = row.get('srv_epoch_max')
+ pre_max = pre.get('epoch_max')
+ post_min = post.get('epoch_min')
+ if not pre_max or not post_min:
+ return None, None, None
+ delivered = max([e for e in (pre_max, fault_max) if e] or [pre_max])
+ gap = post_min - delivered
+ skipped = max(0, (gap // INTERVAL) - 1) if gap > 0 else 0
+ return gap, skipped, delivered
+
+
+def main():
+ files = [
+ ('phase_survive_http.json', 'rows', 'HTTP'),
+ ('phase_survive_tls.json', 'rows', 'HTTPS'),
+ ('phase_mqtt_plain.json', 'faults', 'MQTT'),
+ ('phase_mqtt_tls.json', 'faults', 'MQTTS'),
+ ]
+ print(f"{'transporte':<8}{'falha':<24}{'skip antigo':>12}{'skip corrigido':>16}"
+ f"{'gap corrigido':>15}{'aceitou na falha':>18}")
+ total_old = total_new = 0
+ out = []
+ for fn, key, label in files:
+ p = os.path.join(OUT, fn)
+ if not os.path.exists(p):
+ continue
+ with open(p) as fh:
+ d = json.load(fh)
+ for r in d.get(key, []):
+ gap, skipped, delivered = rescore(r)
+ old = r.get('records_skipped')
+ got = r.get('srv_records')
+ if skipped is None:
+ continue
+ total_old += old or 0
+ total_new += skipped
+ flag = ' <-- corrigido' if (old or 0) != skipped else ''
+ print(f"{label:<8}{r.get('fault',''):<24}{str(old):>12}{skipped:>16}"
+ f"{str(gap):>15}{str(got):>18}{flag}")
+ out.append({'transport': label, 'fault': r.get('fault'),
+ 'skipped_naive': old, 'skipped_corrected': skipped,
+ 'gap_corrected': gap, 'fault_server_records': got,
+ 'verdict_naive': r.get('verdict')})
+ print()
+ print(f'TOTAL registros "perdidos": ingênuo={total_old} corrigido={total_new}')
+ with open(os.path.join(OUT, 'rescore.json'), 'w') as fh:
+ json.dump({'rows': out, 'total_naive': total_old,
+ 'total_corrected': total_new}, fh, indent=1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/telemetry_bench/revalidate.py b/tools/telemetry_bench/revalidate.py
new file mode 100644
index 0000000..579a63d
--- /dev/null
+++ b/tools/telemetry_bench/revalidate.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+"""Re-run, against the fixed firmware, every test that failed or exposed a defect.
+
+A fix is only a fix if the thing that caught it now passes and nothing else
+moved. So this runs three groups:
+
+ regressions the faults that killed or misreported (huge1mb, drip, error500,
+ error401) — same servers, same windows, compared to the numbers
+ the broken build produced
+ fixes the defects proved by inspection rather than by a crash
+ (CSV header, /api/ls completeness, MQTT credentials)
+ no-regression a perf spot-check at the two batch sizes that matter, to show
+ the bounded read loops did not cost throughput
+"""
+import datetime as dt
+import json
+import os
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import campaign as C # noqa: E402
+import phase_survive as PS # noqa: E402
+from bench import Target, Server, kill_stale # noqa: E402
+
+OUT = C.OUT
+
+
+def reboot_and_wait(t, why=''):
+ """Give the run a clean network stack.
+
+ D14: a burst of 1 MB responses leaks the 12-entry lwIP pbuf pool and the web
+ server goes silent until reboot — with the device otherwise alive. Since
+ /api/status is one of the three instruments, a run that starts on an
+ exhausted pool measures the leak instead of the fix. Reboot between groups
+ and let the ordering keep the leaky test last.
+ """
+ C.log(f'--- reboot {why}')
+ t.send('enable', wait=1.5)
+ t.send('reload confirm', wait=3.0)
+ time.sleep(8)
+ back = C.wait_web(C.DEV, timeout=120)
+ C.web_session(force=True)
+ C.log(f' web back after {back}s')
+ return back
+
+
+def group_regressions(t, seconds=120):
+ rows = []
+ # huge1mb LAST on purpose: it is the one that leaks the pbuf pool, so
+ # anything after it would be measuring D14 rather than its own fault.
+ for name, kw in [
+ ('error500', dict(server_kw={'mode': 'error500'})),
+ ('error401', dict(server_kw={'mode': 'error401'})),
+ ('drip', dict(server_kw={'mode': 'drip', 'drip_ms': 400})),
+ ]:
+ rows.append(PS.run_fault(t, name, C.PORT_HTTP, False, seconds, **kw))
+ reboot_and_wait(t, f'after {name}')
+ # The TLS kill is reached through a different door — WiFiClientSecure's
+ # available() drives the BearSSL engine, so the same unbounded drain in
+ # HTTPClient::disconnect() blocks there too — but it should fall to the
+ # same fix. Verify rather than assume.
+ rows.append(PS.run_fault(t, 'tls_slow20', C.PORT_HTTPS, True, seconds,
+ server_kw={'mode': 'ok', 'tls_fault': 'slow',
+ 'delay': 20}))
+ reboot_and_wait(t, 'after tls_slow20')
+ rows.append(PS.run_fault(t, 'huge1mb', C.PORT_HTTP, False, seconds,
+ server_kw={'mode': 'huge', 'huge_bytes': 1048576}))
+ return rows
+
+
+def check_csv_header(t, seconds=45):
+ kill_stale()
+ srv = Server('http', 'reval_csv', OUT, port=C.PORT_HTTP, mode='ok',
+ raw_dump=6)
+ C.cfg_http(t, C.HOST_IP, C.PORT_HTTP, crypto=False, batch=5,
+ interval=2000, mode='csv', path='/ingest')
+ C.tel_reset(t)
+ C.tel_sync(t, wait=30)
+ time.sleep(seconds)
+ st = srv.stats()
+ srv.stop()
+ body = None
+ for b in st.get('raw_bodies', []):
+ if b.startswith('timestamp;'):
+ body = b
+ break
+ res = {'found_csv_body': body is not None}
+ if body:
+ lines = [l for l in body.split('\n') if l]
+ hdr = lines[0].split(';')
+ widths = {len(l.split(';')) for l in lines[1:]}
+ res.update({
+ 'header_cols': len(hdr),
+ 'row_cols': sorted(widths),
+ 'match': len(widths) == 1 and len(hdr) == list(widths)[0],
+ 'header': lines[0][:400],
+ 'first_row': lines[1][:200] if len(lines) > 1 else None,
+ })
+ # Put the transport back the way the other checks expect it.
+ C.cfg_http(t, C.HOST_IP, C.PORT_HTTP, crypto=False, batch=10,
+ interval=1000, mode='json', path='/ingest')
+ return res
+
+
+def check_ls_complete():
+ """Does /api/ls now report everything — and say so when it cannot?"""
+ w = C.web_session(force=True)
+ listings = []
+ for _ in range(4):
+ try:
+ j = w.get('/api/ls?dir=/history').json()
+ listings.append({'n': len(j.get('entries', [])),
+ 'truncated': j.get('truncated', False),
+ 'names': sorted(e['n'] for e in j.get('entries', []))})
+ except Exception as e:
+ listings.append({'err': str(e)[:80]})
+ w = C.web_session(force=True)
+ time.sleep(1.5)
+ ok = [l for l in listings if 'names' in l]
+ stable = len({tuple(l['names']) for l in ok}) <= 1 if ok else False
+ inv = os.path.join(OUT, 'history_inventory.json')
+ truth = None
+ if os.path.exists(inv):
+ with open(inv) as fh:
+ truth = json.load(fh)
+ res = {
+ 'listings': [{k: v for k, v in l.items() if k != 'names'} for l in listings],
+ 'stable_across_calls': stable,
+ 'counts': [l.get('n') for l in listings],
+ }
+ if truth and ok:
+ real = {p['file'] for p in truth['per_file']}
+ got = set(ok[-1]['names'])
+ res.update({
+ 'files_on_flash': len(real),
+ 'files_listed': len(got),
+ 'missing_from_listing': sorted(real - got),
+ 'complete': real.issubset(got),
+ })
+ return res
+
+
+def check_mqtt_credentials(t, seconds=70):
+ """D1: does the password typed on the config page reach the broker?"""
+ import phase_mqtt as PM
+ res = PM.config_fidelity(t, C.web_session(), C.PORT_MQTT, seconds=seconds)
+ # Put the transport back on HTTP before anything else runs. telTransport is
+ # read once, in TelemetryManager::begin( ), so only a commit_all + reboot
+ # moves it — a CLI `tel server`/`tel port` cannot. Skipping this left the
+ # device speaking MQTT at an HTTP sink and the next perf run measured
+ # 0 records/s, which reads exactly like a throughput regression and is not.
+ C.log('--- restoring HTTP transport after the MQTT check')
+ C.commit(C.web_session(), {
+ 't_transport': '0', 't_sec': '0', 't_srv': C.HOST_IP,
+ 't_port': str(C.PORT_HTTP), 't_path': '/ingest',
+ 't_int': '1000', 't_bat': '50', 't_mode': '0',
+ 'm_topic': 'simut/data', 'm_cid': '', 'm_user': '',
+ 'm_qos': '0', 'm_retain': '0', 'm_ka': '60'})
+ C.web_session(force=True)
+ time.sleep(5)
+ return res
+
+
+def perf_spotcheck(t, seconds=90):
+ import phase_perf as PP
+ rows = []
+ rows += PP.http_matrix(t, False, C.PORT_HTTP, [50], seconds, 'reval')
+ rows += PP.http_matrix(t, True, C.PORT_HTTPS, [50], seconds, 'reval')
+ return rows
+
+
+def main():
+ which = sys.argv[1] if len(sys.argv) > 1 else 'all'
+ kill_stale()
+ t = Target(os.path.join(OUT, 'serial_reval.log'))
+ time.sleep(1)
+ out = {'started': dt.datetime.now().isoformat()}
+
+ if which in ('all', 'fixes'):
+ C.log('=== CSV header')
+ out['csv'] = check_csv_header(t)
+ C.log(json.dumps({k: v for k, v in out['csv'].items() if k != 'header'}))
+ C.log(' header: ' + str(out['csv'].get('header'))[:300])
+ C.save('revalidate.json', out)
+
+ C.log('=== /api/ls completeness')
+ out['api_ls'] = check_ls_complete()
+ C.log(json.dumps({k: v for k, v in out['api_ls'].items()
+ if k != 'listings'}))
+ C.save('revalidate.json', out)
+
+ C.log('=== MQTT credentials on the wire')
+ out['mqtt_fidelity'] = check_mqtt_credentials(t)
+ C.log(json.dumps(out['mqtt_fidelity']['checks']))
+ C.save('revalidate.json', out)
+
+ if which in ('all', 'perf'):
+ C.log('=== perf spot-check (no-regression)')
+ out['perf'] = perf_spotcheck(t)
+ C.save('revalidate.json', out)
+
+ # Last, because huge1mb leaks the pbuf pool and takes the web server with
+ # it (D14). Everything that needs /api/status has already run by now.
+ if which in ('all', 'regress'):
+ C.log('=== regressions: the faults that failed on the broken build')
+ out['regressions'] = group_regressions(t)
+ for r in out['regressions']:
+ C.log(f' {r["fault"]:10s} {r["verdict"]:40s} '
+ f'reboots={r["usb_drops"]} devFail+{r["dev_failed_delta"]}')
+ C.save('revalidate.json', out)
+
+ t.close()
+ C.save('revalidate.json', out)
+ C.log('revalidation done')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/telemetry_bench/run_rest.sh b/tools/telemetry_bench/run_rest.sh
new file mode 100755
index 0000000..d540b41
--- /dev/null
+++ b/tools/telemetry_bench/run_rest.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+# Runs the remaining phases back to back. Only one may hold the serial port at
+# a time, so this is strictly sequential by design.
+set -u
+cd "$(dirname "$0")"
+exec >> results/run_rest.out 2>&1
+
+stamp() { echo "=== $(date +%H:%M:%S) $*"; }
+
+stamp "waiting for phase_perf to finish"
+while pgrep -f "[p]hase_perf.py" > /dev/null; do sleep 5; done
+
+stamp "phase_payload"
+python3 phase_payload.py
+
+stamp "phase_drain"
+python3 phase_drain.py 2400
+
+stamp "phase_survive http"
+python3 phase_survive.py 120 http
+
+stamp "phase_survive tls"
+python3 phase_survive.py 120 tls
+
+stamp "phase_mqtt plain"
+python3 phase_mqtt.py 90 plain
+
+stamp "phase_mqtt tls"
+python3 phase_mqtt.py 90 tls
+
+stamp "ALL PHASES DONE"
diff --git a/tools/telemetry_bench/run_rest2.sh b/tools/telemetry_bench/run_rest2.sh
new file mode 100755
index 0000000..32e769b
--- /dev/null
+++ b/tools/telemetry_bench/run_rest2.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# Second leg: the two runs that need the MQTT phases out of the way first.
+set -u
+cd "$(dirname "$0")"
+exec >> results/run_rest2.out 2>&1
+
+stamp() { echo "=== $(date +%H:%M:%S) $*"; }
+
+stamp "waiting for leg 1"
+while pgrep -f "[r]un_rest.sh" > /dev/null; do sleep 10; done
+
+stamp "phase_mqtt_oversize"
+python3 phase_mqtt_oversize.py
+
+stamp "restoring HTTP transport for the full drain (reboot)"
+python3 - <<'PY'
+import sys, time
+sys.path.insert(0, '.')
+import campaign as C
+w = C.web_session()
+C.commit(w, {'t_transport': '0', 't_sec': '0', 't_srv': C.HOST_IP,
+ 't_port': str(C.PORT_HTTP), 't_path': '/ingest',
+ 't_int': '1000', 't_bat': '50', 't_mode': '0',
+ 't_glob': '{"dev":"{DEV}","mac":"{MAC}","data":[{DATA}]}',
+ 't_line': '{"ts":{TS},"t0_ID":{t0},"u0_ID":{u0}}',
+ 't_sep': ','})
+C.web_session(force=True)
+time.sleep(5)
+print('transport back on HTTP')
+PY
+
+stamp "phase_drain_full"
+python3 phase_drain_full.py 4200
+
+stamp "LEG 2 DONE"
diff --git a/tools/telemetry_bench/run_rest3.sh b/tools/telemetry_bench/run_rest3.sh
new file mode 100755
index 0000000..7f6f1ef
--- /dev/null
+++ b/tools/telemetry_bench/run_rest3.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Third leg: true history inventory, once nothing else is loading the device.
+set -u
+cd "$(dirname "$0")"
+exec >> results/run_rest3.out 2>&1
+
+stamp() { echo "=== $(date +%H:%M:%S) $*"; }
+
+stamp "waiting for leg 2"
+while pgrep -f "[r]un_rest2.sh" > /dev/null; do sleep 15; done
+
+stamp "enumerate_history (by date, not by /api/ls)"
+python3 enumerate_history.py
+
+stamp "LEG 3 DONE"
diff --git a/tools/telemetry_bench/server_http.py b/tools/telemetry_bench/server_http.py
new file mode 100644
index 0000000..de2b800
--- /dev/null
+++ b/tools/telemetry_bench/server_http.py
@@ -0,0 +1,393 @@
+#!/usr/bin/env python3
+"""Instrumented HTTP/HTTPS telemetry sink with fault injection.
+
+One process, one port, one mode. The device points its telemetry at it and the
+server records every byte, the wall-clock of every request, and every record it
+could parse out of the payload. Modes other than `ok` are deliberate failures —
+each one models something a real server does when it goes wrong, so the device's
+survival can be measured against a named fault instead of "the network was bad".
+
+Metrics land in a JSON file (--stats) that the orchestrator reads; the raw
+records land in an NDJSON file (--records) so the drained history can be
+compared against what is actually on the device.
+
+Modes
+-----
+ok 200 with the same body shape as the user's real server.
+error500 valid HTTP 500 — device must NOT advance its cursor.
+error401 valid HTTP 401.
+blackhole accept the socket, read the request, answer nothing, hold open.
+slow N accept, then answer only after --delay seconds.
+half send a partial status line and close.
+rst accept then RST (SO_LINGER 0) before reading.
+rst_mid read the request, send half the headers, then RST.
+garbage answer with non-HTTP bytes.
+huge answer 200 with a --huge-bytes body.
+drip answer one byte every --drip-ms ms (slowloris, server side).
+close_early read only the headers, close before the body arrives.
+"""
+import argparse
+import json
+import os
+import re
+import socket
+import ssl
+import struct
+import sys
+import threading
+import time
+
+STATS_LOCK = threading.Lock()
+
+
+class Stats:
+ def __init__(self, path, records_path):
+ self.path = path
+ self.records_path = records_path
+ self.started = time.time()
+ self.conns = 0
+ self.requests = 0
+ self.bytes_in = 0
+ self.records = 0
+ self.batches = [] # per-request: {t, bytes, n, ms, first_epoch, last_epoch}
+ self.tls_failures = 0
+ self.tls_ok = 0
+ self.errors = []
+ self.raw_bodies = []
+ self.epochs_seen = set()
+ self._rf = open(records_path, 'a') if records_path else None
+
+ def add_batch(self, entry, recs):
+ with STATS_LOCK:
+ self.requests += 1
+ self.batches.append(entry)
+ self.records += len(recs)
+ if self._rf:
+ for r in recs:
+ self._rf.write(json.dumps(r, separators=(',', ':')) + '\n')
+ self._rf.flush()
+ for r in recs:
+ ts = r.get('ts')
+ if ts is not None:
+ self.epochs_seen.add(ts)
+
+ def dump(self):
+ with STATS_LOCK:
+ lat = [b['ms'] for b in self.batches if b.get('ms') is not None]
+ lat_sorted = sorted(lat)
+
+ def pct(p):
+ if not lat_sorted:
+ return None
+ k = min(len(lat_sorted) - 1, int(round((p / 100.0) * (len(lat_sorted) - 1))))
+ return lat_sorted[k]
+
+ d = {
+ 'started': self.started,
+ 'now': time.time(),
+ 'elapsed_s': round(time.time() - self.started, 1),
+ 'conns': self.conns,
+ 'requests': self.requests,
+ 'bytes_in': self.bytes_in,
+ 'records': self.records,
+ 'unique_epochs': len(self.epochs_seen),
+ 'epoch_min': min(self.epochs_seen) if self.epochs_seen else None,
+ 'epoch_max': max(self.epochs_seen) if self.epochs_seen else None,
+ 'tls_ok': self.tls_ok,
+ 'tls_failures': self.tls_failures,
+ 'errors': self.errors[-40:],
+ 'raw_bodies': self.raw_bodies,
+ 'server_ms_min': min(lat) if lat else None,
+ 'server_ms_p50': pct(50),
+ 'server_ms_p90': pct(90),
+ 'server_ms_max': max(lat) if lat else None,
+ 'batches': self.batches[-400:],
+ }
+ tmp = self.path + '.tmp'
+ with open(tmp, 'w') as fh:
+ json.dump(d, fh, indent=1)
+ os.replace(tmp, self.path)
+ return d
+
+
+def parse_records(body):
+ """Pull {"ts":...} objects out of the JSON payload.
+
+ Tolerant on purpose: the point is to count what arrived even when the
+ device truncates, so a strict json.loads that throws would hide the very
+ failure being measured.
+ """
+ recs = []
+ try:
+ obj = json.loads(body)
+ if isinstance(obj, list):
+ return [r for r in obj if isinstance(r, dict)]
+ if isinstance(obj, dict):
+ for k in ('data', 'records', 'points'):
+ if isinstance(obj.get(k), list):
+ return [r for r in obj[k] if isinstance(r, dict)]
+ return [obj]
+ except Exception:
+ pass
+ # Fallback: brace-matched scan.
+ depth = 0
+ start = None
+ for i, c in enumerate(body):
+ if c == '{':
+ if depth == 0:
+ start = i
+ depth += 1
+ elif c == '}':
+ depth -= 1
+ if depth == 0 and start is not None:
+ try:
+ recs.append(json.loads(body[start:i + 1]))
+ except Exception:
+ pass
+ start = None
+ return recs
+
+
+def read_request(conn, stats, timeout):
+ conn.settimeout(timeout)
+ buf = b''
+ # headers
+ while b'\r\n\r\n' not in buf:
+ chunk = conn.recv(4096)
+ if not chunk:
+ return None, buf
+ buf += chunk
+ if len(buf) > 1 << 20:
+ break
+ head, _, rest = buf.partition(b'\r\n\r\n')
+ m = re.search(rb'Content-Length:\s*(\d+)', head, re.I)
+ want = int(m.group(1)) if m else 0
+ body = rest
+ while len(body) < want:
+ chunk = conn.recv(min(65536, want - len(body)))
+ if not chunk:
+ break
+ body += chunk
+ return head.decode('latin-1', 'replace'), body
+
+
+def handle(conn, addr, args, stats):
+ t0 = time.time()
+ with STATS_LOCK:
+ stats.conns += 1
+ mode = args.mode
+ try:
+ if mode == 'rst':
+ conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
+ struct.pack('ii', 1, 0))
+ conn.close()
+ return
+
+ head, body = read_request(conn, stats, args.read_timeout)
+ if head is None:
+ return
+ with STATS_LOCK:
+ stats.bytes_in += len(body)
+
+ text = body.decode('utf-8', 'replace') if body else ''
+ if args.raw_dump:
+ with STATS_LOCK:
+ if len(stats.raw_bodies) < args.raw_dump:
+ stats.raw_bodies.append(text[:4096])
+ recs = parse_records(text) if body else []
+ epochs = [r.get('ts') for r in recs if isinstance(r.get('ts'), int)]
+ entry = {
+ 't': round(t0 - stats.started, 3),
+ 'bytes': len(body),
+ 'n': len(recs),
+ 'first_epoch': min(epochs) if epochs else None,
+ 'last_epoch': max(epochs) if epochs else None,
+ 'ms': None,
+ }
+
+ if mode == 'ok':
+ resp = json.dumps({'status': 'ok', 'msg': 'Salvo', 'pts': len(recs)}).encode()
+ conn.sendall(b'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n'
+ b'Content-Length: ' + str(len(resp)).encode() +
+ b'\r\nConnection: close\r\n\r\n' + resp)
+ elif mode == 'slow':
+ time.sleep(args.delay)
+ resp = json.dumps({'status': 'ok', 'pts': len(recs)}).encode()
+ conn.sendall(b'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n'
+ b'Content-Length: ' + str(len(resp)).encode() +
+ b'\r\nConnection: close\r\n\r\n' + resp)
+ elif mode == 'error500':
+ resp = b'{"status":"error","msg":"internal"}'
+ conn.sendall(b'HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\n'
+ b'Content-Length: ' + str(len(resp)).encode() +
+ b'\r\nConnection: close\r\n\r\n' + resp)
+ elif mode == 'error401':
+ resp = b'{"error":"unauthorized"}'
+ conn.sendall(b'HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\n'
+ b'Content-Length: ' + str(len(resp)).encode() +
+ b'\r\nConnection: close\r\n\r\n' + resp)
+ elif mode == 'blackhole':
+ # Answer nothing. Hold the socket open past anything the device
+ # could reasonably wait for, then drop it.
+ time.sleep(args.delay)
+ elif mode == 'half':
+ conn.sendall(b'HTTP/1.1 200 OK\r\nContent-Len')
+ conn.close()
+ return
+ elif mode == 'rst_mid':
+ conn.sendall(b'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n')
+ time.sleep(0.05)
+ conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
+ struct.pack('ii', 1, 0))
+ conn.close()
+ return
+ elif mode == 'garbage':
+ conn.sendall(os.urandom(512))
+ conn.close()
+ return
+ elif mode == 'huge':
+ n = args.huge_bytes
+ conn.sendall(b'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n'
+ b'Content-Length: ' + str(n).encode() + b'\r\n\r\n')
+ blob = b'A' * 4096
+ sent = 0
+ while sent < n:
+ k = min(len(blob), n - sent)
+ conn.sendall(blob[:k])
+ sent += k
+ elif mode == 'drip':
+ resp = (b'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n'
+ b'Content-Length: 2\r\n\r\n{}')
+ for b in resp:
+ conn.sendall(bytes([b]))
+ time.sleep(args.drip_ms / 1000.0)
+ elif mode == 'close_early':
+ conn.close()
+ return
+ else:
+ raise SystemExit(f'unknown mode {mode}')
+
+ entry['ms'] = int((time.time() - t0) * 1000)
+ stats.add_batch(entry, recs)
+ except Exception as e:
+ with STATS_LOCK:
+ stats.errors.append(f'{type(e).__name__}: {e}')
+ finally:
+ try:
+ conn.close()
+ except Exception:
+ pass
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('--port', type=int, required=True)
+ ap.add_argument('--bind', default='0.0.0.0')
+ ap.add_argument('--tls', action='store_true')
+ ap.add_argument('--cert', default='certs/cert.pem')
+ ap.add_argument('--key', default='certs/key.pem')
+ ap.add_argument('--mode', default='ok')
+ ap.add_argument('--delay', type=float, default=30.0)
+ ap.add_argument('--drip-ms', type=float, default=500.0)
+ ap.add_argument('--huge-bytes', type=int, default=1 << 20)
+ ap.add_argument('--read-timeout', type=float, default=30.0)
+ ap.add_argument('--stats', default='stats.json')
+ ap.add_argument('--records', default='')
+ ap.add_argument('--raw-dump', type=int, default=0,
+ help='keep the first N request bodies verbatim, for '
+ 'checking the payload builders (json/csv/custom)')
+ # TLS fault modes act before any HTTP is spoken.
+ ap.add_argument('--tls-fault', default='',
+ choices=['', 'blackhole', 'garbage', 'rst', 'slow'],
+ help='blackhole: accept and never handshake; garbage: junk '
+ 'bytes instead of ServerHello; rst: RST at accept; '
+ 'slow: sleep --delay then handshake')
+ args = ap.parse_args()
+
+ stats = Stats(args.stats, args.records)
+
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ srv.bind((args.bind, args.port))
+ srv.listen(16)
+ ctx = None
+ if args.tls:
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ ctx.load_cert_chain(args.cert, args.key)
+ # BearSSL on the RP2040 speaks TLS 1.2 with ECDHE-RSA-AES-GCM. Pinning
+ # to 1.2 keeps the negotiation identical to the user's real server.
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
+ ctx.maximum_version = ssl.TLSVersion.TLSv1_2
+
+ def dumper():
+ while True:
+ time.sleep(2)
+ try:
+ stats.dump()
+ except Exception:
+ pass
+
+ threading.Thread(target=dumper, daemon=True).start()
+
+ label = ('https' if args.tls else 'http')
+ print(f'[{label}] listening on {args.bind}:{args.port} mode={args.mode} '
+ f'tls_fault={args.tls_fault or "-"}', flush=True)
+
+ while True:
+ try:
+ conn, addr = srv.accept()
+ except OSError:
+ break
+ conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+
+ def wrapped(conn=conn, addr=addr):
+ if args.tls:
+ f = args.tls_fault
+ if f == 'rst':
+ conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
+ struct.pack('ii', 1, 0))
+ with STATS_LOCK:
+ stats.conns += 1
+ conn.close()
+ return
+ if f == 'blackhole':
+ with STATS_LOCK:
+ stats.conns += 1
+ time.sleep(args.delay)
+ conn.close()
+ return
+ if f == 'garbage':
+ with STATS_LOCK:
+ stats.conns += 1
+ try:
+ conn.recv(4096)
+ conn.sendall(os.urandom(2048))
+ except Exception:
+ pass
+ conn.close()
+ return
+ if f == 'slow':
+ time.sleep(args.delay)
+ try:
+ conn.settimeout(args.read_timeout)
+ tconn = ctx.wrap_socket(conn, server_side=True)
+ with STATS_LOCK:
+ stats.tls_ok += 1
+ except Exception as e:
+ with STATS_LOCK:
+ stats.tls_failures += 1
+ stats.errors.append(f'TLS: {type(e).__name__}: {e}')
+ try:
+ conn.close()
+ except Exception:
+ pass
+ return
+ handle(tconn, addr, args, stats)
+ else:
+ handle(conn, addr, args, stats)
+
+ threading.Thread(target=wrapped, daemon=True).start()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/telemetry_bench/server_mqtt.py b/tools/telemetry_bench/server_mqtt.py
new file mode 100644
index 0000000..4fd89b8
--- /dev/null
+++ b/tools/telemetry_bench/server_mqtt.py
@@ -0,0 +1,475 @@
+#!/usr/bin/env python3
+"""Instrumented MQTT 3.1.1 broker with fault injection (plain and TLS).
+
+Written from the wire format rather than wrapping mosquitto, because the point
+is to misbehave on purpose: refuse the CONNECT, answer half a CONNACK, drop the
+socket exactly when the first PUBLISH lands. A real broker has no switch for
+any of that.
+
+Speaks enough of the protocol for the device's PubSubClient: CONNECT/CONNACK,
+PUBLISH (QoS 0 and 1), PUBACK, SUBSCRIBE/SUBACK, PINGREQ/PINGRESP, DISCONNECT.
+Every published message is timestamped and written to --records as NDJSON.
+
+Modes
+-----
+ok full broker.
+rst RST at accept, before CONNECT.
+no_connack read CONNECT, answer nothing, hold the socket.
+slow_connack answer CONNACK after --delay seconds.
+half_connack send 2 of the 4 CONNACK bytes and stall.
+connack_refuse CONNACK rc=5 (not authorized).
+connack_badproto CONNACK rc=1 (unacceptable protocol version).
+connack_badid CONNACK rc=2 (identifier rejected).
+connack_unavail CONNACK rc=3 (server unavailable).
+drop_after_connack CONNACK ok, then close immediately.
+drop_on_publish CONNACK ok, close when the first PUBLISH arrives.
+rst_on_publish CONNACK ok, RST when the first PUBLISH arrives.
+garbage send junk instead of CONNACK.
+no_pingresp full broker except PINGREQ is ignored (keepalive death).
+"""
+import argparse
+import json
+import os
+import socket
+import ssl
+import struct
+import threading
+import time
+
+LOCK = threading.Lock()
+
+CONNECT, CONNACK, PUBLISH, PUBACK = 1, 2, 3, 4
+SUBSCRIBE, SUBACK, PINGREQ, PINGRESP, DISCONNECT = 8, 9, 12, 13, 14
+
+
+class Stats:
+ def __init__(self, path, records_path):
+ self.path = path
+ self.started = time.time()
+ self.conns = 0
+ self.connects = 0
+ self.connacks = 0
+ self.publishes = 0
+ self.bytes_in = 0
+ self.records = 0
+ self.pings = 0
+ self.tls_ok = 0
+ self.tls_failures = 0
+ self.errors = []
+ self.msgs = [] # per-publish: {t, topic, bytes, n, first_epoch, last_epoch}
+ self.epochs_seen = set()
+ self.client_ids = []
+ self.qos_seen = {}
+ self.retain_seen = {}
+ self.will_topics = []
+ self.status_msgs = []
+ self.connect_frames = [] # full CONNECT fields, for config-fidelity checks
+ self.connect_intervals = []
+ self._last_connect = None
+ self._rf = open(records_path, 'a') if records_path else None
+
+ def note_connect(self, cid):
+ with LOCK:
+ self.connects += 1
+ if cid not in self.client_ids:
+ self.client_ids.append(cid)
+ now = time.time()
+ if self._last_connect is not None:
+ self.connect_intervals.append(round(now - self._last_connect, 2))
+ self._last_connect = now
+
+ def note_publish(self, topic, payload, qos=0, retain=False, dup=False):
+ recs = []
+ txt = payload.decode('utf-8', 'replace')
+ try:
+ obj = json.loads(txt)
+ recs = obj if isinstance(obj, list) else [obj]
+ except Exception:
+ depth, start = 0, None
+ for i, c in enumerate(txt):
+ if c == '{':
+ if depth == 0:
+ start = i
+ depth += 1
+ elif c == '}':
+ depth -= 1
+ if depth == 0 and start is not None:
+ try:
+ recs.append(json.loads(txt[start:i + 1]))
+ except Exception:
+ pass
+ start = None
+ recs = [r for r in recs if isinstance(r, dict)]
+ data = [r for r in recs if 'ts' in r]
+ epochs = [r['ts'] for r in data if isinstance(r.get('ts'), int)]
+ with LOCK:
+ self.publishes += 1
+ self.bytes_in += len(payload)
+ self.records += len(data)
+ self.msgs.append({
+ 't': round(time.time() - self.started, 3),
+ 'topic': topic,
+ 'bytes': len(payload),
+ 'n': len(data),
+ 'qos': qos, 'retain': retain, 'dup': dup,
+ 'first_epoch': min(epochs) if epochs else None,
+ 'last_epoch': max(epochs) if epochs else None,
+ })
+ self.qos_seen[qos] = self.qos_seen.get(qos, 0) + 1
+ self.retain_seen[bool(retain)] = self.retain_seen.get(bool(retain), 0) + 1
+ for e in epochs:
+ self.epochs_seen.add(e)
+ if self._rf:
+ for r in data:
+ self._rf.write(json.dumps(r, separators=(',', ':')) + '\n')
+ self._rf.flush()
+
+ def dump(self):
+ with LOCK:
+ d = {
+ 'started': self.started,
+ 'elapsed_s': round(time.time() - self.started, 1),
+ 'conns': self.conns,
+ 'connects': self.connects,
+ 'connacks': self.connacks,
+ 'publishes': self.publishes,
+ 'records': self.records,
+ 'bytes_in': self.bytes_in,
+ 'pings': self.pings,
+ 'unique_epochs': len(self.epochs_seen),
+ 'epoch_min': min(self.epochs_seen) if self.epochs_seen else None,
+ 'epoch_max': max(self.epochs_seen) if self.epochs_seen else None,
+ 'tls_ok': self.tls_ok,
+ 'tls_failures': self.tls_failures,
+ 'client_ids': self.client_ids[:10],
+ 'qos_seen': self.qos_seen,
+ 'retain_seen': {str(k): v for k, v in self.retain_seen.items()},
+ 'will_topics': self.will_topics[:5],
+ 'connect_frames': self.connect_frames[:5],
+ 'status_msgs': self.status_msgs[:10],
+ 'connect_intervals': self.connect_intervals[-60:],
+ 'errors': self.errors[-40:],
+ 'msgs': self.msgs[-400:],
+ }
+ tmp = self.path + '.tmp'
+ with open(tmp, 'w') as fh:
+ json.dump(d, fh, indent=1)
+ os.replace(tmp, self.path)
+ return d
+
+
+def read_exact(sock, n):
+ buf = b''
+ while len(buf) < n:
+ c = sock.recv(n - len(buf))
+ if not c:
+ return None
+ buf += c
+ return buf
+
+
+def read_packet(sock):
+ """Return (ptype, flags, payload) or None on clean EOF."""
+ b0 = read_exact(sock, 1)
+ if b0 is None:
+ return None
+ ptype = b0[0] >> 4
+ flags = b0[0] & 0x0F
+ mult, length = 1, 0
+ while True:
+ b = read_exact(sock, 1)
+ if b is None:
+ return None
+ length += (b[0] & 0x7F) * mult
+ if not (b[0] & 0x80):
+ break
+ mult *= 128
+ if mult > 128 ** 4:
+ raise ValueError('malformed remaining length')
+ body = read_exact(sock, length) if length else b''
+ if body is None:
+ return None
+ return ptype, flags, body
+
+
+def enc_len(n):
+ out = b''
+ while True:
+ d = n % 128
+ n //= 128
+ if n:
+ d |= 0x80
+ out += bytes([d])
+ if not n:
+ return out
+
+
+def parse_connect(body):
+ """Return dict with protocol name/level, client id, will, user, keepalive."""
+ i = 0
+ nlen = struct.unpack('>H', body[i:i + 2])[0]
+ i += 2
+ name = body[i:i + nlen].decode('latin-1')
+ i += nlen
+ level = body[i]
+ i += 1
+ cflags = body[i]
+ i += 1
+ keepalive = struct.unpack('>H', body[i:i + 2])[0]
+ i += 2
+
+ def rd_str():
+ nonlocal i
+ ln = struct.unpack('>H', body[i:i + 2])[0]
+ i += 2
+ s = body[i:i + ln]
+ i += ln
+ return s
+
+ cid = rd_str().decode('utf-8', 'replace')
+ will_topic = will_msg = None
+ if cflags & 0x04:
+ will_topic = rd_str().decode('utf-8', 'replace')
+ will_msg = rd_str().decode('utf-8', 'replace')
+ user = passwd = None
+ if cflags & 0x80:
+ user = rd_str().decode('utf-8', 'replace')
+ if cflags & 0x40:
+ passwd = rd_str().decode('utf-8', 'replace')
+ return {
+ 'proto': name, 'level': level, 'clientId': cid, 'keepalive': keepalive,
+ 'clean': bool(cflags & 0x02), 'willTopic': will_topic,
+ 'willMsg': will_msg, 'willRetain': bool(cflags & 0x20),
+ 'willQos': (cflags >> 3) & 0x03, 'user': user, 'pass': passwd,
+ }
+
+
+def parse_publish(flags, body):
+ qos = (flags >> 1) & 0x03
+ tlen = struct.unpack('>H', body[0:2])[0]
+ topic = body[2:2 + tlen].decode('utf-8', 'replace')
+ i = 2 + tlen
+ pid = None
+ if qos > 0:
+ pid = struct.unpack('>H', body[i:i + 2])[0]
+ i += 2
+ return topic, qos, pid, body[i:]
+
+
+def serve(conn, args, stats):
+ mode = args.mode
+ with LOCK:
+ stats.conns += 1
+ try:
+ if mode == 'rst':
+ conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
+ struct.pack('ii', 1, 0))
+ conn.close()
+ return
+
+ conn.settimeout(args.read_timeout)
+ pkt = read_packet(conn)
+ if pkt is None:
+ return
+ ptype, flags, body = pkt
+ if ptype != CONNECT:
+ with LOCK:
+ stats.errors.append(f'first packet type {ptype}, expected CONNECT')
+ return
+ info = parse_connect(body)
+ stats.note_connect(info['clientId'])
+ with LOCK:
+ if info.get('willTopic') and info['willTopic'] not in stats.will_topics:
+ stats.will_topics.append(info['willTopic'])
+ if len(stats.connect_frames) < 5:
+ stats.connect_frames.append(dict(info))
+ with LOCK:
+ stats.errors.append(
+ 'CONNECT ' + json.dumps({k: info[k] for k in
+ ('proto', 'level', 'clientId', 'keepalive',
+ 'user', 'willTopic')}))
+
+ if mode == 'no_connack':
+ time.sleep(args.delay)
+ return
+ if mode == 'garbage':
+ conn.sendall(os.urandom(64))
+ return
+ if mode == 'half_connack':
+ conn.sendall(b'\x20\x02')
+ time.sleep(args.delay)
+ return
+ if mode == 'slow_connack':
+ time.sleep(args.delay)
+
+ rc = {'connack_refuse': 5, 'connack_badproto': 1,
+ 'connack_badid': 2, 'connack_unavail': 3}.get(mode, 0)
+ conn.sendall(bytes([CONNACK << 4, 2, 0, rc]))
+ with LOCK:
+ stats.connacks += 1
+ if rc != 0:
+ return
+ if mode == 'drop_after_connack':
+ conn.close()
+ return
+
+ # Steady state.
+ deadline_ka = info['keepalive'] * 2 if info['keepalive'] else 0
+ while True:
+ conn.settimeout(args.read_timeout)
+ pkt = read_packet(conn)
+ if pkt is None:
+ return
+ ptype, flags, body = pkt
+ if ptype == PUBLISH:
+ topic, qos, pid, payload = parse_publish(flags, body)
+ if mode == 'drop_on_publish':
+ with LOCK:
+ stats.errors.append(f'dropping on PUBLISH to {topic}')
+ conn.close()
+ return
+ if mode == 'rst_on_publish':
+ with LOCK:
+ stats.errors.append(f'RST on PUBLISH to {topic}')
+ conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
+ struct.pack('ii', 1, 0))
+ conn.close()
+ return
+ retain = bool(flags & 0x01)
+ dup = bool(flags & 0x08)
+ stats.note_publish(topic, payload, qos=qos, retain=retain, dup=dup)
+ if topic.endswith('/status'):
+ with LOCK:
+ stats.status_msgs.append(
+ {'topic': topic, 'retain': retain,
+ 'payload': payload.decode('utf-8', 'replace')[:200]})
+ if qos == 1 and pid is not None:
+ conn.sendall(bytes([PUBACK << 4, 2]) + struct.pack('>H', pid))
+ elif ptype == PINGREQ:
+ with LOCK:
+ stats.pings += 1
+ if mode != 'no_pingresp':
+ conn.sendall(bytes([PINGRESP << 4, 0]))
+ elif ptype == SUBSCRIBE:
+ pid = struct.unpack('>H', body[0:2])[0]
+ # One granted QoS byte per requested filter.
+ i, granted = 2, b''
+ while i < len(body):
+ ln = struct.unpack('>H', body[i:i + 2])[0]
+ i += 2 + ln
+ granted += bytes([body[i]])
+ i += 1
+ conn.sendall(bytes([SUBACK << 4]) + enc_len(2 + len(granted)) +
+ struct.pack('>H', pid) + granted)
+ elif ptype == DISCONNECT:
+ return
+ except Exception as e:
+ with LOCK:
+ stats.errors.append(f'{type(e).__name__}: {e}')
+ finally:
+ try:
+ conn.close()
+ except Exception:
+ pass
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('--port', type=int, required=True)
+ ap.add_argument('--bind', default='0.0.0.0')
+ ap.add_argument('--tls', action='store_true')
+ ap.add_argument('--cert', default='certs/cert.pem')
+ ap.add_argument('--key', default='certs/key.pem')
+ ap.add_argument('--mode', default='ok')
+ ap.add_argument('--delay', type=float, default=30.0)
+ ap.add_argument('--read-timeout', type=float, default=120.0)
+ ap.add_argument('--stats', default='mqtt_stats.json')
+ ap.add_argument('--records', default='')
+ ap.add_argument('--tls-fault', default='',
+ choices=['', 'blackhole', 'garbage', 'rst', 'slow'])
+ args = ap.parse_args()
+
+ stats = Stats(args.stats, args.records)
+
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ srv.bind((args.bind, args.port))
+ srv.listen(16)
+
+ ctx = None
+ if args.tls:
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ ctx.load_cert_chain(args.cert, args.key)
+ ctx.minimum_version = ssl.TLSVersion.TLSv1_2
+ ctx.maximum_version = ssl.TLSVersion.TLSv1_2
+
+ def dumper():
+ while True:
+ time.sleep(2)
+ try:
+ stats.dump()
+ except Exception:
+ pass
+
+ threading.Thread(target=dumper, daemon=True).start()
+ print(f'[{"mqtts" if args.tls else "mqtt"}] listening on {args.bind}:{args.port} '
+ f'mode={args.mode} tls_fault={args.tls_fault or "-"}', flush=True)
+
+ while True:
+ try:
+ conn, _ = srv.accept()
+ except OSError:
+ break
+ conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+
+ def wrapped(conn=conn):
+ if args.tls:
+ f = args.tls_fault
+ if f == 'rst':
+ with LOCK:
+ stats.conns += 1
+ conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
+ struct.pack('ii', 1, 0))
+ conn.close()
+ return
+ if f == 'blackhole':
+ with LOCK:
+ stats.conns += 1
+ time.sleep(args.delay)
+ conn.close()
+ return
+ if f == 'garbage':
+ with LOCK:
+ stats.conns += 1
+ try:
+ conn.recv(4096)
+ conn.sendall(os.urandom(2048))
+ except Exception:
+ pass
+ conn.close()
+ return
+ if f == 'slow':
+ time.sleep(args.delay)
+ try:
+ conn.settimeout(args.read_timeout)
+ tconn = ctx.wrap_socket(conn, server_side=True)
+ with LOCK:
+ stats.tls_ok += 1
+ except Exception as e:
+ with LOCK:
+ stats.tls_failures += 1
+ stats.errors.append(f'TLS: {type(e).__name__}: {e}')
+ try:
+ conn.close()
+ except Exception:
+ pass
+ return
+ serve(tconn, args, stats)
+ else:
+ serve(conn, args, stats)
+
+ threading.Thread(target=wrapped, daemon=True).start()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/telemetry_bench/soak24.py b/tools/telemetry_bench/soak24.py
new file mode 100644
index 0000000..68f0546
--- /dev/null
+++ b/tools/telemetry_bench/soak24.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+"""Soak de 24 h com a configuração real do usuário.
+
+Serve a dois propósitos: é o teste de aceitação das 11 correções desta campanha,
+e cobre a classe de problema que as rajadas de 90–150 s não tocam — deriva de
+cursor, fragmentação de heap ao longo de horas, interação com o GC do histórico,
+NTP re-sincronizando, e a detecção de RSSI implausível (D15) que ainda não foi
+vista disparar.
+
+Monitoramento é só pela serial, de propósito: `/api/status` exigiria deixar um
+usuário web admin descartável vivo por 24 h, e a CLI já entrega tudo que
+importa. Nada de servidor de teste — o alvo é o servidor real do usuário.
+
+Amostra a cada 5 min (288 amostras). Cada linha anômala vai para o log com
+prefixo ANOMALIA, para `grep` valer como triagem.
+
+Parar antes da hora: pkill -f soak24.py
+"""
+import json
+import os
+import re
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import campaign as C # noqa: E402
+from bench import Target # noqa: E402
+
+DURACAO_S = 24 * 3600
+PERIODO_S = 300
+OUT = os.path.join(C.OUT, 'soak24.ndjson')
+
+
+def amostra(t):
+ d = {'wall': time.time()}
+ m = t.metrics(wait=4.0)
+ for k in ('uptime', 'heap', 'heap_min', 'largest', 'largest_min',
+ 'tel_sent', 'tel_failed', 'tel_retries', 'tel_bytes', 'tel_lat',
+ 'reads_ok', 'reads_err', 'flash_ops', 'core1_exposed',
+ 'wifi_conns', 'mqtt_conns'):
+ d[k] = m.get(k)
+ net = t.send('show net status', wait=4.0)
+ r = re.search(r'RSSI:\s*(-?\d+)', net)
+ p = re.search(r'PBUF pool:\s*(\d+) em uso / pico (\d+) / (\d+) total, (\d+) falhas', net)
+ ip = re.search(r'IP:\s*([\d.]+)', net)
+ d['rssi'] = int(r.group(1)) if r else None
+ d['ip'] = ip.group(1) if ip else None
+ if p:
+ d['pbuf_uso'], d['pbuf_pico'], d['pbuf_total'], d['pbuf_falhas'] = \
+ (int(x) for x in p.groups())
+ return d
+
+
+def main():
+ t = Target(os.path.join(C.OUT, 'serial_soak24.log'))
+ time.sleep(1)
+ t0 = time.time()
+ base = amostra(t)
+ boots0, fatal0 = t.port_drops, len(t.fatal_lines)
+ C.log(f'SOAK 24h iniciado. base={json.dumps(base)}')
+ fh = open(OUT, 'a', buffering=1)
+ fh.write(json.dumps({'evento': 'inicio', **base}) + '\n')
+
+ prev = base
+ n = 0
+ while time.time() - t0 < DURACAO_S:
+ time.sleep(PERIODO_S)
+ n += 1
+ d = amostra(t)
+ d['t_h'] = round((time.time() - t0) / 3600, 2)
+ d['reboots_usb'] = t.port_drops - boots0
+ d['ftl'] = len(t.fatal_lines) - fatal0
+ fh.write(json.dumps(d) + '\n')
+
+ alerta = []
+ # 1. Reboot ou pânico — critério de parada imediata.
+ if d['reboots_usb'] or d['ftl']:
+ alerta.append(f"REBOOT/FTL usb={d['reboots_usb']} ftl={d['ftl']}")
+ if (prev.get('uptime') and d.get('uptime')
+ and d['uptime'] < prev['uptime']):
+ alerta.append(f"uptime regrediu {prev['uptime']}->{d['uptime']}")
+ # 2. PBUF falhando fora de rajada: com o pool em 24 e sem carga de
+ # teste, qualquer falha aqui é sinal, não ruído.
+ if d.get('pbuf_falhas'):
+ alerta.append(f"PBUF falhas={d['pbuf_falhas']}")
+ # 3. RSSI implausível — é o gatilho do D15. Se aparecer, o log serial
+ # deve trazer "Implausible RSSI twice" logo em seguida.
+ if d.get('rssi') is not None and (d['rssi'] >= 0 or d['rssi'] < -120):
+ alerta.append(f"RSSI implausivel={d['rssi']}")
+ # 4. Heap: deriva > 5% contra a linha de base é o critério de sucesso.
+ if base.get('heap') and d.get('heap'):
+ drift = abs(d['heap'] - base['heap']) / base['heap']
+ if drift > 0.05:
+ alerta.append(f"heap {base['heap']}->{d['heap']} ({drift:.1%})")
+ if d.get('ip') != base.get('ip'):
+ alerta.append(f"IP mudou {base.get('ip')}->{d.get('ip')}")
+
+ if alerta:
+ C.log(f"ANOMALIA t={d['t_h']}h :: " + ' | '.join(alerta))
+ elif n % 12 == 0: # resumo de hora em hora
+ C.log(f"ok t={d['t_h']}h up={d['uptime']}s heap={d['heap']} "
+ f"lb={d['largest']} rssi={d['rssi']} "
+ f"pbuf={d.get('pbuf_uso')}/{d.get('pbuf_total')}({d.get('pbuf_falhas')}) "
+ f"sent={d['tel_sent']} fail={d['tel_failed']}")
+ prev = d
+
+ fim = amostra(t)
+ fim['evento'] = 'fim'
+ fim['reboots_usb'] = t.port_drops - boots0
+ fim['ftl'] = len(t.fatal_lines) - fatal0
+ fh.write(json.dumps(fim) + '\n')
+ C.log(f'SOAK 24h concluido. {json.dumps(fim)}')
+ fh.close()
+ t.close()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/telemetry_bench/summarize.py b/tools/telemetry_bench/summarize.py
new file mode 100644
index 0000000..a6c990b
--- /dev/null
+++ b/tools/telemetry_bench/summarize.py
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+"""Turn the campaign's JSON output into the tables that go in the report."""
+import datetime as dt
+import json
+import os
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+OUT = os.path.join(HERE, 'results')
+
+
+def load(name):
+ p = os.path.join(OUT, name)
+ if not os.path.exists(p):
+ return None
+ with open(p) as fh:
+ return json.load(fh)
+
+
+def ts(e):
+ if not e:
+ return '—'
+ return dt.datetime.fromtimestamp(e).strftime('%Y-%m-%d %H:%M')
+
+
+def md_table(rows, cols, headers=None):
+ headers = headers or cols
+ out = ['| ' + ' | '.join(headers) + ' |',
+ '|' + '|'.join(['---'] * len(cols)) + '|']
+ for r in rows:
+ out.append('| ' + ' | '.join(str(r.get(c, '—')) for c in cols) + ' |')
+ return '\n'.join(out)
+
+
+def perf_table():
+ d = load('phase_perf_http.json')
+ if not d:
+ return '_(sem dados)_'
+ rows = []
+ for r in d['rows']:
+ rows.append({
+ 'transporte': 'HTTPS' if r.get('tls') else 'HTTP',
+ 'lote': r.get('batch'),
+ 'envios': r.get('srv_requests'),
+ 'registros': r.get('srv_records'),
+ 'reg/s': r.get('records_per_s'),
+ 'B/s': r.get('bytes_per_s'),
+ 'lat med (ms)': r.get('dev_lat_med'),
+ 'lat max (ms)': r.get('dev_lat_max'),
+ 'falhas': r.get('dev_failed'),
+ 'heap min': r.get('heap_min'),
+ 'maior bloco min': r.get('largest_min'),
+ 'reboots': (r.get('usb_drops') or 0) + (r.get('uptime_resets') or 0),
+ })
+ return md_table(rows, list(rows[0].keys()))
+
+
+def mqtt_perf_table():
+ out = []
+ for f, lbl in (('phase_mqtt_plain.json', 'MQTT'), ('phase_mqtt_tls.json', 'MQTTS')):
+ d = load(f)
+ if not d:
+ continue
+ rows = []
+ for r in d.get('perf', []):
+ rows.append({
+ 'transporte': lbl,
+ 'lote': r.get('batch'),
+ 'publishes': r.get('srv_publishes'),
+ 'registros': r.get('srv_records'),
+ 'reg/s': r.get('records_per_s'),
+ 'lat med (ms)': r.get('dev_lat_med'),
+ 'falhas': r.get('dev_failed'),
+ 'conexoes': r.get('srv_connects'),
+ 'qos visto': json.dumps(r.get('srv_qos_seen') or {}),
+ 'heap min': r.get('heap_min'),
+ 'reboots': (r.get('usb_drops') or 0) + (r.get('uptime_resets') or 0),
+ })
+ if rows:
+ out.append(md_table(rows, list(rows[0].keys())))
+ return '\n\n'.join(out) if out else '_(sem dados)_'
+
+
+def survive_table():
+ rows = []
+ for f in ('phase_survive_all.json', 'phase_survive_http.json',
+ 'phase_survive_tls.json'):
+ d = load(f)
+ if not d:
+ continue
+ for r in d['rows']:
+ rows.append({
+ 'falha': r.get('fault'),
+ 'transporte': 'HTTPS' if r.get('tls') else 'HTTP',
+ 'janela (s)': r.get('seconds'),
+ 'reboots': (r.get('usb_drops') or 0) + (r.get('uptime_resets') or 0),
+ 'FTL': r.get('fatal_lines'),
+ 'falhas tel': r.get('dev_failed_delta'),
+ 'envios tel': r.get('dev_sent_delta'),
+ 'heap min': r.get('heap_min'),
+ 'web mudo (polls)': r.get('web_worst_streak_polls'),
+ 'reg perdidos': r.get('records_skipped'),
+ 'recuperou': 'sim' if (r.get('post') or {}).get('records') else 'NAO',
+ 'veredito': r.get('verdict'),
+ })
+ for f, lbl in (('phase_mqtt_plain.json', 'MQTT'), ('phase_mqtt_tls.json', 'MQTTS')):
+ d = load(f)
+ if not d:
+ continue
+ for r in d.get('faults', []):
+ rows.append({
+ 'falha': r.get('fault'),
+ 'transporte': lbl,
+ 'janela (s)': r.get('seconds'),
+ 'reboots': (r.get('usb_drops') or 0) + (r.get('uptime_resets') or 0),
+ 'FTL': r.get('fatal_lines'),
+ 'falhas tel': r.get('dev_failed'),
+ 'envios tel': r.get('dev_sent'),
+ 'heap min': r.get('heap_min'),
+ 'web mudo (polls)': r.get('unreachable_polls'),
+ 'reg perdidos': r.get('records_skipped'),
+ 'recuperou': 'sim' if (r.get('post') or {}).get('records') else 'NAO',
+ 'veredito': r.get('verdict'),
+ })
+ if not rows:
+ return '_(sem dados)_'
+ return md_table(rows, list(rows[0].keys()))
+
+
+def drain_block():
+ d = load('phase_drain.json')
+ if not d:
+ return '_(sem dados)_'
+ t, g, c = d['telemetry'], d['ground_truth'], d['coverage']
+ return f"""**Via telemetria** (lote {t['batch']}, intervalo {t['interval']} ms, HTTP puro)
+
+| métrica | valor |
+|---|---|
+| duração | {t['wall_s']} s |
+| envios HTTP | {t['requests']} |
+| registros aceitos | {t['records']} |
+| epochs únicos | {t['unique_epochs']} |
+| duplicados | {t['duplicates']} |
+| bytes recebidos | {t['bytes_in']} |
+| primeiro registro | {ts(t['epoch_min'])} |
+| último registro | {ts(t['epoch_max'])} |
+| reboots | {t['usb_drops']} |
+
+**Verdade de solo** (todos os `.h5` baixados e decodificados pelo codec de referência)
+
+| métrica | valor |
+|---|---|
+| arquivos listados | {g['files_listed']} |
+| arquivos baixados | {g['files_downloaded']} |
+| falhas de download | {len(g['download_failures'])} |
+| bytes | {g['total_bytes']} |
+| registros no disco | {g['total_records']} |
+| epochs únicos | {g['unique_epochs']} |
+| primeiro | {ts(g['epoch_min'])} |
+| último | {ts(g['epoch_max'])} |
+| erros de decodificação | {len(g['decode_errors'])} |
+
+**Cobertura**: {c['via_telemetry']} de {c['on_disk']} epochs = **{c['pct']}%**.
+Faltaram {c['missing_count']} registros, de {ts(c['missing_first'])} a {ts(c['missing_last'])}.
+"""
+
+
+def fidelity_block():
+ d = load('phase_mqtt_plain.json')
+ if not d or 'fidelity' not in d:
+ return '_(sem dados)_'
+ f = d['fidelity']
+ rows = []
+ for k, v in f['checks'].items():
+ rows.append({'verificação': k, 'resultado': 'OK' if v else 'FALHOU'})
+ return (md_table(rows, ['verificação', 'resultado']) +
+ '\n\nEnviado: `' + json.dumps(f['wanted']) + '`\n\n' +
+ 'Recebido no broker: `' + json.dumps(f['got']) + '`')
+
+
+if __name__ == '__main__':
+ print('## Desempenho HTTP/HTTPS\n')
+ print(perf_table())
+ print('\n## Desempenho MQTT/MQTTS\n')
+ print(mqtt_perf_table())
+ print('\n## Fidelidade da config MQTT\n')
+ print(fidelity_block())
+ print('\n## Sobrevivência\n')
+ print(survive_table())
+ print('\n## Descarga do histórico\n')
+ print(drain_block())