Skip to the content.

SIMUT Air — Plano de correção e otimização

Status: Fases 0, 1 e 3 executadas e medidas no ferro; Fases 2, 4 e 5 abertas. Registro de execução na seção 6. Base: branch feature/simut-air, commit 461a806 (06/09/2026), sobre main 6d2142c (v2.3.9-beta). Origem: revisão de código de 06/09/2026 (21 achados, F01–F21), sem validação no ferro. Esboço de projeto: SIMUT_AIR_ESBOCO.md. Plano de energia (leitura frequente, rádio raro): SIMUT_AIR_PLANO_ENERGIA.md. Suíte que prova cada item: tools/air_test_suite.py (CLI + web + PicoHand) e tools/check_air_consistency.py (estático). Idioma: pt-BR (espelha os demais docs/analysis/).


0. Sumário executivo

A revisão de código mais a bancada de 06/09 acumularam 25 achados, e nenhum bloqueante segue aberto. O último a cair foi o F25: um watchdog durante a janela acordada devolvia o aparelho a M0 e na bancada ele nunca mais hibernava — num produto a bateria, ficar acordado para sempre. A intenção do operador passou a viver no air.bin, a volta ao ciclo tem graça de 10 s, e um contador de boots sujos preserva a alcançabilidade que o desenho original protegia (§6.7).

O defeito que valia a saga era o tempo, e a causa era uma linha. Com o intervalo configurado em 2 minutos o aparelho acordava a cada 16 a 48 minutos, de forma variável. Causa: o ROSC era desligado antes do WFI e não religado, e o reset do wake não passa pelo domínio de reset dele. Corrigido e medido: 120,7 s ± 0,1 em quatro ciclos seguidos. A corrupção do histórico (F23) era consequência disso e sumiu junto — zero gaps negativos nos registros posteriores ao fix.

Estado por fase:

  1. Fase 0 — instrumentar e medir no ferro. ✅ §6.1. Procedência do binário provada (461a806), F22 e F23 confirmados, três instrumentos meus desmascarados.
  2. Fase 1 — bloqueantes. ✅ §6.2. F01/F22 (ROSC), F02 (teto do FLUSH), F03+F07 (energia dos sensores). +200 B no Air, release e alpha intactos.
  3. Fase 3 — consistência. ✅ (menos o CI). check_air_consistency.py C1–C8 limpo e −712 B no release / −272 B no alpha — a limpeza se pagou em flash.
  4. Fase 4 — intervalo real. ✅ §6.4 e §3. O alarme passou a ancorar no wake e a conta passou a arredondar, e o alarme passou a ser armado relativo ao valor que o RTC em vez do zero que se escreve nele — o load já vale um tique, e era isso que fazia o aparelho acordar 1 s cedo em todo ciclo. Ciclo medido pela sonda: 120,23 s para 120 s pedidos. A série do dia, para a mesma configuração: 16–48 min → 147 s → 118,9 s → 119,3 s → 120,23 s.
  5. F24 — cursor de telemetria. ✅ §6.5. Duas causas: a coalescência de 5 s derrotava a escrita pré-sono (corrigida) e o coletor da bancada aceita a conexão TCP e nunca responde (lado do servidor, fora do firmware).
  6. SSID indisponível. ✅ §6.6. Teto de tentativas por wake; medido, a rede ausente não alarga a janela acordada.
  7. Resto da Fase 2 (ciclo M1), da Fase 4 (energia) e a Fase 5 (release) seguem abertas, mais o CI cobrindo pico_w_air/native_air.

Ordem original de ataque, para referência:

  1. Fase 0 — instrumentar e medir no ferro.
  2. Fase 1 — bloqueantes (F22, F23, F02, F03).
  3. Fase 2 — ciclo M1 correto (F04–F14, F21).
  4. Fase 3 — consistência e portões (F15–F20 + CI cobrindo o Air).
  5. Fase 4 — otimização de tempo acordado e energia, só depois de medir.
  6. Fase 5 — release (CHANGELOG, manuais, bateria de ferro, merge).

Critério de pronto: suíte air_test_suite.py sem FAIL nem XFAIL (todo XFAIL vira PASS ou é removido com justificativa), check_air_consistency.py limpo, pico_w_release/pico_w_alpha byte-idênticos ao estado atual exceto pelas remoções de debug, e CI verde com pico_w_air e native_air.


1. Achados (F01–F21)

Severidade: B = bloqueante, F = bug funcional, I = inconsistência. “Teste” refere-se aos casos de tools/air_test_suite.py (T-xx) e tools/check_air_consistency.py (C-x).

Os quatro últimos achados não nasceram da revisão de código: vieram da bancada, e por isso moram no registro de execução em vez desta tabela. F22 (período de sono errado) e F23 (histórico não monotônico) estão em §6.1, com o desfecho em §6.2 e §6.3; F24 (cursor de telemetria que não avança) está em §6.5; F25 (um reset durante o wake tira o aparelho do ciclo, e ele pode não voltar) está em §6.7 — o único bloqueante ainda aberto.

ID Sev Onde Sintoma Correção Teste
F01 CORRIGIDO 06/09, MEDIDO 07/09 (T05 passou: sono 61,8/34,8/34,8 s, erro de período 1,8 s; marca de xfail removida) src/air/pico_sleep.c ROSC desligado antes do WFI e não religado no wake. A hipótese original (travar de vez no runtime_init_clocks) estava errada: o aparelho volta, mas demora um tempo longo e variável. A correlação temporal é a prova circunstancial forte — o ciclo estava certo (147–151 s) até a build sem ROSC-off e degradou para 16–48 min a partir da janela em que a build nova foi gravada (§6.1 item 7b) CORRIGIDO em 06/09: religar o ROSC logo após o wfi, antes do SYSRESETREQ, esperando ROSC_STATUS_STABLE — o mesmo que o sleep_power_up do pico-extras faz. Custo: +32 B de flash T05, --watch
F02 CORRIGIDO 06/09 src/AppManager_Air.cpp FLUSH sem teto: telemetria desligada (telInterval==0, default de fábrica) ou RSSI abaixo do limiar com link de pé deixam update() retornar sem backoff e a fila nunca zera Saída imediata quando telInterval == 0; netLost passa a usar isNetworkHealthy() (cobre RSSI); teto de parede por flushTimeoutMs — nenhuma trava futura no uploader segura o aparelho acordado T06b, T05
F03 CORRIGIDO 06/09 src/AppManager_Boot.cpp, src/AppManager_Air.cpp GP16 só era ligado no WARMUP: em M0 ninguém ligava o pino; no boot M1 o setup() inteiro rodava com os sensores desligados airSensorPower() deixou de ser estática e é chamada no início do setup(), antes de qualquer sondagem; o pino de air.bin substitui o default logo após a carga. Efeito colateral bom: o boot inteiro passa a ser o warm-up dos sensores lentos T02, T05, T09
F04 NÃO REPRODUZ — MEDIDO 2× em 09/09 src/NetworkManager.cpp:108, src/AppManager_Air.cpp:201 Relógio provisório = último registro + 60 s + uptime; cada wake offline avança o histórico ~80 s em vez do intervalo real; a correção pós-NTP (AppManager_Loop.cpp:282) nunca roda em M1 Usar o RTC como relógio de parede: setar o RTC com o epoch atual antes de dormir, ler após o wfi, guardar em scratch[1], e no boot M1 semear setProvisionalTime(scratch[1]); escrever o registro depois do CONNECT quando houver link (ver F08). ⚠️ Nada disso foi implementado — e mesmo assim o defeito não aparece. O que existia era um INSTRUMENTO cego: o T08 pegava os ÚLTIMOS gaps do arquivo do dia, que (a) só tem blocos selados e (b) na hora da leitura já são registros ONLINE, escritos depois do SSID voltar, numa cadência que ninguém estava perturbando — passavam fizesse o que fizesse o wake offline. Corrigido em 09/09: marca antes de cair, lê selado + aberto, pega os PRIMEIROS registros depois da marca e julga todo gap entre eles. Medido duas vezes com a seleção certa: [118, 119, 119] e [119, 119, 119] s contra 120 s configurados — o F04 previa ~80 s. ⚠️ Escopo da medição: 4 wakes offline (~8 min sem rede) a h_int=2; um afastamento offline de horas não foi exercitado. T08
F05 CORRIGIDO E MEDIDO 07/09 src/TelemetryManager.cpp (_drainMode), src/AppManager_Air.cpp (CONNECT→FLUSH, saída do FLUSH), src/AppManager_Commands.cpp (air stop) begin() carimba _lastCheckTime e cada lote espera telInterval; medido com a sonda: t_int = 60 s → wake de 57 s com o rádio ligado e 0 registros; t_int = 1 s → 28 lotes em 29 s de FLUSH “Modo dreno”: setDrainMode(true) ao entrar no FLUSH, false ao sair e no air stop; em modo dreno update() ignora o intervalo (backoff após falha continua valendo). Validado: mesmo wake, mesmo t_int = 60 s → 18.800 registros, dormiu em seguida. Detalhes e tabelas em SIMUT_TELEMETRIA_PLANO_CADENCIA.md §2.4/§2.8 T06
F06 sem objeto após o F03 src/AppManager_Boot.cpp WARMUP dura zero no boot M1: o timer é marcado no início do boot e já expirou quando o airLoop roda Com o F03 os sensores passam a ser energizados no início do setup(), e o boot leva ~20 s — ou seja, o warm-up real hoje é o boot inteiro, muito acima do 1 s que o DHT22 pede. O caminho por comando (air hibernate) sempre teve os 400 ms corretos (medido: WARMUP→SAMPLE em 395 ms). Fica só a nota T05, T09
F07 CORRIGIDO 06/09 src/AppManager_Air.cpp gpio_init a cada iteração do WARMUP: o SDK põe o pino em entrada e nível 0 antes de subir de novo (glitch na alimentação e na sonda) gpio_init uma vez por pino, com máscara estática; depois só gpio_put T09
F08 F src/AppManager_Air.cpp:203, src/NetworkManager.cpp:223 CONNECT é inalcançável: DECIDE só vai a CONNECT quando já NET_READY, que exige NTP sem fallback; um pacote NTP perdido (retry 20 s) ou rede sem internet = dorme sem enviar; connectTimeoutMs nunca atua DECIDE→CONNECT quando WiFi.status()==WL_CONNECTED; CONNECT espera NET_READY até connectTimeoutMs; NTP em Air com 1º retry curto (5 s) T05, T06
F09 CORRIGIDO E MEDIDO 07/09 src/AppManager_Commands.cpp:842 air idle aceita até 86400 mas o campo é uint16: 86400 vira 20864 s Faixa 10..65535 em airIdleSecValid() (AirConfig.h) + teste nativo. O achado era pior do que esta linha dizia: 65536 vira 0, e ocioso zero manda o aparelho dormir na passada seguinte do laço — só se volta pegando uma janela de wake pelo console. Aconteceu na bancada em 07/09 e envenenou a suíte inteira (T07 e T11 acusaram “target absent” contra um aparelho que estava só dormindo). airIdleSecValid() + test_idle_sec_bounds T04, native
F10 CORRIGIDO 09/09, teste nativoairSleepSecBounded( ) limita o sono a 86.399 s no único caminho que vira alarme (AppManager_Air.cpp), com WARN APP_AIR_SLEEP_CLAMPED (413); h_int=1440 segue aceito e dorme 1 s a menos. test_sleep_sec_bounded prova o alias ((base+86400) % 86400 == base) e a sua ausência depois do limite, para todo segundo-base do dia. ⚠️ Não medido no ferro: um wake de 24 h não cabe numa sessão de bancada src/AppManager_Air.cpp:315, src/WebManager_Commit.cpp:833 Alarme do RTC com hora módulo 24; h_int aceita 1440 min → alarme em 00:00:00 do mesmo dia, dispara na hora ou nunca Com F04 o alarme vira epoch+wakeSec→gmtime, sem limite; alternativa mínima: wakeSec = min(wakeSec, 23*3600) native (cálculo do alarme)
F11 F src/StorageManager.cpp:1216, src/AppManager_Core.cpp:136 O M1 pula StorageManager::update() (limpeza de orçamento do FS) e nunca chama flushPendingIfAny(): em uso só-M1 a partição enche e logs diferidos se perdem Antes de dormir: flushPendingIfAny(), syslog flushBlocking() se ligado, e executar a limpeza pendente até concluir (com watchdog_update) T05 (longo), fs% em /api/status
F12 DOCUMENTADO como limitação 09/09 (decisão do Ângelo: não corrigir — folga de flash apertada, não é risco) src/AppManager_Loop.cpp:43, src/BluetoothManager.cpp:73 air stop por Bluetooth não funciona em M1: o laço M1 só chama processInput (USB) Bombear o BluetoothManager no laço M1 do mesmo jeito que o M0 faz (ou documentar “só USB em M1”, decisão D-1 abaixo) T05 (variante BT, manual)
F13 CORRIGIDO E MEDIDO 07/09 src/AppManager_Boot.cpp:803, :816, :820 D5 não implementado: em M1 sobem web, BT, mDNS, syslog e discovery; a web nem é bombeada (conexões aceitas ficam penduradas) Gate if (!_airActive) em web/BT/mDNS/syslog/HA/metrics; pular delay(1000), preload de cache do histórico, temas e pack de idioma no boot M1 Portão _airActive, não _airRadioWake: nem o wake de telemetria sobe listener. Saíram do wake: servidor web, CLI Bluetooth, anúncio mDNS e o preload de mín/máx do painel (as duas chamadas). Medido: T10 passou (porta 80 fechada durante o wake, sono 61,8 s) e virou XPASS, marca de xfail removida. air stop num wake continua subindo a web sozinho. T10
F14 já resolvido por desenho, verificado 09/09 — o campo chargerPin tomou o lugar do wifiScanTimeoutMs morto (F17); todo arquivo v2 real guarda 4000 ali, cujo byte baixo (160) não é GPIO, e airSanitise() o converte no default (16 no Air). test_charger_pin_from_legacy_field fixa isso; a versão NÃO é bumpada de propósito, para nada mais cair em default. E o comando existe: air charger <0..22\|26..28\|off> (exercitado no V-06). A premissa da linha (“mantém 255 e não há comando”) estava velha src/air/AirConfig.h:21 Default do pino mudou para 16 sem bump de AIR_CONFIG_VERSION; air.bin antigo mantém 255 e não há comando para trocar AIR_CONFIG_VERSION 3; comando air pin <gpio|off>; air status mostra pin= T03, T04
F15 CORRIGIDO 06/09 (com resíduo) src/HelpLicenseEN.h, src/CommandParser.cpp, tools/check_cli_help.py, packs Help de emergência anunciava air … em release/alpha; o portão passou a exigir isso; es-ES sem system ssid/pass Raw string partida em três literais adjacentes com o bloco air sob #if SIMUT_AIR; o parser também foi guardado (antes release/alpha reconheciam air … e caíam no “comando desconhecido”, pagando flash por isso); check_cli_help.pySIMUT_AIR e monta EMERGENCY_EXPECTED por imagem; es-ES ganhou system ssid/pass. Resíduo: um .lng é compartilhado por todas as imagens, então os comandos air não entram no @HELP dos packs — num aparelho Air com pack pt-BR/es-ES o help não os lista. Fechar exige marcadores no pack que o getActiveHelpText() pule quando SIMUT_AIR=0 C2, C3, C4
F16 CORRIGIDO 06/09 src/AppManager_Boot.cpp Dez Serial.println("[AIR] boot: …") sem guarda no boot compartilhado: imagens TFT e alpha imprimiam marcadores de um código que nem têm Macro AIR_BOOT_MARK(s), definida como Serial.println sob #if SIMUT_AIR e como no-op fora dele. Mantém o rastro onde ele serve (o Air não tem display e reinicia a cada wake) e some do resto C1
F17 parcialmente corrigido 06/09 src/AppManager.h, src/air/AirConfig.h, src/simut_config.h airBeginWake() declarado e nunca definido; AIR_PHASE_PERSIST, wifiScanTimeoutMs, flushTimeoutMs mortos; comentários prometendo o que não existe airBeginWake() removido; flushTimeoutMs passou a valer (F02); comentários de simut_config.h e platformio.ini corrigidos. Resta: AIR_PHASE_PERSIST (no-op). ✅ wifiScanTimeoutMs fechado em 07/09: virou chargerPin + chargerRsv NO LUGAR, sem bump de versão — mesmo tamanho, mesmo CRC, nenhum air.bin perdido. Nada nunca escreveu o campo, então todo arquivo carrega os 4000 ms do default e o byte baixo cai como 160, que não é GPIO e vira o default no airSanitise( ); preso por test_charger_pin_from_legacy_field, que é o único portão porque a versão NÃO subiu C5, C7
F18 feito 06/09, verificado 09/09 platformio.ini:378 Comentário diz SIMUT_CLI_FULL=1, o env usa 0 Corrigir o comentário (feito em 06/09) C6
F19 CORRIGIDO 06/09 src/LogManager.cpp Mapa de scratch dizia que 0..2 eram reservados e intocáveis; o Air usa o 0 scratch[0] documentado no mapa como o marcador de hibernação do Air, com o porquê de o slot estar livre e a semântica medida (sobrevive ao SYSRESETREQ do wake, zera no reset físico) C8
F20 feito 06/09, verificado 09/09 (README ×3, CLI-Manual, C3 limpo) src/CommandParser.cpp, README system ssid/pass entraram no console de emergência de todas as imagens sem doc; README fala em 10 comandos Manter (útil na alpha headless) e documentar: README ×3, CLI-Manual (feito em 06/09) C3
F21 CORRIGIDO 06/09, MEDIDO 07/09 (T07 passou: M0 mantido por 100 s com idle=60; marca de xfail removida) src/AppManager_Air.cpp:95 airMarkActivity() só é chamado por comando serial/BT; requests web não resetam o timer de inatividade (operador na web é hibernado aos 5 min); não há /api/air CORRIGIDO em 06/09: WebManager::setActivityCallback chamado em getAuthPerms( ) (toda requisição autenticada) e em ensureLoginStateSlot( ) (o caminho pré-login, /api/login_init e /api/login), mais os dois funis de envio. ⚠️ Hookar só os funis NÃO basta e a bancada provou: 209 respostas saem por _server->send( ) direto do framework, inclusive o /api/login_init — com só o funil, um poll a cada 30 s não segurou nada e o aparelho hibernou pontualmente aos 300 s. Falta ainda o endpoint /api/air (D2/D7). T07
F22 B — o achado crítico src/AppManager_Air.cpp:315, src/air/pico_sleep.c:101 MEDIDO no ferro em 06/09 por quatro caminhos independentes (§6.1): com hist=120 s o ciclo real é de 16 a 30 min. O arquivo de histórico do dia é a prova direta: gaps entre ciclos de 945, 978, 1039, 1640 e 1817 s, e 168 registros no dia inteiro onde caberiam 720. Teto de backoff é 300 s, então nenhuma configuração explica. A não-uniformidade argumenta contra erro fixo de divisor e a favor de o alarme não disparar como programado Investigar antes de corrigir: (a) ler o RTC logo após o wfi e imprimir quantos segundos ele contou — separa “alarme atrasado” de “acordou por outro motivo”; (b) conferir clk_rtc de fato em 46875 Hz (clock_get_hz) e o clkdiv_m1 pós-rtc_init; (c) casar o alarme só por hora/min/seg (campos de data em −1) para não depender da data fictícia 2026-01-01. F04 reaproveita o mesmo conserto T05, --watch, histórico
F24 B src/StorageManager.cpp (flushCursorIfDirty) × ciclo M1 Relatado pelo Ângelo e confirmado no ferro em 06/09: o cursor de telemetria não avança e o mesmo pacote é reenviado a cada wake. Causa: flushCursorIfDirty( ) adia a escrita por CURSOR_COALESCE_MS (5 s) para poupar a flash — mas no ciclo M1 o envio marca o cursor sujo e a fase FLUSH sai ~150 ms depois (fila vazia), então a janela nunca decorre e a escrita nunca acontece. O sono perde a SRAM e o boot seguinte relê o arquivo antigo. O comentário no ponto de chamada já dizia “o cursor precisa estar na flash porque o dormant perde a SRAM”; a intenção estava certa e o portão silenciosamente a anulava. Medido antes do fix: pending=8 e subindo, com o coletor no ar CORRIGIDO: flushCursorIfDirty(bool force) — os dois portões (coalescência e toque) só valem quando existe um “depois”. Os três caminhos do Air para o sono passam true, incluindo o airEnterDormant( ), que é o ponto único por onde todo sono passa /api/status pending ao longo de N ciclos
F23 CORRIGIDO E MEDIDO 07/09 src/StorageManager.cpp (encoder V5) × ciclo M1 MEDIDO em 06/09, com a ressalva da §6.1: lido com o nominal correto, o histórico do dia tem 12 gaps negativos em 174 — o arquivo não é monotônico. O interior de cada bloco é reconstruído pelo passo nominal, então um bloco cujas amostras reais foram mais espaçadas avança além do t0 do bloco seguinte. ⚠️ As “rajadas de 60 s” da primeira versão deste plano eram artefato de decodificação, não do firmware Selar o bloco antes de dormir (hoje flushWipV5 só faz snapshot), para que cada wake abra bloco novo com t0 próprio e o interior nunca precise representar um intervalo de sono. Reavaliar depois que o F22 fechar: com o período correto, a distorção do interior encolhe sozinha. ⚠️ 07/09 — a correção proposta JÁ ACONTECE, por acidente, e ninguém tinha custeado o preço. O boot não recarrega o .wip no encoder: ele o anexa ao arquivo do dia e o apaga (STO_H5_WIP/wip_adopted). Como todo wake é um boot, cada leitura já vira um bloco próprio com t0 próprio. Medido no arquivo real do dia: 448 registros em 316 blocos (1,42 reg/bloco; 253 blocos com UM registro), 16,0 B por registro contra os 5,38 B do projeto do V5 — ~3× o espaço e retenção de ~35 dias em vez de ~130. Ou seja: o F23 não é ‘selar antes de dormir’, que já é o comportamento; é decidir se vale pagar 22 B de cabeçalho por 4 B de dado, ou fazer o boot RETOMAR o bloco aberto. Resolvido pela retomada (StorageManager::h5ResumeOpenBlock): o boot reinjeta o snapshot no encoder em vez de selá-lo, e só sela quando o bloco está cheio, quando o dia vira ou quando uma correção de relógio chega. A reinjeção é sem perda porque o formato codifica a época real de cada registro (delta-de-delta com escape de 32 bits) — o nominal é só o preditor, ao contrário do que a versão anterior desta linha afirmava. ⚠️ Um bloco retomado guarda carimbos da sessão ANTERIOR, e o deslocamento do NTP não pode tocá-los: shiftHistoryTimeV5 sela o bloco antes de corrigir e o marca em _h5AdoptedT0, a mesma regra que a varredura do arquivo já aplicava. Deslocar só os registros novos foi recusado — um deslocamento parcial negativo reordenaria o bloco, que é exatamente o defeito desta linha. Medido no ferro: wip_resumed crescendo de 2 a 11 em dez wakes; e uma janela de 25 min sem tocar no aparelho produziu UM bloco de 27 registros (t0 17:58) por 52 B, contra os blocos de 1 registro logo antes na mesma sessão (17:42, 17:43, 17:44, 17:45) — ou seja 1 → 27 reg/bloco e 16,0 → 1,9 B/registro com um sensor de um canal. ⚠️ Armadilha da medição: comparar o arquivo do dia no início e no fim da janela deu +0 B, +0 registros, +0 blocos — o bloco ainda estava ABERTO no .wip, que é justamente o ponto do fix. Zero aqui é indistinguível de “parou de gravar”: o controle é olhar os blocos SELADOS e o contador de pendentes, não o delta do arquivo. Testes nativos de reinjeção com controle positivo (2 dos 4 discriminam; os “no ritmo” não podem, porque um bloco no passo nominal é indistinguível) T11
F28 🔴 ABERTO — REPRODUZIDO 10/09 (soak de 12 h) src/air/pico_sleep.c:131, src/AppManager_Air.cpp:503 e :667 Um sleep ordinário nunca acorda. 119 ciclos perfeitos (27,8/29,1/61,8 s, 0 atrasados, 0 OVERRUN, 0 FATAL, telemetria entregando) e às 13:51:24 dormiu e não voltou em 27,5 h. Dois instrumentos independentes concordam ao segundo: presença crua do USB e o histórico em flash (último registro 13:51:24). O USB sumiu → passou de :667 (D+ solto). Checkpoint de 24 h em branco → refutado o alarme casar no dia seguinte (o código já desmentia: alarme armado de baseSec lido de volta, ~200 ms antes de uma janela de 26 s). Responde a RESET (web em 8 s) → spin/hang, não hardware. O código do ciclo é o da v2.4.2-beta lançada Suspeitas: (A) spin em while (!(rosc_hw->status & STABLE)) pós-wfiúnica espera sem limite depois do D+, com IRQs desligadas; (D) alarme não disparar (fraca: sleep_run_from_xosc não toca no clk_rtc e o sleep_en0 o mantém vivo); (B) SYSRESETREQ não pegar (remota). 🔴 Independente da causa: o watchdog fica desarmado de :503 até o boot seguinte (main.cpp:41) — é o que torna isso morte permanente em vez de reset recuperável. Proposta: teto na espera do STABLE; watchdog_enable(~2 s) logo após o wfi; marcadores de estágio em watchdog_hw->scratch[2] logados no boot seguinte. ⚠️ Falta o discriminador: corrente in situ (~20–30 mA girando × ~1–2 mA dormindo) — perdida ao recuperar a bancada; se reproduzir, medir antes de resetar tools/air_soak.py; evidências em evidence/2026-09-10-soak/

2. Fases

Fase 0 — bancada segura (antes de tocar em código)

  1. Estabelecer a procedência do binário gravado. A string de versão não discrimina builds (regra antiga do projeto). Discriminador barato para o Air: o help do console de emergência traz system ssid apenas a partir de 461a806. Sem isso, qualquer veredito sobre F01 fala de um binário desconhecido. Se houver dúvida, regravar com python3 tools/air_test_suite.py --flash .pio/build/pico_w_air/firmware.uf2.
  2. tools/fsguard.py backup do LittleFS do alvo (regra da bancada: nada de stage/format sem backup).
  3. Provar F01: gravar 461a806 como está, air hibernate com h_int=1, observar o wake pela suíte (--only T05 --cycles 1). O critério é o alvo reenumerar sozinho dentro de wakeSec + 120 s; se não reenumerar, F01 está confirmado. ⚠️ hand RESET não serve de prova: o pulso é no pino RUN, reset global que restaura o ROSC e os clocks de fábrica, então recupera o alvo mesmo com o F01 real — serve só para retomar a bancada (e dá boot frio em M0, porque o reset físico zera o scratch[0], src/LogManager.cpp:605). Registrar o resultado na seção 6 e em AGENTS.md.
  4. Rodar a suíte inteira em --baseline para congelar o estado atual (os XFAIL esperados estão marcados no código da suíte e listados na seção 4).
  5. Medir o ciclo sem tocar no aparelho: python3 tools/air_test_suite.py --watch 2700. Esse é o instrumento a confiar para o período — qualquer comando de CLI reseta o timer de inatividade e abrir a porta na hora errada perturba justamente a janela medida.

Fase 1 — bloqueantes ✅ EXECUTADA em 06/09/2026

As três correções abaixo estão implementadas e gravadas no ferro. Custo somado: +200 B de flash no pico_w_air (97,6% → 97,7%); pico_w_release e pico_w_alpha intactos. Validação em §6.2.

F01 — religar o ROSC no wake (src/air/pico_sleep.c) — ✅ FEITO e MEDIDO

__asm volatile("wfi");
/* Wake. The reset that follows does NOT go through the ROSC/CLOCKS blocks
 * (sleep_en0 and the RTC alarm state were observed to survive it), so the
 * ROSC we disabled above stays disabled unless we bring it back here. The
 * boot ROM and runtime_init_clocks() switch clk_ref onto the ROSC and spin
 * until the glitchless mux sees an edge — with the ROSC stopped that never
 * happens and the chip is dead until a power cycle. */
rosc_hw->ctrl = (rosc_hw->ctrl & ~ROSC_CTRL_ENABLE_BITS)
              | (ROSC_CTRL_ENABLE_VALUE_ENABLE << ROSC_CTRL_ENABLE_LSB);
while (!(rosc_hw->status & ROSC_STATUS_STABLE_BITS)) tight_loop_contents();

Resultado: o ciclo voltou ao que a bancada tinha de manhã. Ver §6.2.

F02 — teto do FLUSH (src/AppManager_Air.cpp, caso AIR_PHASE_FLUSH) — ✅ FEITO

⚠️ Aceite parcial: o caminho telInterval == 0 não foi exercitado no ferro (exigiria mudar a config de telemetria do Ângelo e reiniciar). O que foi observado é o FLUSH saindo em 145 ms com a fila vazia. T06b continua sendo o teste que fecha isso.

F03 — energia dos sensores desde o boot (AppManager_Boot.cpp, AppManager_Air.cpp) — ✅ FEITO

Efeito colateral bom: como a energia sobe no início do boot e o boot leva ~20 s, o warm-up real passou a ser muito maior que o 1 s que o DHT22 pede — é o que torna o F06 sem objeto.

⚠️ Aceite parcial, agora com a fiação confirmada pelo Ângelo: na bancada de referência o DS18B20 está no GP0, sem chaveamento, e o GP16 vai para a PicoHand como sonda. Ou seja, o caminho de power-gating não é exercitado aqui — o que se mediu é que o firmware aciona a linha desde o início do setup() e a solta só ao dormir. Para a sonda isso é uma melhora: o nível alto passa a cobrir a janela acordada inteira, em vez de começar só no WARMUP.

Fase 2 — ciclo M1 correto

Dois itens desta fase já saíram, fora de ordem, porque a bancada os cobrou:

F04 + F10 — RTC como relógio de parede

Aceite: T08 (3 wakes offline com h_int=2 → espaçamento 120 ± 25 s).

F05 — cadência do dreno ✅ 07/09

Aceite: T06 revisto — “registros entregues > 0 e o wake dorme” passou; “todos os registros entregues” depende do teto do FLUSH derivado do intervalo de leitura (plano de cadência §3.4), que ainda não existe: o wake parou no teto de 30 s com ~16 mil na fila.

F06 + F07 — WARMUP real e pino sem glitch: ver Fase 1; AIR_WARMUP_MS default 1000 ms; campo warmupMs no air.bin (reaproveitando wifiScanTimeoutMs).

F08 — CONNECT de verdade

SAMPLE (sensores estáveis ou timeout)
  ├─ WiFi.status()==WL_CONNECTED  → CONNECT: esperar NET_READY até connectTimeoutMs
  │                                  ├─ READY   → DECIDE (grava com hora real) → FLUSH
  │                                  └─ timeout → DECIDE (grava provisório)   → SLEEP
  └─ sem associação                → DECIDE (grava provisório)               → SLEEP

F09 — air idle 10..65535 com airIdleSecValid() em AirConfig.h e teste em test/test_air_config.

F11 — tarefas do loop antes de dormir: em AIR_PHASE_SLEEP, antes de airEnterDormant(): LogManager::flushPendingIfAny(), _syslogMgr->flushBlocking() se habilitado, e while (_storageMgr->cleanupPending()) { _storageMgr->update(); watchdog_update(); } com teto de 20 s.

F12 — Bluetooth em M1: bombear o BluetoothManager no laço M1 se a decisão D-1 for “BT é canal de emergência”; senão, documentar “em M1 só USB”.

F13 — D5 (boot M1 enxuto): em setup(), sob _airActive: não iniciar web, BT, mDNS, syslog, HA, métricas; pular delay(1000), preload de min/max do histórico, scanCustomThemes, loadTheme, pack de idioma. Medir o ganho de boot (T05 reporta awake_s).

F14 — air.bin v3 + air pin: bump de versão (arquivo antigo → defaults), comando air pin <0..28|off>, air status com pin=.

F21 — web reseta o timer + /api/air: airMarkActivity() no gate de sessão (WebManager_Auth), endpoint /api/air (GET status; POST op=hibernate|idle|pin), seção Air na página de config. Fecha D2/D7 do esboço.

Fase 3 — consistência e portões ✅ EXECUTADA em 06/09/2026 (menos o CI)

python3 tools/check_air_consistency.py fecha C1–C8 limpo, e o tools/check_cli_help.py passa nos quatro ambientes com a expectativa certa para cada imagem (18 comandos no Air, 14 no release e no alpha, 68 no test).

O que saiu das imagens de produção, medido pelo linker:

imagem antes depois delta
pico_w_release 1.017.828 B 1.017.116 B −712 B
pico_w_alpha 1.017.932 B 1.017.660 B −272 B
pico_w_air 1.020.024 B 1.020.024 B 0

São os dez marcadores de boot, o texto de ajuda dos comandos air e o bloco do parser que os reconhecia sem ter handler. Ou seja: a limpeza de consistência se pagou em flash nas duas imagens que mais sofrem com o teto.

Resíduo consciente: um .lng é compartilhado por todas as imagens, então os comandos air ficam fora do @HELP dos packs — confirmado no ferro, o help de um Air com pack pt-BR não os lista. Fechar isso exige marcadores no pack que o getActiveHelpText() pule quando SIMUT_AIR=0. ⚠️ Observado de passagem: o pack pt-BR do aparelho é mais antigo que o do repositório (não lista nem system ssid), o que é a armadilha de sempre — pack no LittleFS sobrevive à gravação de firmware.

Falta desta fase: o CI (build.yml roda só em main e não cobre pico_w_air, native_air nem check_air_consistency.py).

Itens originais da fase

Fase 4 — otimização (medir antes, medir depois)

Métricas por wake (a suíte reporta): boot_to_sample_s, sample_s, connect_s, flush_s, awake_s, sleep_s, period_error_s; corrente média em sleep e acordado (multímetro em série no VSYS ou INA219, registro manual na tabela da seção 6).

Alavancas, por ganho estimado:

  1. Boot M1 enxuto (F13): −1 s do delay(1000), −(preload do histórico, que varre blocos), −(web/BT/mDNS). Estimativa: 3–6 s por wake.
  2. Janela de estabilização: MOVING_AVG_WINDOW=10 × s_int domina o wake (10 amostras × 2 s = 20 s). Novo campo stabSamples no air.bin (default 3) e critério “N amostras válidas” em vez de bufferFull(). Estimativa: −12 s.
  3. Dreno com kick (F05) e lote 250: o tempo de envio vira função do payload, não do telInterval.
  4. NTP a cada N wakes (RTC mantém a hora, F04): dispensa esperar o NTP em todo wake; estimativa −1 a −2 s e menos dependência da internet.
  5. Alarme compensado — FEITO em 06/09 (§6.4). Agendar o próximo wake relativo ao instante do wake atual, não ao instante de dormir, para o período não derivar awake_s por ciclo. Medido pela sonda: 120,23 s para 120 s pedidos. ⚠️ Vieram junto duas lições, ambas em §3: a conta tem de arredondar (sleepMs / 1000 truncava até 1 s por ciclo, sempre no mesmo sentido) e o alarme tem de ser armado sobre o valor que o RTC , porque o load já vale um tique.
  6. ROSC off (já feito) só depois de F01 provado; ganho ~0,25 mA em sleep.
  7. LED: apagado durante SAMPLE/FLUSH, um pulso de 50 ms ao gravar e ao enviar (economia pequena; melhor sinalização).
  8. USB em bateria: se VBUS ausente, não esperar a enumeração nem imprimir marcadores (avaliar depois de medir).
  9. DORMANT: só reabrir quando SLEEP estiver estável por semanas; o esboço registra por que foi descartado.

Fase 5 — release

  1. CHANGELOG (EN + pt-BR) com a entrada Air (já existe “Unreleased” apontando para este plano).
  2. README ×3: tabela de envs (feito), console de emergência (feito), seção curta “SIMUT Air”.
  3. Manuais: docs/CLI-Manual.md (console de emergência + air, feito), docs/WIRING.md (GP16, feito), PicoHand (feito).
  4. Bateria de ferro: suíte completa verde + 3 ciclos OTA no Air (a OTA nunca foi exercitada nesta imagem; validar applier com o LittleFS do Air).
  5. Merge por PR em main, tag e release conforme release-tag da memória (conferir as três fontes antes de numerar).

3. Protocolo de medição

Sinal de tempo (sem hardware extra): o Air solta o pull-up do USB ao dormir e reenumera ao acordar; a suíte carimba absent/present de /dev/serial/by-id/…Pico_W… com resolução de ~0,5 s. awake_s = presente→ausente; sleep_s = ausente→presente.

Sinal de tempo fino (GP16) — ✅ FEITO em 06/09. O pino fica alto durante toda a janela acordada e baixo dormindo. O GP2 da PicoHand está ligado ao GP16 do alvo, e o firmware da mão ganhou o canal PROBE: pega carona no laço de 10 kHz que o Core 1 já roda para o VERIFY, guarda só as transições num anel de 64 bordas carimbadas com micros(), e responde a PROBE STATUS, PROBE START e PROBE READ (linhas EDGE <n> <H|L> <t_us> terminadas em DONE PROBE). GP4/GP5 seguem sendo a ponte serial — a sonda foi para GP2 para não desativá-la.

Primeira medição, 06/09 ~18h15, ciclo completo:

janela medida
sono (sem compensação, alarme de 120 s) 120,715 s
acordado 29,455 s
sono (compensado) 89,413 s
ciclo 118,868 s para 120 s pedidos (−0,94%)

O caso T09 da suíte automatiza isso: arma a sonda, hiberna, espera o wake, lê as bordas, recusa qualquer par de bordas a menos de 5 ms (que seria o glitch do F07) e compara o sono medido com o alarme. Rodou verde no ferro: 2 edges; asleep=120.705s.

O resíduo de ~1 s foi RESOLVIDO em 06/09, e a hipótese acima estava errada. O que fechou a questão foi parar de inferir e mandar o aparelho medir o próprio sono: depois do wfi ele lê o RTC (que é o relógio que atravessou o sono), guarda os segundos em scratch[1] e o boot seguinte imprime [AIR] woke: slept=<n>s. Com isso:

pedido dormiu de fato
120 s 120 s
91 s 91 s
92 s 92 s

Isso revelou o primeiro dos dois erros: wakeSec = sleepMs / 1000 truncava, jogando fora até um segundo inteiro por ciclo e sempre no mesmo sentido (pedir 91817 ms virava 91 s). Trocado por arredondamento ((sleepMs + 500) / 1000).

⚠️ RETRATAÇÃO — eu escrevi aqui que “o alarme sempre foi exato”, e não era. O autorrelato do aparelho também mentia, pelo mesmo motivo que ele existia para corrigir. Quem pegou foi a sonda, que é passiva: enquanto o aparelho jurava um ciclo de 119,84 s, a PicoHand media 119,31 s e 118,69 s de subida a subida. Instrumento contra instrumento, o passivo ganha.

A segunda causa, e a raiz de verdade: o load do RTC já vale um tique. A linha de depuração que o próprio firmware imprime dizia isso o tempo todo, e ninguém tinha lido:

[AIR] rtc after set:  2026-01-01 00:00:01 dotw=4

O contador é escrito com 00:00:00 e, 3 ms depois, lê 00:00:01. Um alarme armado em wakeSec estava portanto a wakeSec − 1 tiques de distância, e o aparelho acordava um segundo cedo em todo ciclo. É por isso que ele reportava slept=120s (o valor do alarme) enquanto tinha dormido

  1. Com sonda e serial na mesma janela, a conta fechou em −0,90 s por ciclo, constante:
ciclo awake= wakeSec= período pretendido período medido erro
1 29218 ms 91 s 120,218 s 119,314 s −0,904 s
2 30592 ms 89 s 119,592 s 118,686 s −0,906 s

Correção: armar o alarme relativo ao que o RTC , não ao zero que se escreveu nele — alarmSec = baseSec + wakeSec. É imune ao mecanismo: se um SDK futuro parar de emitir aquele tique, baseSec vale 0 e o alarme volta a ser onde sempre foi. O autorrelato passou a descontar a base, então slept= virou tempo real de sono em vez do valor do alarme.

Medido depois, com os dois instrumentos na mesma janela:

ciclo base= awake= wakeSec= período pretendido período medido erro
1 1 s 32120 ms 88 s 120,120 s 120,229 s +0,109 s
2 1 s 32240 ms 88 s 120,240 s 120,346 s +0,106 s

O resíduo caiu de −0,90 s para +0,106 s, e agora ele é explicável inteiro: é o trabalho entre a leitura de millis( ) e o load do RTC, mais o boot ROM até o GP16 subir. Contra os 120 s configurados o erro final é de +0,23 a +0,35 s (0,2 a 0,3%), e o que sobra dele é o arredondamento do meio segundo que a resolução de 1 s do RTC custa.

Série completa do dia para os mesmos 120 s configurados: 16–48 min (ROSC parado) → 147 s (ROSC corrigido) → 118,9 s (ancorado no wake, com truncamento) → 119,3 s (com arredondamento, medido pela sonda) → 120,23 s (alarme relativo à base do RTC).

A lição de método, que vale mais que o número: o aparelho medindo a si mesmo é um instrumento como qualquer outro, e herda os vieses do relógio que usa. Foi preciso um instrumento externo e passivo — a sonda, que não toca no alvo — para pegar o viés. E a evidência estava impressa no console desde o começo: rtc after set: 00:00:01. Ler o que o próprio firmware já diz vem antes de construir instrumento novo.

⚠️ Regravar a mão reinicia o alvo (observado: uptime zerado e boot frio logo depois da cópia do .uf2). E pôr a mão em BOOTSEL exige SELF_BOOTSEL ou o botão físico — não é automatizável.

Corrente: multímetro em série no VSYS (ou INA219 no VBUS) durante um ciclo completo; anotar sleep, boot, sample, flush. Tabela na seção 6.


4. Matriz achado × teste

Teste Cobre Estado esperado em 461a806
T01 hand_health bancada PASS
T02 target_boot_m0 F03 (com VCC no GP16) PASS sem gating físico; FAIL com gating
T03 air_status_fields F14 (pin=) PASS (campo pin= é XFAIL)
T04 air_idle_bounds F09 XFAIL (aceita 86400)
T05 hibernate_cycles F01, F06, F11, F13, F22 XFAIL — em 06/09 o erro de período foi de 6 a 13×
--watch N (passivo) F22 mede acordado/dormindo sem tocar no aparelho
T06 telemetry_drain F05, F08 entrega PASS; janela acordada XFAIL
T06b telemetry_off_sleeps F02 XFAIL (fica acordado)
T07 web_activity_resets_idle F21 XFAIL (hiberna com a web em uso)
T08 offline_timestamps F04, F10 XFAILPASS medido 2× em 09/09, marca xfail F04 removida. ⚠️ Os XFAIL/XPASS anteriores desta linha não eram sobre os registros deste teste — ver a linha do F04. [118,119,119] e [119,119,119] s contra 120 s; o gap de fronteira sobre o reboot (119 s numa rodada, 47 s na outra) é impresso e não julgado, que é justamente por quê.
T09 gp16_probe F03, F07 SKIP sem extensão da PicoHand
T10 m1_services_off F13 XFAIL (porta 80 aceita em M1)
T11 history_integrity F22, F23 XFAILPASS. ⚠️ O teste foi REESCRITO em 09/09 e os números antigos não são comparáveis: ele lia só o arquivo do dia, que tem apenas blocos SELADOS — repetido depois de 12 min de silêncio devolveu os mesmos 17 registros byte por byte, porque os 15 que a janela produziu estavam no bloco aberto (/api/history/open). Agora fabrica a própria janela, lê selado + aberto, e correlaciona cada carimbo com a presença no USB — a única forma de ver “registro gravado durante o sono”, que nenhum critério de espaçamento enxerga. Medido 09/09, duas rodadas: 6 registros / 6 wakes completos em 420 s cada, pior margem 0,0 s, 5 gaps no ritmo, 0 para trás; selados parados em 44 e abertos indo de 10 a 19 entre elas — o trabalho todo estava no bloco que o teste antigo não lia. Detalhe e armadilhas em SIMUT_AIR_PLANO_ENERGIA.md §10.3.
T17 admin_reset_persists F27 PASS desde HEAD; FAIL medido à mão no firmware anterior
C1–C8 (check_air_consistency.py) F15–F19 FAIL em C1, C2, C3, C4, C5, C8

5. Decisões pendentes (para o Ângelo)


6. Registro de execução

Preencher a cada fase (data, commit, resultado da suíte, medições).

Data Commit Fase Suíte (PASS/FAIL/XFAIL/XPASS) awake_s médio sleep mA acordado mA Notas
06/09 ~15h 461a806 0 não rodada (alvo fora do USB) plano criado; F01 pendente de prova
06/09 ~16h 461a806 (procedência provada) 0 ✅ T01 PASS · T11 XFAIL (F23) · T05 XFAIL (erro do instrumento, corrigido) F22 e F23 confirmados; ver §6.1
06/09 ~16h30 461a806 + fix do ROSC 1 ✅ --watch: sono 110,8 / 120,8 s 26,4 F22/F01 RESOLVIDOS — era 16–48 min
06/09 ~16h48 + F02, F03, F07 1 ✅ --watch 620: 4 ciclos, sono 116,8 / 120,8 / 120,8 / 120,6 s 28,9 alarme 120 s, erro +0,6 a +0,8 s; nada regrediu
06/09 ~16h55 idem histórico pós-fix: 12 registros, 0 gaps negativos F23 deixa de ser bloqueante (§6.3)
06/09 ~17h00 + F15, F16, F17p, F19 3 ✅ check_air_consistency C1–C8 limpo; check_cli_help OK nos 4 envs release −712 B, alpha −272 B
06/09 ~17h10 idem, gravada 3 ✅ --watch 500: 3 ciclos, sono 112,1 / 120,8 / 120,8 s 29,9 nada regrediu; native 121/121 + 7/7, autoteste 16/16

6.1 Bancada de 06/09/2026, ~16h — o que foi MEDIDO

Alvo: Pico W E6642815E34C1824, firmware reportando 2.3.9-beta, IP 192.168.3.24, RSSI −34 dBm, hist=120 s (h_int=2), idle=300 s, telemetria HTTP para 192.168.3.206:8080 funcionando (pending=0), heap livre 95,7 KB, FS 13% usado, 1 sensor ativo, pack pt-BR carregado.

Procedência do binário: ESTABELECIDA — é o 461a806 (a ponta da branch). O discriminador que funciona é mandar system ssid sem argumento: o binário responde ERROR: SSID invalido (1-31 chars, sem ctrl chars), que é exatamente a mensagem e a faixa introduzidas nesse commit; antes dele o comando nem era alcançável com SIMUT_CLI_FULL=0 e a resposta seria “Comando desconhecido” (confirmado no mesmo teste com air pin, que não existe). É não-destrutivo: a validação rejeita antes de gravar.

  1. O alvo estava vivo e alimentado o tempo todo, mas ausente do USB por horas. hand RESET trouxe-o de volta em 3 s. Ou seja: ausência prolongada do USB não era falta de energia nem cabo.
  2. Reset físico entrega M0 — MEDIDO. A primeira leitura de air status depois do pulso de RUN, antes de qualquer air stop, deu phase=0. Confirma o mapa de scratch (src/LogManager.cpp:605, “zeroed on power cycle / physical reset”) e derruba a nota anterior deste plano, que dizia que o hand RESET acordava em M1.
  3. F01 NÃO REPRODUZ — veredito. O binário gravado é o 461a806, que contém o ROSC-off, e o aparelho ciclou sono→wake dezenas de vezes ao longo do dia. Logo, desligar o ROSC antes do WFI é sobrevivível neste silício: a hipótese de travamento no runtime_init_clocks está descartada para este alvo. A correção continua recomendada como endurecimento (o sleep_power_up do pico-extras religa o ROSC, e depender de comportamento não documentado do reset é frágil), mas sai da fila de bloqueantes.
  4. F22 — o período está errado por 6 a 13× (o achado do dia).

    A aritmética, para ser auditável. O relógio provisório é semeado no boot com _provisionalBase = getLastRecordedTimestamp() + 60 (NetworkManager.cpp:108) e anda com o millis(); no sync o NetworkManager calcula delta = time(nullptr) − (_provisionalBase + uptime_s) e o AppManager::handleTimeSync grava esse delta em segundos no contexto do código 408 (AppManager_Core.cpp:91, saturado em int16). Como o time() real vale ultimo_registro + ciclo_real, sai ciclo_real = delta + 60 + uptime — e o uptime no sync é ~20 s pelo próprio log:

    wake ctx do código 408 ciclo real = ctx + 80
    1 +1565 s 27,4 min
    2 +783 s 14,4 min
    3 +690 s 12,8 min
    4 +1386 s 24,4 min
    5 +723 s 13,4 min

    Boots consecutivos no log ficam 17–29 min apart, batendo com a tabela por outro caminho. O pedido era 120 s e o teto de backoff é 300 s (BACKOFF_MAX_MS em TelemetryManager.h:165), então nenhuma configuração explica o observado. O histórico grava um registro por wake (o AIR_DECIDE chama processHistoryLogging() sem gate), então o ciclo medido aqui é o ciclo de amostragem do produto.

  5. F13 confirmado no ferro: todo wake M1 registra WEB_SERVER_STARTED ctx=80 e APP_CACHE_PRELOAD_DONE (ctx 83→88, subindo 1 por wake) — a web sobe e o preload de gráfico varre blocos em cada acordada, exatamente o que a D5 mandava não fazer.
  6. F04 quantificado: as mesmas correções de +690 a +1565 s são o erro que um wake offline gravaria no histórico, porque aí não há NTP para corrigir.
  7. A prova direta do F22 — o arquivo de histórico. GET /download?file=/history/20260906.h5 (2.116 B) decodificado com tools/history_v5.py: 168 registros no dia inteiro, onde um intervalo de 2 min renderia 720. Os 25 últimos, com o gap em segundos:

    14:07:44   14:08:44 +60   14:09:44 +60   14:09:27  −17   14:39:44 +1817
    14:40:44 +60   14:41:44 +60   14:41:31  −13   14:58:50 +1039
    14:59:50 +60   15:00:50 +60   15:00:33  −17   15:16:18  +945
    15:17:18 +60   15:18:18 +60   15:18:01  −17   15:45:21 +1640
    15:46:21 +60   15:47:21 +60   15:47:05  −16   16:03:23  +978
    

    ⚠️ RETRATAÇÃO PARCIAL, e ela importa. A primeira leitura deste arquivo foi feita com o intervalo nominal errado: history_v5.read_series() assume 60 s por default e o aparelho usa 120 s (h5NominalSeconds(h_int=2)). O V5 codifica cada registro como desvio do passo nominal, então decodificar com o nominal errado reescreve todos os tempos interiores — foi isso, e não o firmware, que produziu as “rajadas de 3 registros a exatos 60 s” e boa parte dos gaps negativos que a primeira versão desta seção reportou. Números corretos, com nominal 120 s:

    decodificado com registros no intervalo curtos longos para trás
    60 s (errado) 175 21 77 56 20
    120 s (certo) 175 83 30 49 12

    O que sobra de real depois da correção, e é o F23: 12 gaps negativos — o arquivo ainda não é monotônico. A causa é estrutural: o interior de cada bloco é reconstruído pelo passo nominal, então um bloco cujas amostras reais foram mais espaçadas “avança” além do t0 do bloco seguinte e produz um retrocesso na leitura.

7b. A leitura que decide o F22: as âncoras de bloco. Cada chunk DATA carrega seu próprio t0 absoluto, imune ao nominal. Lendo só os t0 do dia:

   11:16:18  11:18:45  11:21:16  11:23:45  11:26:13  11:28:40  11:31:08  11:33:35
      gaps:   147  151  149  148  147  148  147   (segundos)

O ciclo do Air estava CORRETO de manhã: 147–151 s = 120 s de sono + ~28 s acordado, com h_int=2. Antes disso, por volta das 10h40, os gaps eram de 87–88 s — o mesmo ciclo com h_int=1. A degradação começa perto das 12h15: daí em diante os gaps de bloco viram 2642, 2915, 1920, 1146, 1048, 1743 e 1082 s.

Portanto o F22 é uma REGRESSÃO, não um defeito de nascença do desenho — e a janela em que ela aparece é a mesma em que a build nova foi gravada (o .uf2 da árvore tem mtime 13h04 e o alvo sumiu do USB a partir das 12h28). O único candidato nessa build que mexe em clock é o ROSC desligado antes do WFI, que era o F01. Ou seja: o F01 não era inofensivo, apenas falhava de um jeito diferente do previsto — em vez de travar de vez, faz o wake demorar um tempo longo e variável.

Lição de instrumento (registrada porque custou uma execução): a primeira rodada da T05 falhou com “serial vanished before the alarm line”. Não era o firmware — era a suíte: cmd() lia por 2 s e descartava o transcrito, e um aparelho com sensores já estáveis vai de air hibernate ao [AIR] alarm: em menos de um segundo. Corrigido escrevendo o comando e lendo um único fluxo. Daí também nasceu o modo --watch, que mede o ciclo sem tocar no aparelho.

Três instrumentos que mentiram nesta bancada — registrados para não custarem de novo:

  1. O help NÃO discrimina firmware. Num aparelho com pack não-inglês o console de emergência serve o @HELP do .lng do LittleFS (mudança da v2.3.7-beta), que sobrevive à gravação de firmware. O help do alvo não lista system ssid nem os comandos air, e ainda assim o binário é o 461a806 e os comandos funcionam. Discriminar por comportamento (system ssid sem argumento), nunca por texto de ajuda.
  2. /api/status reporta uptime em MILISSEGUNDOS. Ler 84.494 como segundos dá 23,5 h num aparelho que tinha 84 s de vida. Bate exatamente com millis().
  3. O ModemManager está ativo nesta máquina e sonda todo ttyACM recém-enumerado. Logo depois de um reset a primeira sessão serial pode morrer com “device reports readiness to read but returned no data” sem que o alvo tenha feito nada. Reabrir e seguir; se incomodar, regra de udev com ID_MM_DEVICE_IGNORE.

6.2 Validação da Fase 1 — 06/09/2026, ~16h30

Fix do ROSC, medido isolado (build = 461a806 + o religamento do ROSC, gravada pela PicoHand porque o picotool sozinho não achou o alvo em BOOTSEL). air hibernate pela serial, alarme 00:02:00 wakeSec=120, e o ciclo medido por --watch (passivo, enumeração USB):

ciclo dormindo acordado
1 110,8 s * 26,5 s
2 120,8 s 26,3 s

* o primeiro valor é parcial: a observação começou com o aparelho já dormindo.

Com as três correções da Fase 1 gravadas, quatro ciclos seguidos (--watch 620):

medida ciclo 1 ciclo 2 ciclo 3 ciclo 4
dormindo 116,8 s * 120,8 s 120,8 s 120,6 s
acordado 33,0 s 26,5 s 26,5 s 29,5 s

* parcial pelo mesmo motivo. Alarme pedido: 120 s. Erro dos três ciclos completos: +0,6 a +0,8 s.

De 16–48 min para 120,7 s ± 0,1. O ciclo total (~147–150 s) bate com os 147–151 s que a mesma bancada tinha de manhã, antes da build que desligou o ROSC — ou seja, o comportamento voltou ao conhecido-bom, e não para um valor novo qualquer. Isso fecha F22 e F01 de uma vez, e mostra que F02/F03 não regrediram nada.

Fases do ciclo, com a build final (air hibernate → dormindo, lido do console):

WARMUP @21378  SAMPLE @21772  DECIDE @35787  CONNECT @35868  FLUSH @35868  SLEEP @36013

Ou seja: warm-up 394 ms (o valor certo — o F06 nunca afetou o caminho por comando), amostragem 14,0 s, decisão 81 ms, FLUSH 145 ms (fila vazia, sem travar) e entrada em sono 145 ms depois. Janela acordada total ~26 s.

Portões de host, com as três correções: pico_w_air 97,7% de flash (+200 B), pico_w_release 97,4% e pico_w_alpha 97,5% inalterados, native_air 7/7, autoteste da suíte 16/16.

6.3 F23 depois do fix — a monotonicidade voltou sozinha

Medido às ~16h55, com o período já correto, baixando o mesmo arquivo do dia e olhando só os registros posteriores ao fix (≥ 16:30):

recorte registros gaps para trás
dia inteiro (inclui a manhã quebrada) 188 12
após o fix 12 0

Tempos pós-fix: 16:31:09, 16:31:24, 16:33:51, 16:36:18, 16:38:20, 16:38:35, 16:41:02, 16:43:36, 16:44:54, 16:48:31, 16:51:01, 16:53:38. Nenhum retrocesso.

Confirma a previsão que este plano já registrava: os gaps negativos vinham de blocos cujo interior era reconstruído pelo passo nominal enquanto as amostras reais estavam muito mais espaçadas. Com o wake no intervalo, a reconstrução volta a bater com a realidade. O F23 deixa de ser bloqueante; o endurecimento (selar o bloco antes de dormir) continua valendo para o caso de o período voltar a escorregar, mas não segura mais nada.

⚠️ O que sobrava: o intervalo real entre registros era ~147 s com h_int=2 (120 s), porque o alarme era ancorado no instante em que o aparelho dorme, não no instante em que acordou — o período efetivo virava h_int + janela acordada, ~22% de amostras a menos por dia. ✅ Corrigido em 06/09 — ver §6.4.

6.4 Intervalo real: alarme ancorado no wake

O alarme passa a ser h_int − (tempo que este wake já passou acordado). Como um wake do M1 é um boot, esse tempo é exatamente millis( ) no instante de dormir, e a subtração faz o período boot-a-boot valer o intervalo configurado.

Três detalhes que o desenho precisou tratar:

A linha do alarme agora carrega a conta inteira, para a bancada não precisar inferi-la:

[AIR] alarm: 00:02:00 wakeSec=120 awake=39394ms target=120000ms

Medido no ferro logo em seguida (--watch 560, h_int=2):

ciclo dormindo acordado período
1 — boot frio, sem compensar 114,3 s 26,8 s 141,1 s
2 91,8 s 26,8 s 118,6 s
3 91,8 s 26,8 s 118,6 s
4 91,8 s 27,0 s 118,8 s

O primeiro ciclo é o boot frio, que por desenho não compensa (não há wake anterior a que ancorar) — e ele serve de controle: 141 s, o comportamento antigo. Os três seguintes ficam em 118,6 s de período observado contra 120 s configurados, com o tempo de sono repetindo 91,8 s nos três. ⚠️ Os ~1,4 s que faltavam para 120 não eram o atraso da enumeração USB, como esta seção afirmou primeiro: eram truncamento na conta do alarme. Ver §3, onde o aparelho passa a medir o próprio sono, a sonda pega o viés desse autorrelato e o ciclo fecha em 120,23 s.

Série completa do dia, para a mesma configuração de 120 s: 16–48 min (ROSC parado) → 147 s (ROSC corrigido) → 118,6 s (alarme ancorado no wake).

Pendências desta bancada: exercitar o caminho telInterval == 0 do F02 (T06b); confirmar se o sensor da bancada está mesmo chaveado pelo GP16 (nesta bancada o DS18B20 está no GP0, sem chaveamento, então o F03 não é exercitado aqui); medir corrente.

6.5 O cursor de telemetria — duas causas, uma em cada lado

Sintoma relatado pelo Ângelo: “o cursor não está avançando e o mesmo pacote é reenviado quando o simut-air acorda”. A investigação achou duas causas independentes, e as duas produzem exatamente esse sintoma.

Causa 1 — firmware (corrigida, F24). flushCursorIfDirty( ) adia a escrita por CURSOR_COALESCE_MS (5 s). No ciclo M1 o envio marca o cursor sujo e a fase FLUSH sai ~150 ms depois, com a fila drenada: a janela nunca decorre, o sono perde a SRAM e o boot relê o arquivo antigo. Medido antes: pending=8, subindo 1 por ciclo. Depois do fix: 12 ciclos renderam +5, ou seja, a subida 1:1 parou.

Causa 2 — o coletor, e ela é a dominante. O endpoint 192.168.3.206:8080/telemetry aceita a conexão TCP e nunca responde. Medido desta máquina, fora do aparelho:

$ curl -X POST --max-time 20 http://192.168.3.206:8080/telemetry
http=000  conectou=0,0035s  primeiro_byte=0,000000s  total=20,002s   (rc=28, timeout)

O log do aparelho diz o mesmo pelo lado dele: [ERR][TEL] code=31 ctx=-11 (SYS_TEL_FAIL com HTTPC_ERROR_READ_TIMEOUT) seguido de [WRN][TEL] code=32 (retry), três vezes com backoff crescente. O firmware está certo: um envio sem resposta é um envio não confirmado, e o store-and-forward existe justamente para não avançar o cursor nesse caso. O resultado correto é reenviar o mesmo lote.

Prova cruzada. Apontando o mesmo aparelho, sem tocar em mais nada, para um coletor que responde 200 na hora (tools/air_test_suite.py sobe um em :8010):

coletor pending
.206:8080 (não responde) 8 → subindo
local :8010 (responde 200) 9 → 1 em 3 ciclos

A configuração original foi salva, restaurada e conferida ao fim do teste.

O que falta fazer, e é do lado do servidor: o endpoint precisa devolver uma resposta HTTP completa (qualquer 2xx serve) e prontamente. Enquanto ele só aceitar a conexão e calar, o aparelho vai continuar reenviando o mesmo lote — por desenho.

6.6 SSID indisponível: tentativas limitadas e sono imediato

Pedido do Ângelo: com o SSID fora do ar, no máximo 2 tentativas, e assim que a leitura e a gravação do histórico terminarem, hibernar — tentando de novo no wake seguinte.

Implementado: AIR_MAX_CONNECT_ATTEMPTS (default 2). A fase SAMPLE só bombeia o NetworkManager enquanto getConnectCycles( ) < AIR_MAX_CONNECT_ATTEMPTS; estourado o limite, para de bombear pelo resto do wake, registra no console, e o DECIDE trata o wake como offline mesmo que o link apareça depois — os dados já estão na flash e o próximo wake envia. Como cada wake é um boot novo, o contador zera sozinho e a tentativa recomeça.

Medido no ferro com o SSID errado (<SSID da bancada>2, posto pelo Ângelo justamente para isso), três wakes seguidos:

wake janela acordada
1 26,7 s
2 26,4 s
3 26,2 s

Com o SSID certo a mesma janela é de 26,3 a 29,5 s. Ou seja: a rede ausente não alarga o wake em nada — o que fecha a fase é a estabilização dos sensores, o histórico é gravado e o aparelho dorme.

⚠️ Honestidade sobre o limite: o teto de 2 é um teto, não o caso comum. Cada tentativa custa até 20 s dentro do NetworkManager, e o wake inteiro dura ~28 s, então na prática uma tentativa começa por wake e o limite não chega a disparar (a linha [AIR] wifi: no link after… não apareceu em nenhum dos três wakes). Ele existe para o caso de uma janela acordada longa — sensores lentos, stabTimeoutMs grande — em que o segundo ciclo de reconexão caberia.

6.7 F25 — um reset durante o wake tirava o aparelho do ciclo ✅ CORRIGIDO

Achado novo, e é o pior modo de falha visto até agora nesta build. Descoberto às 20h30 de 06/09, com o SSID errado ainda configurado, quando uma medição encontrou o aparelho em M0, acordado, varrendo a rede — e não no ciclo em que ele estava meia hora antes.

O mecanismo é deliberado, e está escrito em src/AppManager_Boot.cpp:132: o marcador de hibernação é lido e zerado em todo boot, “so a watchdog reset during the cycle does not re-enter M1”. A válvula de segurança existe por um bom motivo — um aparelho que trava em M1 fica inalcançável, acordando e travando para sempre. O preço, porém, é este: um watchdog durante a janela acordada devolve o aparelho a M0, com rádio ligado e consumo cheio, e a única volta para o ciclo é o timeout de inatividade (air idle, 300 s por padrão).

Na bancada, essa volta nunca aconteceu. Com o SSID inexistente, o Core 0 travava de novo antes dos 300 s. Do show system log: 72 boots, 6 FATAL, quatro deles no trecho final. As causas são ctx=455 (= 200 + 0xFF, “HW WATCHDOG: Core 0 loop stalled, trace channel empty”) e ctx=209 (= 200 + módulo 9). Entre resets o aparelho ficou acordado 54, 58, 86 e 107 s — sempre menos que os 300 s de que precisava para voltar a dormir. Resultado: acordado para sempre, que é exatamente o que a build Air existe para evitar.

⚠️ Um boot que dura mais que o air idle não prova que a válvula funciona: todo comando da CLI chama airMarkActivity( ) e rearma o timer. O boot de 6m18s que apareceu aqui foi mantido acordado pelas MINHAS consultas, não pelo firmware.

CORRIGIDO em 06/09 ~22h, combinando os três caminhos que a decisão D-6 listava — e nenhum deles sozinho bastava:

  1. A intenção do operador virou estado de flash. AirConfig.flags bit 0 (AIR_FLAG_CYCLE_ARMED) diz “o ciclo está armado”, escrito por air hibernate e apagado por air stop. O marcador do scratch responde outra pergunta — “acabei de acordar?” — e continua sendo zerado em todo boot, de propósito. Sem bump de versão do air.bin: o campo flags já existia como reservado, e um arquivo v2 lê 0, que significa “não armado” — o default seguro.
  2. Graça curta para voltar. Se o boot não é um wake mas o ciclo está armado, o timer de inatividade do M0 passa a valer AIR_RESUME_GRACE_SEC (10 s) em vez dos 300 s configurados. Dez segundos porque, enquanto ele espera, o aparelho está acordado com rádio ligado — o estado que esta build existe para evitar.
  3. Guarda de laço de crash. flags bits 4..7 contam boots sujos consecutivos (LogManager::bootWasClean( ), novo: falso só para watchdog e soft panic — reboot pedido pelo próprio firmware, incluindo o wake do Air, carrega a marca de markCleanReboot). Um boot limpo zera a conta; a partir de AIR_MAX_DIRTY_BOOTS (3) a graça volta a ser o air idle inteiro e o log ganha APP_AIR_CYCLE_HELD (411). A janela larga passa a ser merecida, e não o padrão. Escrita em flash só quando o número muda: a operação saudável não toca no air.bin, e um laço de crash gasta no máximo uma escrita por boot sujo até o teto.

air status passou a mostrar armed= e dirty=, porque sem eles phase=0 fica idêntico para “o operador parou o ciclo” e “um watchdog derrubou o aparelho para fora dele”.

⚠️ CORREÇÃO no mesmo dia, e o defeito era meu. A primeira versão dava a graça de 10 s a todo boot com o ciclo armado, inclusive um power cycle deliberado — e o Ângelo reportou o resultado: “não estou conseguindo entrar na interface web, o simut-air dorme antes de eu logar”. Dez segundos não dão para digitar um login. A graça passou a depender de quem causou o boot: boot limpo (power cycle, RUN, reload, OTA) é gente querendo entrar → vale o air idle inteiro; boot sujo (watchdog) não tem ninguém → graça curta, que é o caso do F25. Nos dois o ciclo volta sozinho, que era a propriedade em questão; só a espera difere.

Teste T12 da suíte (cycle_survives_reset): arma o ciclo, confirma um wake, reseta pela PicoHand no meio da janela acordada e não manda mais nenhum comando — porque todo comando chama airMarkActivity( ) e rearma o timer, ou seja, perguntar ao aparelho se ele voltou a dormir é exatamente o que o impede de dormir. O veredito vem da enumeração USB: ausente = dormindo.

Pendente de investigação separada: a travada do Core 0 em si, sob varredura de SSID inexistente. É parente do R1 histórico (“Core 0 na varredura”), e o ctx=455 diz que nem o canal de rastreio de módulo sobreviveu.


6.8 O preâmbulo de boot — a maior fonte de ruído do log num aparelho que boota a cada minuto

Achado da bancada, 07/09. Depois que as falhas de telemetria viraram por-transição, o log do Air continuou enchendo em pouco mais de uma hora. A contagem do próprio aparelho explicou por quê: 1.253 registros, 108 boots, e a assinatura de um wake era sempre a mesma, na mesma ordem:

524 441 590 407 549 540 567 404
NET_PROVISIONAL_TIME · APP_UI_LANG_CHANGED · SENSOR_RUNTIME_LOADED ·
APP_SENSORS_CALIBRATED · TEL_ALARM_LINE_ON · TEL_HTTP_INIT · STO_H5_WIP · APP_READY

Oito códigos = 68,2% da janela forense inteira. Não é um sistema contando alguma coisa; é um roteiro sendo reexecutado.

⚠️ Retratação de um diagnóstico meu, escrito horas antes nesta mesma sessão. Eu tinha registrado que a causa era “o LogPolicy mora na RAM e o begin( ) o zera a cada boot”. Está errado para 7 dos 8 códigos: eles não têm regra nenhuma na EDGE_RULES e caíam no default “código não roteado → grava sempre”. Fazer o estado do filtro sobreviver ao sono — que era o caminho que eu tinha descartado como impossível — não teria mudado nada. Só o STO_H5_WIP é roteado. Conferir isso é cruzar a lista com a tabela por programa, e leva um minuto; eu tinha deduzido em vez de conferir.

A correção. LogPolicy::setQuietPreamble( ), armado dentro do LogManager::begin( ) somente quando o boot veio da hibernação e desarmado por endBootPreamble( ) no fim do setup( ). Enquanto armado, os códigos da lista BOOT_PREAMBLE em nível INFO são suprimidos.

⚠️ A contabilidade horária não vale nada aqui, e é limitação conhecida. Os suprimidos são contados, mas o SYS_LOG_SUPPRESSED sai a cada 1 h de millis( ) — e num Air o millis( ) reinicia a cada wake, então o relatório nunca vence. Neste caso o custo é nenhum: o que foi calado é uma lista fixa e conhecida de oito códigos, ao contrário da supressão de telemetria, onde o número é a informação (quanto durou a queda). Emitir o relatório antes de dormir devolveria um registro por wake — exatamente o que se está tirando.

O discriminador já existia, e é melhor do que qualquer arquivo. _airActive é lido do watchdog_hw->scratch[0] como primeiro efeito colateral do setup( ), 427 linhas antes de o log existir. Esse registrador é a “abertura e fechamento de boot” que se pensaria em guardar no LittleFS: escrito antes de dormir, e apagado fisicamente pela queda de energia — justamente o caso que precisa gravar. Um arquivo custaria uma escrita de flash por ciclo (o oposto do que o F24 acabou de conquistar) e ainda seria menos confiável, porque a marca de “abertura” poderia sobreviver ao evento que ela deveria detectar.

Reforço barato: o portão exige _airActive && _airSleptSec != 0. O scratch[1] só recebe os segundos medidos depois que o WFI retorna, então um reset apertado durante o sono cai como boot frio e leva o preâmbulo inteiro — a direção segura, e a certa: ninguém acordou, alguém interveio.

412 APP_AIR_COLD_BOOT é a linha que um wake nunca escreve. Quando aparece, o aparelho reiniciou sem vir da hibernação: numa implantação a bateria, isso é uma interrupção de energia. ctx=1 boot limpo (energia, RUN, reload, OTA), ctx=0 o watchdog chegou antes.

Medido no ferro — mesmo procedimento nos dois firmwares, telemetria desligada para que nenhum wake levantasse o rádio, oito despertares contados de fora pela enumeração do USB:

Imagem registros no início no fim delta por wake
antes (ba43d55) 1.383 1.450 67 8,38
depois 1.498 1.508 10 1,25

6,7× menos. O delta bruto inclui a cauda do M0 final (rede, web, cache), que é constante do procedimento e cai igual nos dois lados — por isso o número que vale é a diferença, e o bruto está aí para poder ser conferido.

Lido no log do próprio aparelho, depois, as duas metades aparecem inteiras:

up8   412 ctx=1   <<<< BOOT      boot frio, limpo
up9   524 441 590 407            preâmbulo INTEIRO
up22  549 540 567 404 …          … até o "pronto"
--------------------------------------------------
up11  (wake)      <<<< BOOT      nenhum registro de preâmbulo
up12  520 522 570 14 13          isto é o `air stop`, não o boot

E o outro ctx também apareceu sozinho: [FTL] code=1 ctx=227 seguido de 412 ctx=0 — autópsia de watchdog e, logo abaixo, a linha dizendo que aquele boot não veio da hibernação. As duas juntas são a assinatura de “o aparelho reiniciou de verdade, e não foi de propósito”.

⚠️ Uma coisa vista e NÃO explicada, para não passar como se tivesse sido: uma rajada única de sete 567 seguidos (ctx 3→9, mesmo segundo, época provisória), uma vez em 1.514 registros e em nenhum outro lugar do arquivo. É comportamento de M0, aparece igual nos dois lados e não muda a comparação, mas sete gravações do bloco inteiro num segundo interessam ao desgaste de flash. Fica anotado como observação, sem causa atribuída.

O resultado esperado é 1 por wake, não 0, e o 1 é de propósito. O STO_H5_WIP sai uma segunda vez no flush do próprio ciclo, depois que a janela fechou; como um registro suprimido não marca a família como vista, essa cópia chega como a primeira transição de LOGGRP_HIST e é gravada, com a contagem do bloco no ctx. O wake troca oito registros por um, e o que sobra é o que descreve o trabalho que ele acordou para fazer — um Air saudável continua provando isso uma vez por ciclo, que era exatamente a objeção que tinha me feito não mexer nisso antes.

⚠️ Dois códigos da lista também são ação de operador (441 troca de idioma pela CLI, 407 calibração por /api/calib). É por isso que a janela fecha no fim do setup( ) e não dura o wake inteiro. É também o único teste que reprova quando o filtro fica ligado para sempre — o controle positivo foi rodado nos dois sentidos antes do commit: portão removido reprova 4 testes, arming ignorado reprova 2, e são esses 2 que importam, porque um filtro travado em ligado cala um aparelho que ninguém está olhando.

Portão: T16 wake_writes_no_preamble na suíte, teto 1. Delta negativo = o log rotacionou no meio da janela e o teste dá SKIP, porque só valem deltas: o show system log costura o arquivo rotacionado com o corrente, e nem o clear log confirm esvazia o que ele devolve.

Custo: +176 B de flash na imagem Air (1.023.720 → 1.023.896 B; folga 20.584 B).

6.9 F26 — o wake nunca ligava o rádio: o contador de pendentes é cego para o .wip

Sintoma relatado pelo Ângelo, 08/09: “o simut-air nunca faz wake com telemetria. Ele só faz telemetria quando GP17 está em high ou nos primeiros minutos do boot limpo.”

Os dois casos que funcionavam dizem qual é o caso que não funciona. GP17 em high é o carregador: o setup( ) cancela o M1 e o aparelho fica em M0, com rádio permanente. Os primeiros minutos do boot limpo são o mesmo M0, antes de o idle expirar. Fora do M0 a telemetria não existia — ou seja, o ciclo M1, que é o produto.

A prova, impressa pelo próprio aparelho, antes de qualquer alteração:

[AIR] wake: radio=off (pending=0 min=5 skip=0)
...  9 s depois, no mesmo boot ...
[STO] History snapshot written: wip_resumed (25)

Zero pendentes segundo o contador, 25 registros segundo o recuperador do .wip, no mesmo wake.

A causa. refreshPendingCount( ) conta duas coisas: os registros dos arquivos *.h5 mais novos que o cursor, e o bloco aberto na RAM. Num wake do Air não existe nem um nem outro no instante da decisão:

Entre esses dois pontos o bloco existe só como /history/.wip, e nenhum dos dois termos o enxerga. pending = 0 em todo wake, telemetryDue( ) falso em todo wake, CYW43 desligado em todo wake. A fila crescia na flash e nunca saía.

⚠️ É uma regressão de um acerto. O gatilho por quantidade (2e04326, 07/09 10h39) foi medido funcionando no mesmo dia — o comentário do T13 registra tel= lendo 0,0,1,2,3,4,5 em sete wakes. O que o quebrou foi o F23, oito horas depois, e a única coisa que ligava os dois era o arquivo do dia crescer por acidente. Nenhum teste cobria a junção, porque cada um dos dois estava certo.

A correção, em três peças:

  1. h5CountAfter( ) em HistoryV5.h — quantos registros de um chunk são mais novos que um cursor. Função pura, ao lado do h5SeedFromSnapshot( ) que faz a mesma leitura para outra pergunta, e coberta por 4 casos no native_history_v5.
  2. StorageManager::h5WipPendingSince( ) — lê o /history/.wip sob o read-lock e chama a função acima. Devolve 0 quando o encoder tem alguma coisa, porque depois de um resume o .wip continua na flash com os mesmos registros: sem essa regra os dois termos contariam em dobro.
  3. airTelemetryDue( ) passa a contar a leitura que este wake ainda vai tirar. A decisão é tomada antes do DECIDE e um wake nunca pula a leitura, então sem esse termo o t_int ficava um wake atrasado — com t_int=1 o aparelho mandava a cada dois wakes.

Medido no ferro, mesma bancada, mesma configuração (hist=60s, t_int=5, coletor 192.168.3.206:8080 respondendo):

wake .wip pending rádio
00:28:15 2 0 off
00:29:15 3 1 off
00:30:15 4 2 off
00:31:15 5 3 off
00:32:15 6 4 on — drenou
00:33:15 7 0 off
00:34:15 8 1 off
00:35:14 9 2 off
00:36:14 10 3 off
00:37:14 11 4 on

Dez wakes, duas transmissões, período exato de 5 — que é o que t_int=5 sempre quis dizer. Antes: dez wakes, zero transmissões, pending=0 nas dez.

Custo: +440 B de flash na imagem Air (1.025.440 → 1.025.880 B; folga 18.600 B).

Portão: o T13 já reprovava isto (radio never came up in N wakes) — o que faltava era rodá-lo. O budget do teste caiu de every + 2 para every + 1, porque um dos dois off-by-one que ele compensava era o do item 3 e acabou.

6.10 Consumo medido e autonomia estimada

awake_s por wake, dois instrumentos independentes na mesma janela de 10 minutos: o slept= que o próprio aparelho lê do RTC, e a janela de enumeração USB vista de fora.

tipo de wake slept= acordado (60 − slept) janela USB
leitura (rádio off) 34 s 26 s 24 s
telemetria (rádio on) 33 s 27 s 25–26 s

A telemetria custa 1 s a mais de janela, não mais: o CONNECT roda em paralelo com o SAMPLE por desenho, e um lote de 5 registros sai em menos de um segundo. O que ela custa é a corrente do CYW43 durante a janela inteira.

Com as correntes de bancada do Ângelo (25 mA leitura, 80 mA telemetria, 2 mA dormindo), 1 medição por minuto e telemetria 1:5:

estado wakes/dia h/dia mA mAh/dia %
leitura (rádio off) 1.152 8,32 25 208,0 51,0
telemetria (rádio on) 288 2,16 80 172,8 42,4
dormindo 13,52 2 27,0 6,6
TOTAL 1.440 24,00 17,0 méd. 407,8 100

O sono é 6,6% da conta. O aparelho passa 43,7% do tempo acordado, e é aí que a bateria vai.

bateria autonomia útil (~80%)
LiPo 1.000 mAh 2,5 d 2,0 d
18650 2.600 mAh 6,4 d 5,1 d
18650 3.400 mAh 8,3 d 6,7 d
2× 18650 6.800 mAh 16,7 d 13,3 d
powerbank 10.000 mAh 24,5 d 19,6 d

A alavanca não é a telemetria, é o período. Cortar o wake pela metade (13 s) dobra a autonomia; passar de 1/min para 1/5min quase quadruplica:

mudança mAh/dia 18650 3.400 mAh
medido (60 s, 1:5, wake 26/27 s) 407,8 8,3 d
wake pela metade (13 s) 227,9 14,9 d
período 120 s 227,9 14,9 d
período 300 s 120,0 28,3 d
período 600 s 84,0 40,5 d
telemetria 1:10 347,5 9,8 d
telemetria a cada wake (1:1) 890,4 3,8 d

⚠️ Dos 26 s de um wake de leitura, ~11 s são boot e ~14 s são o SAMPLE esperando o sensor estabilizar. É onde o F13 (boot M1 enxuto) e o stabTimeoutMs ainda têm o que render, e vale mais que qualquer economia no rádio.

⚠️ As correntes são as da bancada, medidas no ponto de alimentação; um pack real ainda perde no rendimento do regulador, na autodescarga e na tensão de corte. As colunas “útil ~80%” existem por isso e continuam sendo estimativa — corrente nunca foi medida por este agente.


6.11 F27 — o system admin reset anunciava uma senha que o próximo boot esquecia

Como apareci nisso: eu ia rodar o T11 e o login da bancada respondeu 401 err=2. Descartei regressão de firmware conferindo o que a auditoria tinha tocado (verifyPasswordFor intacto, caminho legado→V1 correto) e o estado do aparelho (SSID e nome preservados — o LittleFS não fora apagado). Era descasamento de credencial mesmo. O caminho documentado para sair disso é o comando que a auditoria de 07/09 adicionou (V-01a, parte B): system admin reset confirm, só pela USB.

Ele não funcionava. O console de emergência (SIMUT_CLI_FULL == 0, que é a imagem Air e a de release) não tem write memory; lá o changed = true só imprime “Vale para esta sessao; nao persiste apos reiniciar” e ninguém chama saveConfiguration( ). O handler reescrevia o hash, o salt e o hashVersion na RAM, imprimia a senha de uma vez só — e o boot seguinte trazia a senha velha de volta.

Num Air todo wake é um boot. A senha impressa valia cerca de um minuto. A única recuperação de uma web trancada não recuperava nada.

Medido no ferro, 08/09, nas duas pontas:

momento login com a senha impressa
mesmo boot em que foi impressa OK
depois de reload confirm 401 {"ok":false,"err":2}

O que escondeu o defeito. Duas caixas acima no mesmo switch, CMD_SET_WIFI_SSID e CMD_SET_WIFI_PASS chamam saveConfiguration( ) na hora, com um comentário que enuncia a regra: “Emergency console has no ‘write memory’ — persist right away”. Quem escreveu aquilo sabia. O que apagou a regra para o leitor seguinte foi o comentário do #else, que afirmava:

debug is the only survivor that sets this flag

Era falsosystem admin reset também setava. Uma afirmação errada num comentário custou um dia de invisibilidade a uma senha que não persistia.

Conserto. cmdHandleResetAdmin( ) chama saveConfiguration( ) antes de anunciar a senha, e não seta changed (setar imprimiria uma frase que virou mentira). Se o save falhar, o comando diz NAO SALVOU: vale so ate reiniciar — o hash na RAM já é a credencial viva, então o operador ainda pode usá-la, mas precisa saber que ela morre no boot.

O buraco que o conserto abriu, e como foi fechado. Persistir o reset também persiste mustChangePassword = true, e isFactoryDefaults( ) era exatamente “users[0] é admin e está pendente de troca”. Com a flag sobrevivendo em flash, todo boot passaria a anunciar SEC-003: FACTORY DEFAULTS ATIVADO e a gravar um LOG_WARN — num Air, uma vez por minuto, sobre um aparelho que não está em factory defaults. Isso estouraria o T16 (teto de 1 registro por wake), que tinha acabado de ser conquistado em 07/09.

O predicado honesto não é a flag, é o texto claro de uma vez só: ele é escrito apenas por loadDefaults( ) e zerado assim que uma config válida é lida da flash, então sua presença data a resposta ao boot que regenerou a config — que é o que o único chamador quer dizer. isFactoryDefaults( ) passou a exigi-lo, e o else do anúncio (que existia para o caso “factory defaults sem texto claro”) deixou de ter estado que o alcance.

⚠️ O que eu NÃO reproduzi: no firmware antigo esse mesmo else já era alcançável por um system factory confirm que o operador deixasse sem trocar a senha — aí ele avisava a cada boot, para sempre. Não fui atrás dessa via porque um factory reset custaria o rig inteiro; o que medi é que com o conserto o estado não gera nem banner nem registro:

### reset (persiste mustChangePassword=true) → reload
FACTORY banner on serial: False
factory-defaults records in the flash log: 0

Portão novo: T17 admin_reset_persists. Reseta a senha pelo console, reinicia, e exige que a senha impressa ainda logue; depois devolve a senha da bancada pela web (o caminho que sempre salvou). Uma falha deixa a bancada usável de qualquer jeito: um reset que não persistiu já restaurou a senha antiga ao falhar, e o teste confere isso e diz. Controle negativo: a falha acima foi medida à mão no firmware anterior, antes de existir o teste.

Custo: Air 1.025.880 → 1.025.896 B (+16). Release e alpha compilam e rodam os mesmos 290 casos nativos.

A regra que fica: num console sem write memory, quem muda algo que precisa sobreviver ao boot salva ali mesmo, ao lado da mudança — e não confia num flag compartilhado cujo significado depende da imagem em que o arquivo foi compilado.