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, commit461a806(06/09/2026), sobremain6d2142c(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) etools/check_air_consistency.py(estático). Idioma: pt-BR (espelha os demaisdocs/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:
- 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. - 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.
- Fase 3 — consistência. ✅ (menos o CI).
check_air_consistency.pyC1–C8 limpo e −712 B no release / −272 B no alpha — a limpeza se pagou em flash. - 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 lê em vez do zero
que se escreve nele — o
loadjá 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. - 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).
- SSID indisponível. ✅ §6.6. Teto de tentativas por wake; medido, a rede ausente não alarga a janela acordada.
- 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:
- Fase 0 — instrumentar e medir no ferro.
- Fase 1 — bloqueantes (F22, F23, F02, F03).
- Fase 2 — ciclo M1 correto (F04–F14, F21).
- Fase 3 — consistência e portões (F15–F20 + CI cobrindo o Air).
- Fase 4 — otimização de tempo acordado e energia, só depois de medir.
- 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 nativo — airSleepSecBounded( ) 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.py lê SIMUT_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)
- 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
helpdo console de emergência trazsystem ssidapenas a partir de461a806. Sem isso, qualquer veredito sobre F01 fala de um binário desconhecido. Se houver dúvida, regravar compython3 tools/air_test_suite.py --flash .pio/build/pico_w_air/firmware.uf2. tools/fsguard.py backupdo LittleFS do alvo (regra da bancada: nada de stage/format sem backup).- Provar F01: gravar
461a806como está,air hibernatecomh_int=1, observar o wake pela suíte (--only T05 --cycles 1). O critério é o alvo reenumerar sozinho dentro dewakeSec + 120 s; se não reenumerar, F01 está confirmado. ⚠️hand RESETnã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 oscratch[0],src/LogManager.cpp:605). Registrar o resultado na seção 6 e emAGENTS.md. - Rodar a suíte inteira em
--baselinepara congelar o estado atual (os XFAIL esperados estão marcados no código da suíte e listados na seção 4). - 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
- saída imediata quando
cfg.telInterval == 0(o default de fábrica): nesse estado oTelemetryManager::update( )retorna sem enviar, sem falhar e sem escalar backoff, então nenhuma das três condições de saída antigas podia se tornar verdadeira; netLostpassou a usarisNetworkHealthy( ), que inclui o piso de RSSI — um link associado mas fraco demais para subir dados mantinhaisConnected( )verdadeiro;- teto de parede por
_airCfg.flushTimeoutMs, que não depende do uploader chegar a um veredito: nenhuma trava futura naquele caminho segura o aparelho acordado.
⚠️ 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
airSensorPower( )deixou de ser estática e é chamada no início dosetup(), logo após a leitura doscratch[0], antes de qualquer sondagem de sensor;- depois de
airLoadConfig( ), seair.binnomear outro pino, o configurado sobe e o default desce — exatamente uma linha fica acionada; gpio_inituma vez por pino, com máscara estática (F07), então o WARMUP não glitcha mais a alimentação nem a sonda;- em M0 o pino fica alto o tempo todo, que era o buraco principal.
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:
- ✅ F24 — o cursor de telemetria é gravado antes de dormir (
flushCursorIfDirty(true)), com a coalescência e a prioridade de toque puladas quando a escrita é forçada. §6.5. - ✅ SSID indisponível não segura o wake —
AIR_MAX_CONNECT_ATTEMPTSlimita as tentativas por wake; passado o teto, SAMPLE para de bombear a rede e DECIDE trata o wake como offline. §6.6.
F04 + F10 — RTC como relógio de parede
- Antes de dormir:
epoch = getEpoch(); RTC ←gmtime(epoch); alarme ←gmtime(epoch + wakeSec)(sem limite de 24 h). - ⚠️
scratch[1]JÁ TEM DONO desde 06/09 (§3): guardaAIR_SLEPT_MAGIC | segundos dormidos, medidos pelo próprio RTC depois dowfi. Não reivindicar o registrador semgrep— é a armadilha de sempre. E não faz falta: segundos dormidos é o dado melhor, porque o boot monta o epoch comoúltimo epoch conhecido + dormidos + acordado, sem depender de o RTC ter sido semeado com hora real antes de dormir. - No boot M1: se o scratch trouxer o magic,
setProvisionalTime(epoch_antes + slept - 60)(a função soma 60) e zerar o scratch. O boot já lê e imprime esse valor ([AIR] woke: slept=<n>s); o que falta é usá-lo para semear o relógio. - Manter a semântica de proveniência: continua “provisório” até o NTP; com F08 o registro só é escrito após o NTP quando há link.
- Atualizar o mapa de scratch (F19).
Aceite: T08 (3 wakes offline com h_int=2 → espaçamento 120 ± 25 s).
F05 — cadência do dreno ✅ 07/09
- Feito como um estado, não como um “kick” por lote:
TelemetryManager::setDrainMode(bool). Ligado na transição CONNECT→FLUSH, desligado na saída do FLUSH e noair stop. Em modo drenoupdate( )pula só o portão do intervalo; o backoff após falha continua (é oserverLostque encerra o wake com coletor mudo). - Medido antes/depois com a sonda GP16,
t_int= 60 s, leitura a 1 min, HTTP lote 100: 0 → 18.800 registros no mesmo wake de ~57 s (SIMUT_TELEMETRIA_PLANO_CADENCIA.md§2.4, §2.8).
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
- Em Air,
NetworkManagerusa retry inicial de NTP de 5 s (hoje 20 s). air statuspassa a mostrarnet=(assoc/ready/offline) para diagnóstico.
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
- F15:
HelpLicenseEN.hcom o blocoairsob#if SIMUT_AIR(partir a raw string em duas literais adjacentes);check_cli_help.pylêSIMUT_AIRdos build flags e montaEMERGENCY_EXPECTEDpor imagem;@HELPdos dois packs completos. - F16: remover os
Serial.println("[AIR] boot: …"). - F17: remover
airBeginWake,AIR_PHASE_PERSIST; renomear campos com o bump de F14; corrigir comentários. - F19: mapa de scratch.
- CI:
build.ymlganhapio run -e pico_w_air,pio test -e native_airepython3 tools/check_air_consistency.py. O workflow roda só emmain; para a branch, rodar localmente antes de cada push. - Testes nativos novos em
test/test_air_config:airIdleSecValid, cálculo do alarme por epoch (F10),airSleepSecondsFor(hist, backoff).
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:
- 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. - Janela de estabilização:
MOVING_AVG_WINDOW=10×s_intdomina o wake (10 amostras × 2 s = 20 s). Novo campostabSamplesnoair.bin(default 3) e critério “N amostras válidas” em vez debufferFull(). Estimativa: −12 s. - Dreno com kick (F05) e lote 250: o tempo de envio vira função do payload, não do
telInterval. - 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.
- ✅ 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_spor 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 / 1000truncava até 1 s por ciclo, sempre no mesmo sentido) e o alarme tem de ser armado sobre o valor que o RTC lê, porque oloadjá vale um tique. - ROSC off (já feito) só depois de F01 provado; ganho ~0,25 mA em sleep.
- LED: apagado durante SAMPLE/FLUSH, um pulso de 50 ms ao gravar e ao enviar (economia pequena; melhor sinalização).
- USB em bateria: se
VBUSausente, não esperar a enumeração nem imprimir marcadores (avaliar depois de medir). - DORMANT: só reabrir quando SLEEP estiver estável por semanas; o esboço registra por que foi descartado.
Fase 5 — release
- CHANGELOG (EN + pt-BR) com a entrada Air (já existe “Unreleased” apontando para este plano).
- README ×3: tabela de envs (feito), console de emergência (feito), seção curta “SIMUT Air”.
- Manuais:
docs/CLI-Manual.md(console de emergência +air, feito),docs/WIRING.md(GP16, feito), PicoHand (feito). - Bateria de ferro: suíte completa verde + 3 ciclos OTA no Air (a OTA nunca foi exercitada nesta imagem; validar
appliercom o LittleFS do Air). - Merge por PR em
main, tag e release conformerelease-tagda 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
- 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 lê, 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 | 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 | /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)
- D-1 Bluetooth em M1: canal de emergência (bombear em M1, custa tempo de boot e energia) ou “só USB em M1”?
- D-2 Web em M1: manter fora (D5, recomendado) ou permitir uma janela curta para diagnóstico?
- D-3
system ssid/passno console de emergência de todas as imagens: manter (recomendado, documentado) ou restringir ao Air? - D-4
air.binv3: aceitar perder a config Air existente nos aparelhos de bancada (defaults voltam)? - D-5 RTC como relógio de parede (F04): aceita que registros offline fiquem “provisórios” com erro do drift do XOSC (~20 ppm ≈ 1,7 s/dia) até o próximo NTP?
D-6 Volta ao ciclo após boot sujo (F25, §6.7)— ✅ RESOLVIDA em 06/09: os três caminhos foram combinados (intenção noair.bin, graça de 10 s, contador de boots sujos com teto de 3). Ver §6.7.
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.
- O alvo estava vivo e alimentado o tempo todo, mas ausente do USB por horas.
hand RESETtrouxe-o de volta em 3 s. Ou seja: ausência prolongada do USB não era falta de energia nem cabo. - Reset físico entrega M0 — MEDIDO. A primeira leitura de
air statusdepois do pulso de RUN, antes de qualquerair stop, deuphase=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 ohand RESETacordava em M1. - 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 noruntime_init_clocksestá descartada para este alvo. A correção continua recomendada como endurecimento (osleep_power_updo pico-extras religa o ROSC, e depender de comportamento não documentado do reset é frágil), mas sai da fila de bloqueantes. -
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 omillis(); no sync oNetworkManagercalculadelta = time(nullptr) − (_provisionalBase + uptime_s)e oAppManager::handleTimeSyncgrava esse delta em segundos no contexto do código 408 (AppManager_Core.cpp:91, saturado em int16). Como otime()real valeultimo_registro + ciclo_real, saiciclo_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_MSemTelemetryManager.h:165), então nenhuma configuração explica o observado. O histórico grava um registro por wake (oAIR_DECIDEchamaprocessHistoryLogging()sem gate), então o ciclo medido aqui é o ciclo de amostragem do produto. - F13 confirmado no ferro: todo wake M1 registra
WEB_SERVER_STARTED ctx=80eAPP_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. - 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.
-
A prova direta do F22 — o arquivo de histórico.
GET /download?file=/history/20260906.h5(2.116 B) decodificado comtools/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
t0do 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:
- O
helpNÃO discrimina firmware. Num aparelho com pack não-inglês o console de emergência serve o@HELPdo.lngdo LittleFS (mudança da v2.3.7-beta), que sobrevive à gravação de firmware. Ohelpdo alvo não listasystem ssidnem os comandosair, e ainda assim o binário é o461a806e os comandos funcionam. Discriminar por comportamento (system ssidsem argumento), nunca por texto de ajuda. /api/statusreportauptimeem MILISSEGUNDOS. Ler 84.494 como segundos dá 23,5 h num aparelho que tinha 84 s de vida. Bate exatamente commillis().- O ModemManager está ativo nesta máquina e sonda todo
ttyACMrecé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 comID_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:
- Só quando o boot foi mesmo um wake. Depois de um boot frio — ou de um
air stopque devolveu o aparelho ao operador —millis( )mede tempo de bancada, não de ciclo, e não há wake anterior a que ancorar. Daí o_airWokeFromSleep, separado do_airActive(que oairStartHibernate( )também liga). - O backoff não é compensado.
getBackoffRemainingMs( )já conta a partir de agora, e encurtar uma punição derrotaria o propósito dela. A compensação vale só para a cadência. - Piso de
AIR_MIN_SLEEP_SEC(5 s). Se um wake durar mais que o próprio intervalo, a subtração pediria zero e o aparelho entraria em boot-dorme-boot. Com o piso ele degrada para “o mais rápido que dá” e a linha do log ganha o sufixoOVERRUN, que é o sinal de que a cadência configurada não cabe no trabalho do wake.
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:
- A intenção do operador virou estado de flash.
AirConfig.flagsbit 0 (AIR_FLAG_CYCLE_ARMED) diz “o ciclo está armado”, escrito porair hibernatee apagado porair 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 doair.bin: o campoflagsjá existia como reservado, e um arquivo v2 lê 0, que significa “não armado” — o default seguro. - 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. - Guarda de laço de crash.
flagsbits 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 demarkCleanReboot). Um boot limpo zera a conta; a partir deAIR_MAX_DIRTY_BOOTS(3) a graça volta a ser oair idleinteiro e o log ganhaAPP_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 noair.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:
- o bloco aberto só chega ao arquivo do dia quando sela, uma vez por hora a um registro por
minuto — e desde o F23 (
1620ced, 07/09 18h26) o boot retoma o.wipem vez de selá-lo, então o arquivo do dia parou de crescer a cada wake; - a decisão de levantar o rádio é tomada em
AppManager_Boot.cpp:523, logo depois do_storageMgr->begin( ), e orecoverWipV5( )— que devolve o bloco à RAM — roda ~630 linhas depois, no mesmosetup( ).
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:
h5CountAfter( )emHistoryV5.h— quantos registros de um chunk são mais novos que um cursor. Função pura, ao lado doh5SeedFromSnapshot( )que faz a mesma leitura para outra pergunta, e coberta por 4 casos nonative_history_v5.StorageManager::h5WipPendingSince( )— lê o/history/.wipsob o read-lock e chama a função acima. Devolve 0 quando o encoder tem alguma coisa, porque depois de um resume o.wipcontinua na flash com os mesmos registros: sem essa regra os dois termos contariam em dobro.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 ot_intficava um wake atrasado — comt_int=1o 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:
debugis the only survivor that sets this flag
Era falso — system 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.