diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c159c568..24598e0c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -126,6 +126,15 @@ jobs: # with each switch flipped from it, and compares the two. - name: Feature bits /api/status reports run: python3 tools/test_feature_bits.py + # The savings floor itself (measure_savings.py --check) builds 21 images, + # so it runs nightly in savings.yml, where no pull request sees it. What + # it compares is tested here: the fill the linker puts in front of the + # BTstack flash bank, read from the map and taken off both sides. Counted, + # it moved the floor of every switch on the alpha and the Air with each + # byte of .rodata anywhere in the image: on 2026-10-22, after 21 red + # nights, it was all of bluetooth's drop and part of tel_tls's. + - name: Savings floor measures past the BTstack bank's fill + run: python3 tools/test_measure_savings.py # The network driver is the image's identity (SIMUT_NET_CYW43, the default, # or SIMUT_NET_ENC28J60) and the Wi-Fi / cable class (SIMUT_NET_WIFI, diff --git a/.github/workflows/savings.yml b/.github/workflows/savings.yml index 85059b4b..aaebd484 100644 --- a/.github/workflows/savings.yml +++ b/.github/workflows/savings.yml @@ -2,10 +2,12 @@ name: SIMUT savings watermark # O portão de economia (docs/analysis/ECONOMIA_DE_RECURSOS.md): prova que # desligar um recurso ainda devolve pelo menos o que devolvia — que nenhum -# recurso vazou para o caminho sempre-ligado. Constrói ~13 imagens (base + -# variante por recurso), caro demais para o job `gates` de todo PR, então roda -# à noite e sob demanda. Um `--check --only ` local é o passo barato -# para conferir um recurso ao mexer no que ele guarda. +# recurso vazou para o caminho sempre-ligado. Constrói 21 imagens (as 5 bases e +# uma variante por recurso do piso, em 2026-10-22), caro demais para o job +# `gates` de todo PR, então roda à noite e sob demanda. Um `--check --only +# ` local é o passo barato para conferir um recurso ao mexer no que ele +# guarda. Um vermelho daqui não aparece em PR nenhum: de 2026-10-02 a 2026-10-22 +# ele reprovou todas as noites sem ninguém ver. on: schedule: - cron: '0 6 * * *' # 06:00 UTC diariamente diff --git a/AGENTS.md b/AGENTS.md index 861cb3a2..c957b89d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -392,11 +392,16 @@ python3 tools/test_flash_budget.py # as duas regras que mantê cada chave devolve quando desligada, e `tools/feature_savings.json` guarda o piso — um ratchet ao contrário do orçamento (a economia só sobe). O portão `measure_savings.py --check` reprova se um recurso vazou para o caminho - sempre-ligado e deixou de sumir. Constrói ~13 imagens, então NÃO está no job - `gates`: roda em `.github/workflows/savings.yml` (noturno/manual). Ao mexer no - que um recurso guarda, confira barato com `--check --only `. A tabela - e os achados (bluetooth = 140 kB no Alpha; o buzzer, com chave própria desde - 26/09) estão em + sempre-ligado e deixou de sumir. O flash que ele compara é o `used` sem o + enchimento que o linker põe antes do banco do BTstack (lido do mapa do linker): + contado, o enchimento mexia o piso de toda chave do Alpha e do Air a cada byte + de `.rodata` da imagem. Constrói 21 imagens, então NÃO está no job `gates`: + roda em `.github/workflows/savings.yml` (noturno/manual), e **nenhum PR vê o + vermelho dele** — confira com `gh run list --workflow "SIMUT savings + watermark"`. Ao mexer no que um recurso guarda, confira barato com `--check + --only `; uma queda intencional pede `--update` e a causa no + commit. A tabela e os achados (bluetooth = 137 kB no Alpha sem o + enchimento, 22/10; o buzzer, com chave própria desde 26/09) estão em [docs/analysis/ECONOMIA_DE_RECURSOS.md](docs/analysis/ECONOMIA_DE_RECURSOS.md). - **Build sob medida** (o configurador, P7 de `MODELO_DE_RECURSOS.md`): `tools/build_custom.py --profile '{"v":1,"base":"pico_w_air","set":{"bluetooth":false}}'` diff --git a/docs/analysis/ECONOMIA_DE_RECURSOS.md b/docs/analysis/ECONOMIA_DE_RECURSOS.md index 9740aecc..4c4d2d80 100644 --- a/docs/analysis/ECONOMIA_DE_RECURSOS.md +++ b/docs/analysis/ECONOMIA_DE_RECURSOS.md @@ -2,7 +2,9 @@ **Estado:** Living · **Levantado em:** 2026-09-26 contra `main` `dc7c7c5`, medindo os seis ambientes de firmware com `tools/measure_savings.py`; o rastreio -do que ainda não tem chave, em 2026-09-30 contra `main` `7b948e7` (v2.8.0) · +do que ainda não tem chave, em 2026-09-30 contra `main` `7b948e7` (v2.8.0); o +piso, medido de novo sem o enchimento do banco do BTstack, em 2026-10-22 contra +`main` `25e169df` · **Pergunta:** ao desligar um driver ou qualquer funcionalidade, quanto de flash e de RAM a imagem realmente devolve — e o que hoje **não** devolve o que deveria? @@ -19,7 +21,10 @@ cai e o portão reprova: o recurso **vazou** para o caminho sempre-ligado. A medida é o `used` que o próprio PlatformIO reporta (flash = `.text`+`.rodata` do ELF; RAM = `.data`+`.bss` estáticos). O que importa aqui é a **diferença** -entre base e variante, não o número absoluto. +entre base e variante, não o número absoluto. Desde 2026-10-22 o piso compara o +flash **sem o enchimento** que o linker põe antes do banco do BTstack, lido do +mapa do linker: contado, ele mexia a economia de toda chave do Alpha e do Air a +cada byte de `.rodata` da imagem ([O enchimento do banco do BTstack](#o-enchimento-do-banco-do-btstack-e-os-pisos-de-2026-10-22)). ## O que cada recurso devolve (2026-09-26) @@ -244,6 +249,114 @@ gráfico" agora é baixado fora delas: com a cerca em volta dos dois, um evento perdido deixaria o sinal de pé e pararia todos os eventos da interface. Com a chave ligada, as quatro imagens com painel saem byte a byte idênticas. +### O enchimento do banco do BTstack e os pisos de 2026-10-22 + +O job noturno (`savings.yml`) reprovou todas as noites de 2026-10-02 a +2026-10-22, 21 seguidas, sem que PR nenhum visse: ele não roda em PR. Na +última, cinco recursos estavam abaixo do piso: `tel_tls` (−10 096 B), +`bluetooth` (−2 776 B), `cli_full` (−80 B de flash e −2 048 B de RAM), +`web_export_api` (−40 B) e `sound_buzzer` (−24 B). Nenhum era vazamento: o do +`bluetooth` era só o instrumento, e os outros, mudanças de verdade que ninguém +registrou no piso, a do `tel_tls` com o instrumento por cima. + +**O instrumento.** O framework guarda o banco do BTstack em 8 KB de `.rodata` +alinhados a um setor de 4 KB (`__bluetooth_tlv`, +`cores/rp2040/sdkoverride/btstack_flash_bank.cpp`), em toda imagem com +Bluetooth: entre as bases, o Alpha e o Air. O banco é a última coisa da +`.rodata`, e o linker enche até 4 095 B na frente dele; o `used` conta esse +enchimento. Numa base com o banco, um byte a mais de `.rodata` em qualquer +lugar da imagem some no enchimento, e na variante sem Bluetooth ele conta: a +economia do `bluetooth` caía a cada mudança e subiria 4 096 B de uma vez +quando o enchimento acabasse. No Air os dois lados têm o banco, e a economia +pulava 4 096 B para um lado ou para o outro. É o mesmo degrau que as notas de +2026-09-30 acima viram ("o corte cabe no degrau"). Medido aqui, com o mesmo +toolchain e o mesmo framework nos quatro commits. O framework daqui já tem os +patches de outubro: em `2721eb2e`, o único dos três primeiros que o job mediu +(em 01/10), o `used` do `bluetooth` sai 8 B longe do número dele, e o do +`tel_tls`, igual: + +| | `2721eb2e` (01/10) | `2690c0a2` (#220) | `6109ddb3` (#221) | `25e169df` (22/10) | +|---|---:|---:|---:|---:| +| `bluetooth`, `used` | 140 268 | 139 572 | 138 892 | 137 356 | +| `bluetooth`, sem o enchimento | 136 792 | 136 816 | 136 808 | 136 808 | +| enchimento na base Alpha | 3 476 | 2 756 | 2 084 | 548 | +| `tel_tls`, `used` | 67 936 | 72 032 | 57 880 | 57 840 | +| `tel_tls`, sem o enchimento | 69 968 | 69 976 | 58 120 | 58 080 | +| enchimento na base Air / na variante | 444 / 2 476 | 4 084 / 2 028 | 3 412 / 3 652 | 2 300 / 2 540 | + +Os símbolos que a chave do Bluetooth tira somam 132 474 B em `2721eb2e` e +132 482 B em `25e169df`: o código dela mudou 8 B, e o `used` acusava −2 912 B. +Os que a do `tel_tls` tira são os mesmos 69 879 B em `2721eb2e` e em +`2690c0a2`, e o `used` acusava +4 096 B. + +**O conserto.** O piso (`--check`, `--update`) compara o `used` menos o +`*fill*` que termina no endereço do banco, lido do mapa que a build pede ao +linker (`-Wl,-Map`, por `PLATFORMIO_BUILD_FLAGS`). Com o mapa, as cinco bases +saem byte a byte as mesmas imagens: sha256 do `firmware.bin` igual com e sem o +flag (`pico_w_test` `b4074dd0`, `pico_w_release` `e971dfe8`, `pico_w_asserts` +`abd422b5`, `pico_w_alpha` `93001b45`, `pico_w_air` `e5935de2`). Uma build que +não deixa o mapa, que roda sem zopfli ou cujo mapa o leitor não entende é +recusada, não medida; também a cujo mapa tem o objeto do banco +(`btstack_flash_bank.cpp.o`) sem o banco com o nome dele, que é o que um +framework que o renomeasse deixaria. A `--matrix` +segue com o `used` inteiro, porque ali o que importa é caber no slot, e o +enchimento ocupa o slot. + +**As quedas de verdade**, cada uma com a causa, medidas sem o enchimento: + +- `tel_tls`, −11 856 B, no #221 (`016dacab`, o stage e o apply só aceitam + imagem assinada). A imagem confere a assinatura ECDSA P-256 de toda + atualização com o verificador do BearSSL, e ele passou a ser sempre-ligado: + 52 símbolos, 12 884 B, que a chave tirava em `2721eb2e` e não tira em + `11d38223` (`mul_f256`, `square_f256`, `p256_*`, `br_ecdsa_i31_vrfy_raw`, + `br_i31_*`). Desligar o TLS da telemetria não os tira mais. +- `cli_full`, RAM 0 → −2 048 B, no #223 (`3335d115`). A tela de Licença do + painel passou a ler o `@WEBDICT` por um pedaço na pilha, sem o `lazyRead( )`. + O `help` da CLI completa nunca lê o pacote, então a imagem de teste perdeu o + `lazyRead( )` e o buffer estático de 2 048 B dele (`_lazyReadBuf`, + `DisplayManager_LangParser.cpp`). A imagem sem a CLI completa guarda os dois + para o `@HELP` do console de emergência. Desligar a CLI completa custa agora + 2 048 B de RAM, e esses 2 048 B são só do `help` de emergência, em toda imagem + que o tem. +- `cli_full`, flash −80 B. O #214 (`ab1e5b3b`) pôs o `set time` em toda + imagem (`case CMD_SET_TIME: /* every image */`), para o aparelho sem rede que + pede a hora: `cmdHandleSetTime( )` e `parse_3ints( )`, 412 B, saíram do que a + chave tira. O que ela tira cresceu em volta (`executeCommand( )` +396 B, + `cmdHandleSensorField( )` +124 B, `renderMetrics( )` +88 B), e a `.rodata` + dela encolheu 128 B. +- `sound_buzzer`, −24 B. O #234 (`f24e1b71`) moveu o corpo de + `SoundManager::fillConfig( )` (218 → 22 B) para `soundStateToConfig( )` + (186 B), que o ensaio a seco do `commit_all` também usa + (`SoundManager::stateToConfig( )`), e o que a chave tira do `commit_all` saiu + 12 B menor. +- `web_export_api`, −40 B. O compilador passou a emitir fora de linha o + caminho de crescimento do `std::vector` (`_M_check_len( )`, 60 B) no + `/api/history_days`, que toda imagem tem; em `2721eb2e` a variante sem a + exportação não tinha esse caminho fora de linha. +- `bluetooth`: só o enchimento (acima). + +**Os pisos gravados em 2026-10-22** (`--update` sobre `25e169df` com a medida +nova; os que subiram também sobem o piso, que só aceita subir): + +| Recurso | Base | Flash, piso anterior | Flash, piso novo | RAM, anterior | RAM, nova | Causa | +|---|---|---:|---:|---:|---:|---| +| `bluetooth` | `pico_w_alpha` | 140 132 | 136 808 | 19 348 | 19 348 | o enchimento (548 B na base) fica fora | +| `tel_tls` | `pico_w_air` | 67 936 | 58 080 | 16 | 16 | #221; o enchimento fica fora | +| `cli_full` | `pico_w_test` | 48 232 | 48 152 | 0 | −2 048 | #214 (flash) e #223 (RAM) | +| `web_https` | `pico_w_release` | 24 520 | 24 568 | 24 | 16 | RAM dentro da tolerância de 64 B | +| `web_history` | `pico_w_air` | 21 768 | 23 872 | 0 | 0 | o enchimento fica fora | +| `tft_graph` | `pico_w_release` | 20 312 | 20 432 | 2 236 | 2 232 | RAM dentro da tolerância de 64 B | +| `sensor_bme280` | `pico_w_release` | 17 724 | 17 804 | 220 | 220 | | +| `mdns` | `pico_w_release` | 16 568 | 16 632 | 168 | 168 | | +| `web_export_api` | `pico_w_release` | 12 336 | 12 296 | 2 052 | 2 052 | o `vector` (acima) | +| `tel_mqtt` | `pico_w_air` | 7 704 | 9 344 | 0 | 0 | o enchimento fica fora | +| `sensor_ds18b20` | `pico_w_release` | 5 432 | 5 520 | 64 | 64 | | +| `sound_buzzer` | `pico_w_release` | 5 008 | 4 984 | 0 | 0 | #234 | +| `web_metrics` | `pico_w_release` | 4 904 | 4 968 | 0 | 0 | | +| `sensor_dht22` | `pico_w_release` | 3 400 | 3 456 | 0 | 0 | | +| `concurrency_asserts` | `pico_w_asserts` | 2 176 | 2 240 | 0 | 0 | | +| `syslog` | `pico_w_release` | 1 760 | 1 768 | 0 | 0 | | + ## O que ainda não tem chave — o rastreio de 2026-09-30 A tabela acima mede o que já se desliga. Esta seção olha o contrário: **o que @@ -300,12 +413,18 @@ python3 tools/flash_compose.py --env pico_w_air --objects # o rastreio: flash O `--check` reconstrói base e variante de cada recurso do ledger e falha se a economia de flash ficou **abaixo** do piso (a RAM entra com tolerância de 64 B, -ruído de alinhamento do linker). É um ratchet, como o orçamento de flash e a +ruído de alinhamento do linker). O flash dos dois lados é o `used` sem o +enchimento antes do banco do BTstack, lido do mapa que cada build pede ao +linker; a build sem o mapa ou sem zopfli é recusada. É um ratchet, como o orçamento de flash e a marca d'água do sprawl: a economia só sobe; abaixá-la exige `--update` e justificativa no commit. -**Custo em CI.** O portão constrói ~13 imagens (base + variante por recurso), o -que não cabe no job `gates` (livre de framework). Pertence a um job próprio, -agendado (noturno) ou disparado à mão — ver `.github/workflows/savings.yml`. +**Custo em CI.** O portão constrói 21 imagens (as 5 bases e uma variante por +recurso do piso, em 2026-10-22), o que não cabe no job `gates` (livre de +framework). Pertence a um job próprio, agendado (noturno) ou disparado à mão — +ver `.github/workflows/savings.yml` —, e por isso **nenhum PR vê o vermelho +dele**: confira com `gh run list --workflow "SIMUT savings watermark"`. O que o +`gates` roda em todo PR é o `tools/test_measure_savings.py`, a conta que o piso +faz. Um `--check --only ` é barato e serve para conferir um recurso após mexer no que ele guarda. diff --git a/tools/README.md b/tools/README.md index 70874f75..af288b14 100644 --- a/tools/README.md +++ b/tools/README.md @@ -43,7 +43,8 @@ or a release. | `check_feature_sprawl.py` | A marca d'água do emaranhamento (P2 do modelo de recursos): conta os sítios `#if SIMUT_` por macro e por arquivo em `src/` (359 em 13/10, 37 macros; o `simut_config.h`, o `FeatureBits.h` e o `net/NetCaps.h` ficam de fora porque só definem macros) e os prende ao teto de `tools/feature_sprawl.json`. Um sítio a mais num arquivo compartilhado reprova; extrair um recurso para a sua unidade de tradução baixa a marca. Mesmo mecanismo do orçamento de flash. Sabe reprovar: controle conferido em 26/09. | 2026-09-26 | | `check_net_boundary.py` | The network is one driver per image, and the rest of `src/` must not know which, so a board added later touches `src/net/` and the manifest and nothing else. Outside `src/net/`: no identity macro (`SIMUT_NET_` for each `[net.]` of `features.toml`, and the ENC28J60's `SIMUT_ENC_*` and `SIMUT_PINS_ENC*`; `simut_config.h` holds the pins and is let off this rule alone), no call on the radio (`WiFi.`, `cyw43_`, `pico/cyw43_arch.h`), no driver library included (`...lwIP.h`, `LwipIntfDev.h`), and a file that tests `SIMUT_NET_WIFI` or `SIMUT_NET_LAN` includes `simut_config.h` or `net/NetCaps.h` itself, before the first use. Comments and the contents of strings and raw strings are stripped by a character state machine first (`WiFiClient` and the other classes are fine). What has to break a rule is listed in `tools/net_boundary.json` with its reason; nothing is, since the radio's state machine moved from `NetworkManager.cpp` into its own unit, `src/net/NetworkManager_Cyw43.cpp`, and the Air's power-down of the radio into `net::powerDownForSleep( )` (2026-10-13). The counts only go down, like the sprawl watermark: more fails (`grew`), fewer fails (`lower the allowance`), an allowed file with none is `stale`. `--report` lists the findings and the counts, `--update` lowers the counts and adds no file. Born on 2026-10-13, after `SyslogManager.cpp`, `ota/orchestrator.cpp` and the boot each had an `#if` on the chip of their own. | 2026-10-13 | | `test_net_boundary.py` | The gate bites. A violation of each rule put into a clean tree, and appended to a copy of the real `src/`, is exactly one finding of that rule, on its line; the same text in a comment, a string or a raw string is none, and so are `WiFiClient`, `WiFiUDP`, `WiFiServer` and `WiFiClientSecure`; the lexing traps (`'"'`, `1'000`, `"//"`, a comment continued by a backslash, a raw string over lines) keep the line numbers; an allowance over, under, stale, without a reason, with a count that is not a number or under a rule that does not exist fails; `--update` lowers a count and never adds a file; the identity macros come from the manifest; the real tree passes, its allowed counts are tight, and the gate never writes under `src/` (sha256 before and after, `--update` included). Job `gates`. Mutation-tested on 13/10: 37 of 37 one-line changes to the gate are caught (a rule's pattern or its exemption removed; the stripper's comments, backslash continuation, digit separators, raw strings, escapes or include names broken; `grew`, `lower the allowance`, `stale`, the reason or the rule name no longer checked; `--update` made to add files or not to lower; the exit status, the trailer or `--report` changed; a write under `src/`; a tree with no `src/` or no `[net.]` let through). | 2026-10-13 | -| `measure_savings.py` | Quanto de flash e RAM cada recurso devolve quando desligado: constrói o perfil-base e a variante com só aquele recurso off (derivada de `features.toml`, então `off_exclude` tira mesmo o `.cpp`) e mede a diferença. `--update` grava o piso em `tools/feature_savings.json`; `--check` reprova se a economia caiu (recurso vazou para o sempre-ligado) — ratchet ao contrário do orçamento. Caro (~13 imagens): roda em `savings.yml` (noturno), não no job `gates`. Sabe reprovar: controles +/− conferidos em 26/09. Achados em `docs/analysis/ECONOMIA_DE_RECURSOS.md`. `--matrix` mede, para cada produto publicado, cada chave invertida a partir dele, depois compila as combinações de conferência (`CHECKS`), e grava `tools/feature_costs.json` — a estimativa do configurador, porque custo medido numa base não vale noutra (o buzzer devolve 5.016 B no SIMUT e 8.224 B no Alpha; o Bluetooth devolve 140 KB no Alpha e nem cabe no SIMUT). Inversão proibida por regra não compila e fica anotada; a que pede uma placa que o chip não tem, ou que não cabe nos blocos de PIO ou nos pinos (`gen_features.blockers( )`, desde 13/10), fica `unsupported`, sem imagem a medir; a que estoura o flash registra o excesso que o linker mede; a que sai idêntica à base, byte a byte, fica `same` (a chave não tem efeito naquele produto); a que não compila faz a ferramenta sair com erro, porque é defeito de chaveamento e não custo (achou dois no Air em 26/09, consertados no #185). Medida feita sem zopfli é recusada, não gravada. `--checks` refaz só as conferências: a página compara cada uma com a soma dela e usa o maior erro como margem. `--matrix --only ` (2026-09-30) mede só as inversões daquela chave e as grava no arquivo de custos que existe — depois de reconstruir as bases dos produtos publicados e recusar se alguma não bate mais com o que o arquivo guarda; vale para uma chave nova que não muda as imagens publicadas, e custa 8 builds em vez das 81 da matriz inteira (quatro produtos desde 05/10, com o Pico 2 W). Desde 13/10 a matriz troca também a rede de cada produto por cada outro driver (`flips.net=`; o ambiente de medição se chama `_cost__net_`, porque o PlatformIO não aceita `=` no nome), e `--only net` mede só essas trocas. | 2026-10-13 | +| `measure_savings.py` | Quanto de flash e RAM cada recurso devolve quando desligado: constrói o perfil-base e a variante com só aquele recurso off (derivada de `features.toml`, então `off_exclude` tira mesmo o `.cpp`) e mede a diferença. `--update` grava o piso em `tools/feature_savings.json`; `--check` reprova se a economia caiu (recurso vazou para o sempre-ligado) — ratchet ao contrário do orçamento. Desde 22/10 o piso compara o `used` sem o enchimento que o linker põe antes do banco do BTstack (`__bluetooth_tlv`, 8 KB alinhados a um setor, a última coisa da `.rodata` do Alpha e do Air), lido do mapa do linker que a build pede (`-Wl,-Map` por `PLATFORMIO_BUILD_FLAGS`); recusa a build sem o mapa ou sem zopfli. A `--matrix` segue com o `used` inteiro, porque ali importa caber no slot. Caro (21 imagens): roda em `savings.yml` (noturno), não no job `gates`, e nenhum PR vê o vermelho dele. Sabe reprovar: controles +/− conferidos em 26/09. Achados em `docs/analysis/ECONOMIA_DE_RECURSOS.md`. `--matrix` mede, para cada produto publicado, cada chave invertida a partir dele, depois compila as combinações de conferência (`CHECKS`), e grava `tools/feature_costs.json` — a estimativa do configurador, porque custo medido numa base não vale noutra (o buzzer devolve 5.016 B no SIMUT e 8.224 B no Alpha; o Bluetooth devolve 140 KB no Alpha e nem cabe no SIMUT). Inversão proibida por regra não compila e fica anotada; a que pede uma placa que o chip não tem, ou que não cabe nos blocos de PIO ou nos pinos (`gen_features.blockers( )`, desde 13/10), fica `unsupported`, sem imagem a medir; a que estoura o flash registra o excesso que o linker mede; a que sai idêntica à base, byte a byte, fica `same` (a chave não tem efeito naquele produto); a que não compila faz a ferramenta sair com erro, porque é defeito de chaveamento e não custo (achou dois no Air em 26/09, consertados no #185). Medida feita sem zopfli é recusada, não gravada. `--checks` refaz só as conferências: a página compara cada uma com a soma dela e usa o maior erro como margem. `--matrix --only ` (2026-09-30) mede só as inversões daquela chave e as grava no arquivo de custos que existe — depois de reconstruir as bases dos produtos publicados e recusar se alguma não bate mais com o que o arquivo guarda; vale para uma chave nova que não muda as imagens publicadas, e custa 8 builds em vez das 81 da matriz inteira (quatro produtos desde 05/10, com o Pico 2 W). Desde 13/10 a matriz troca também a rede de cada produto por cada outro driver (`flips.net=`; o ambiente de medição se chama `_cost__net_`, porque o PlatformIO não aceita `=` no nome), e `--only net` mede só essas trocas. | 2026-10-22 | +| `test_measure_savings.py` | The number the savings floor compares. The fill before the BTstack bank read from a real map (548 B on `pico_w_alpha`, main `25e169df`); 0 when the bank sits right after the section before it, when the image has no bank, and when the bank is only under "Discarded input sections"; the zero-size entries a map lists are skipped; a fill further up is not the bank's; a map with no memory map, or a fill that does not reach the bank, is refused rather than read as 0. A map with the bank's object (`btstack_flash_bank.cpp.o`) and no bank under the bank's name, as a framework that renamed it would leave, is refused too. The build asks for the map, and one that left no map, ran without zopfli, left a map the reader cannot follow or failed is refused. `measure( )` takes each side's fill off, and the temporary environments come out again. Job `gates`. Mutation-tested on 22/10: 10 of 10 one-line changes to the tool are caught, each by the case written for it (the reader's refusal also by the build's). The reader gives, on the twelve real maps of 2026-10-22 (alpha, Air, their variants), the fill each build printed. | 2026-10-22 | | `test_configurator_rules.py` | A página e o CI concordam sobre o que é proibido: roda `tools/configurator_cases.json` no validador Python (`gen_features.rule_violations`, o que a build aplica) e no JS (`docs/configurador/rules.js`, o que a página aplica, via `node`) e reprova se qualquer um se afastar da tabela ou do outro, ou se uma regra/aviso não tem caso que o acenda. Desde 13/10 a tabela diz também, em `blocks`, tudo o que impede a build de cada caso, e o `gen_features.blockers( )` e o `blockersOf( )` do `logic.js` têm de dar a mesma lista: as regras, o driver de rede que o chip recusa (`unsupported-net`) e o orçamento de PIO e de pinos; todo bloqueio de orçamento e toda recusa de driver do `model.json` precisam de um caso. Os casos trocam a rede com `"set": {"net": ""}`. Job `gates`. Sabe reprovar: `rules.js` sem o `!` e regra sem caso, controles conferidos em 26/09; a página sem a tabela de orçamento, os 5 casos do cabo com os dois sensores (13/10). | 2026-10-13 | | `build_custom.py` | Compila a build montada no configurador (`docs/configurador/`): recebe um perfil `{"v":1,"base":…,"set":{…}}` por `--profile` ou `SIMUT_PROFILE`, recusa o que não for exatamente esse formato (chave repetida, versão que não é o inteiro 1, produto ou chave fora do manifesto, valor de chave que não é booleano, `net` que não é um driver do manifesto) e reaplica tudo o que impede a build (`gen_features.blockers( )`: as regras do manifesto, o driver de rede que o chip recusa e, desde 13/10, o orçamento de PIO e de pinos do `pio_budget.py`), porque a página não é a autoridade (exit 2). Compila e deixa em `--out` o `.uf2` (USB), o `.bin` (OTA) e `build.json` com flags, tamanhos, sha256 e se o `.bin` cabe no teto de OTA; exit 3 é "não cabe no slot". Sem mudanças, a imagem sai idêntica byte a byte à do perfil publicado (conferido em 26/09). No Pico 2 W (05/10) são as duas imagens que a release publica: o `.bin` é a atualização marcada para teste (`firmware_ota.bin`), o `.uf2` é o de fábrica, com a tabela de partições, e os tetos são o slot (`check_flash_budget.ceilings( )`). `--validate-only` só confere. `--finish-signed `, que o job de assinatura roda, põe o `.bin` assinado no `build.json` e refaz dele o `.uf2` do Pico 2 W, como a release faz. É o que `.github/workflows/build-custom.yml` roda. | 2026-10-13 | | `test_build_custom.py` | A porta do `build_custom.py`: 54 perfis hostis têm de ser recusados antes de qualquer build (quebra de linha no nome do produto, chave repetida, `NaN`, `__proto__`, `display` no conjunto, a chave `lan` que saiu, um `net` que não é driver do manifesto ou que não é texto…), cada produto sem mudanças tem de compor o mesmo ambiente que o manifesto, as regras e o orçamento de PIO têm de sair com exit 2 pela linha de comando (a release do Pico W com o cabo, o Wire com o DS18B20, o Alpha e o Air com o cabo pelas regras do CYW43, o Pico 2 W com o cabo por falta de placa; o Wire com o DS18B20 no lugar do DHT22, o Wire com TFT e a release com o cabo sem o DS18B20 passam), e um perfil com `\n::warning::` embutido não pode injetar comando no log do Actions. Job `gates`. Sabe reprovar: sem o bloqueio de chave repetida e com a versão frouxa, reprova quatro casos; sem o escape do log, acusa o comando injetado (26/09); sem o orçamento, os dois casos `pio-budget` (13/10); aceitando qualquer texto em `net`, 12 falhas, e o driver desconhecido saía com exit 1 e um traceback em vez de 2 (13/10). | 2026-10-13 | diff --git a/tools/feature_savings.json b/tools/feature_savings.json index a2ffc17a..74600063 100644 --- a/tools/feature_savings.json +++ b/tools/feature_savings.json @@ -1,82 +1,82 @@ { "cli_full": { "base": "pico_w_test", - "flash_saved": 48232, - "ram_saved": 0 + "flash_saved": 48152, + "ram_saved": -2048 }, "web_https": { "base": "pico_w_release", - "flash_saved": 24520, - "ram_saved": 24 + "flash_saved": 24568, + "ram_saved": 16 }, "mdns": { "base": "pico_w_release", - "flash_saved": 16568, + "flash_saved": 16632, "ram_saved": 168 }, "concurrency_asserts": { "base": "pico_w_asserts", - "flash_saved": 2176, + "flash_saved": 2240, "ram_saved": 0 }, "bluetooth": { "base": "pico_w_alpha", - "flash_saved": 140132, + "flash_saved": 136808, "ram_saved": 19348 }, "sensor_ds18b20": { "base": "pico_w_release", - "flash_saved": 5432, + "flash_saved": 5520, "ram_saved": 64 }, "sensor_dht22": { "base": "pico_w_release", - "flash_saved": 3400, + "flash_saved": 3456, "ram_saved": 0 }, "sensor_bme280": { "base": "pico_w_release", - "flash_saved": 17724, + "flash_saved": 17804, "ram_saved": 220 }, "sound_buzzer": { "base": "pico_w_release", - "flash_saved": 5008, + "flash_saved": 4984, "ram_saved": 0 }, "tel_tls": { "base": "pico_w_air", - "flash_saved": 67936, + "flash_saved": 58080, "ram_saved": 16 }, "tel_mqtt": { "base": "pico_w_air", - "flash_saved": 7704, + "flash_saved": 9344, "ram_saved": 0 }, "web_history": { "base": "pico_w_air", - "flash_saved": 21768, + "flash_saved": 23872, "ram_saved": 0 }, "web_export_api": { "base": "pico_w_release", - "flash_saved": 12336, + "flash_saved": 12296, "ram_saved": 2052 }, "web_metrics": { "base": "pico_w_release", - "flash_saved": 4904, + "flash_saved": 4968, "ram_saved": 0 }, "syslog": { "base": "pico_w_release", - "flash_saved": 1760, + "flash_saved": 1768, "ram_saved": 0 }, "tft_graph": { "base": "pico_w_release", - "flash_saved": 20312, - "ram_saved": 2236 + "flash_saved": 20432, + "ram_saved": 2232 } } diff --git a/tools/measure_savings.py b/tools/measure_savings.py index 72126460..6c965415 100644 --- a/tools/measure_savings.py +++ b/tools/measure_savings.py @@ -43,7 +43,9 @@ Metrica: o `used` que o proprio PlatformIO reporta (ELF), nao o .bin — e o numero consistente entre base e variante, e o que importa aqui e a DIFERENCA. O flash e o numero travado (deterministico); a RAM entra com tolerancia (ruido de alinhamento -do linker) — ver RAM_TOL. +do linker) — ver RAM_TOL. O piso (--check, --update) mede o flash SEM o enchimento +que o linker poe antes do banco do BTstack, lido do mapa do linker — ver BANK_SECTION; +a --matrix mede o `used` inteiro, porque ali o que importa e caber no slot. @project SIMUT — Integrated Universal Monitoring and Telemetry System @license MIT License @@ -71,6 +73,77 @@ # RAM do recurso nao muda; o piso de RAM aceita essa folga. O flash nao tem folga. RAM_TOL = 64 +# O banco do BTstack: 8 KB de .rodata que o framework alinha a um setor de 4 KB +# (__bluetooth_tlv, cores/rp2040/sdkoverride/btstack_flash_bank.cpp), em toda imagem +# com Bluetooth — entre as bases, a pico_w_alpha e a pico_w_air. O linker enche ate +# 4.095 B na frente dele, e o `used` do PlatformIO conta esse enchimento: o banco e a +# ultima coisa da .rodata, entao a secao termina num setor. Medida com o enchimento, a +# economia de um recurso nessas bases andava com cada byte de .rodata da imagem inteira: +# em 2026-10-22 a do bluetooth tinha caido 2.912 B em tres semanas enquanto o codigo que +# a chave tira crescia 8 B, e a do tel_tls tinha subido 4.096 B entre duas arvores com +# os mesmos simbolos removidos (docs/analysis/ECONOMIA_DE_RECURSOS.md). O piso desconta +# o enchimento: `used` menos o `*fill*` que termina no endereco do banco, no mapa. +BANK_SECTION = ".rodata._ZL15__bluetooth_tlv" +# $BUILD_DIR e do SCons: o mapa sai ao lado do firmware.elf de cada ambiente. Muda o +# checksum do projeto, como os ambientes temporarios ja mudam (AGENTS.md). +MAP_FLAG = "-Wl,-Map,$BUILD_DIR/firmware.map" +# O objeto que define o banco: numa imagem com Bluetooth ele aparece no mapa (as funcoes +# pico_flash_bank_* e o banco); numa sem Bluetooth, em parte nenhuma (conferido 2026-10-22). +BANK_OBJECT = "btstack_flash_bank.cpp.o" +_MAP_ENTRY_RE = re.compile(r"^ (\S+)(?:\s+0x([0-9a-fA-F]+)\s+0x([0-9a-fA-F]+)(?:\s+(\S.*))?)?") +_MAP_ADDR_RE = re.compile(r"^\s+0x([0-9a-fA-F]+)\s+0x([0-9a-fA-F]+)\s+(\S.*)") + + +def bank_pad(map_text): + """Bytes de enchimento que o linker poe logo antes do banco do BTstack; 0 sem banco. + + So conta a parte "Linker script and memory map": numa imagem sem Bluetooth o banco + pode aparecer em "Discarded input sections", que nao ocupa flash. O enchimento e o + `*fill*` imediatamente anterior a entrada do banco (pulando as entradas de tamanho + zero, que o mapa tambem lista), e tem de terminar no endereco dele: um que nao + termina e mapa que este leitor nao entende, e vira erro, nao zero. Tambem vira erro + o mapa que tem o objeto do banco (BANK_OBJECT) e nao tem o banco com esse nome: um + framework que o renomeie faria a Alpha e o Air voltarem a contar o enchimento, calados. + As strings mescladas (.str1.1) dividem enderecos, entao o "fim da entrada anterior" + nao serve para calcular.""" + start = map_text.find("Linker script and memory map") + if start < 0: + raise ValueError("mapa do linker sem 'Linker script and memory map'") + lines = map_text[start:].splitlines() + seen = [] # (nome, endereco, tamanho) das entradas ate aqui + bank_object = False # alguma entrada veio do objeto do banco + i = 0 + while i < len(lines): + m = _MAP_ENTRY_RE.match(lines[i]) # uma entrada: um espaco e o nome + if not m: + i += 1 + continue + name, addr, size, obj = m.group(1), m.group(2), m.group(3), m.group(4) + if addr is None and i + 1 < len(lines): + m2 = _MAP_ADDR_RE.match(lines[i + 1]) + if m2: + addr, size, obj = m2.group(1), m2.group(2), m2.group(3) + i += 1 + if obj and BANK_OBJECT in obj: + bank_object = True + if addr is not None: + if name == BANK_SECTION: + bank = int(addr, 16) + before = [e for e in seen if e[2] != 0] + if not before or before[-1][0] != "*fill*": + return 0 + fill = before[-1] + if fill[1] + fill[2] != bank: + raise ValueError(f"*fill* em {fill[1]:#x}+{fill[2]:#x} nao termina " + f"no banco do BTstack ({bank:#x})") + return fill[2] + seen.append((name, int(addr, 16), int(size, 16))) + i += 1 + if bank_object: + raise ValueError(f"o mapa tem o {BANK_OBJECT} e nao tem {BANK_SECTION}: " + "o banco do BTstack mudou de nome?") + return 0 + # Interruptores do manifesto: (recurso, perfil-base onde esta LIGADO). Desligar = # virar a chave no dicionario do perfil e recompor — compose() cuida de flag, # exclusao de fonte e lib_ignore. @@ -168,12 +241,19 @@ def parse_sizes(text): return out -def pio_run(env): +def pio_run(env, linker_map=False): + """`linker_map`: pede o mapa do linker (MAP_FLAG) somado a qualquer + PLATFORMIO_BUILD_FLAGS que ja esteja no ambiente.""" pio = os.path.expanduser("~/.platformio/penv/bin/pio") if not os.path.exists(pio): pio = "pio" + run_env = None + if linker_map: + run_env = dict(os.environ) + run_env["PLATFORMIO_BUILD_FLAGS"] = \ + (run_env.get("PLATFORMIO_BUILD_FLAGS", "") + " " + MAP_FLAG).strip() return subprocess.run([pio, "run", "-e", env], - cwd=ROOT, capture_output=True, text=True) + cwd=ROOT, capture_output=True, text=True, env=run_env) # build_webui_gz.py cai para gzip -9 quando o python do PlatformIO nao tem o @@ -212,17 +292,38 @@ def pio_build_full(env, echo=False): def pio_build(env): - """Constroi um env e devolve {'flash':N,'ram':M}, ou None se falhou.""" - r = pio_run(env) + """Constroi um env com o mapa do linker e devolve {'flash':N,'ram':M,'pad':P}, + ou None se falhou. P e o enchimento antes do banco do BTstack (bank_pad). + Recusa, como a pio_build_full, a build sem zopfli; e recusa a build sem mapa, + porque um enchimento que nao foi lido nao e um enchimento zero.""" + r = pio_run(env, linker_map=True) sizes = parse_sizes(r.stdout) if r.returncode != 0 or sizes is None: sys.stderr.write(f"[falha ao construir {env}]\n") tail = "\n".join(r.stdout.splitlines()[-8:] + r.stderr.splitlines()[-8:]) sys.stderr.write(tail + "\n") return None + if NO_ZOPFLI in r.stdout: + sys.stderr.write(f"[{env}: zopfli ausente no python do PlatformIO: medida recusada]\n") + return None + map_path = os.path.join(ROOT, ".pio", "build", env, "firmware.map") + if not os.path.exists(map_path): + sys.stderr.write(f"[{env}: a build nao deixou {map_path}: medida recusada]\n") + return None + with open(map_path, encoding="utf-8", errors="replace") as fh: + try: + sizes["pad"] = bank_pad(fh.read()) + except ValueError as e: + sys.stderr.write(f"[{env}: {e}: medida recusada]\n") + return None return sizes +def gate_flash(sizes): + """O flash que o piso compara: o `used` sem o enchimento do banco do BTstack.""" + return sizes["flash"] - sizes["pad"] + + class TempEnvs: """Anexa envs de medicao ao profiles.ini gerado e SEMPRE os remove ao sair.""" def __init__(self, blocks): @@ -263,10 +364,12 @@ def measure(features): continue results[f] = { "base": base, - "flash_saved": bs["flash"] - vs["flash"], + "flash_saved": gate_flash(bs) - gate_flash(vs), "ram_saved": bs["ram"] - vs["ram"], "flash_base": bs["flash"], "flash_off": vs["flash"], + "pad_base": bs["pad"], + "pad_off": vs["pad"], } return results @@ -285,6 +388,9 @@ def print_table(results): if r.get("error"): print(f"{f:<20} {r['base']:<16} {'ERRO':>10} {'':>8} {note}") continue + if r.get("pad_base") or r.get("pad_off"): + note = (note + "; " if note else "") + \ + f"sem o enchimento do banco BT (base {r['pad_base']} B, off {r['pad_off']} B)" print(f"{f:<20} {r['base']:<16} {r['flash_saved']:>10} " f"{r['ram_saved']:>8} {note}") skipped = [f for f in COUPLED if f not in results] diff --git a/tools/test_measure_savings.py b/tools/test_measure_savings.py new file mode 100644 index 00000000..da901326 --- /dev/null +++ b/tools/test_measure_savings.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Tests the flash number the savings floor compares (tools/measure_savings.py --check, --update). + +Run: python3 tools/test_measure_savings.py (CI runs it in the gates job; the floor itself runs nightly) + +The BTstack flash bank (__bluetooth_tlv, 8 KB aligned to a 4 KB sector) is the last thing in .rodata of +every image with Bluetooth, and PlatformIO's `used` counts the fill the linker puts in front of it. The +floor measures past that fill, read from the linker map. Measured with it, the alpha's Bluetooth saving fell +2,912 B in three weeks while the code its switch removes grew 8 B, and the Air's tel_tls saving rose +4,096 B between two trees that removed the same symbols (2026-10-22). So: + + * bank_pad( ) reads the fill from a real map (pico_w_alpha on main 25e169df: 0x224, 548 B); gives 0 + when the bank sits right after the section before it, when the image has no bank, and when the bank is + only under "Discarded input sections"; skips the zero-size entries a map lists; does not take a fill + from further up; and refuses, rather than answer 0, a map it cannot read and a map that has the bank's + object (btstack_flash_bank.cpp.o) with no bank under the bank's name, as a framework that renamed it + would leave. + * pio_build( ) asks for the map, reads the fill, and refuses a build that left no map, ran without + zopfli or left a map it cannot follow: a fill nobody read is not a fill of zero. + * measure( ) takes each side's fill off: the saving is (used - fill) of the base minus (used - fill) of + the variant. +""" +import os +import sys +import tempfile +import types + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import measure_savings as ms # noqa: E402 + +FAILS = [] +RAN = [] + + +def check(name, cond, detail=""): + RAN.append(name) + tag = "ok " if cond else "FAIL" + print(f"[{tag}] {name}" + (f" — {detail}" if detail and not cond else "")) + if not cond: + FAILS.append(name) + + +HEADER = "Linker script and memory map\n\nLOAD /x/crt0.o\n.rodata 0x10086000 0x6a000\n" +# The end of .rodata of pico_w_alpha on main 25e169df, as the linker wrote it (-Wl,-Map). The three +# SPI sections share an address: merged strings, so "the end of the entry before" is not a rule. +REAL_TAIL = """\ + .rodata.btstack_link_key_db_tlv + 0x100edd64 0x24 /x/liblwip-bt.a(btstack_link_key_db_tlv.c.o) + .rodata._ZN14SPIClassRP20405setRXEh.str1.1 + 0x100edd88 0x67 .pio/build/pico_w_alpha/lib530/libSPI.a(SPI.cpp.o) + .rodata._ZN14SPIClassRP20405beginEb.str1.1 + 0x100edd88 0x23 .pio/build/pico_w_alpha/lib530/libSPI.a(SPI.cpp.o) + .rodata._ZTV14SPIClassRP2040 + 0x100edd88 0x40 .pio/build/pico_w_alpha/lib530/libSPI.a(SPI.cpp.o) + 0x100edd88 _ZTV14SPIClassRP2040 + .rodata._ZL28pico_flash_bank_instance_obj + 0x100eddc8 0x14 .pio/build/pico_w_alpha/libFrameworkArduino.a(btstack_flash_bank.cpp.o) + *fill* 0x100edddc 0x224 + .rodata._ZL15__bluetooth_tlv + 0x100ee000 0x2000 .pio/build/pico_w_alpha/libFrameworkArduino.a(btstack_flash_bank.cpp.o) + 0x100f0000 . = ALIGN (0x4) + *(SORT_BY_ALIGNMENT(SORT_BY_NAME(.flashdata*))) + 0x100f0000 . = ALIGN (0x4) +""" +BANK = (" .rodata._ZL15__bluetooth_tlv\n" + " 0x100ee000 0x2000 /x/libFrameworkArduino.a(btstack_flash_bank.cpp.o)\n") +DISCARDED_BANK = ("Discarded input sections\n\n" + " .text 0x00000000 0x0 /x/crt0.o\n" + " .rodata._ZL15__bluetooth_tlv\n" + " 0x00000000 0x2000 /x/libFrameworkArduino.a(btstack_flash_bank.cpp.o)\n\n") +SIZES_OUT = ("RAM: [===== ] 47.9% (used 125680 bytes from 262144 bytes)\n" + "Flash: [========= ] 93.5% (used 976236 bytes from 1044480 bytes)\n") + + +def pad(text): + """bank_pad( ), with a refusal as a value: a case that should give a number and raises fails as a case.""" + try: + return ms.bank_pad(text) + except ValueError as e: + return f"refused ({e})" + + +def pad_raises(text): + return str(pad(text)).startswith("refused") + + +def bank_pad_cases(): + real = HEADER + REAL_TAIL + check("real map: the fill before the bank is 548 B", pad(real) == 548, pad(real)) + flush = HEADER + (" .rodata.a\n 0x100edc00 0x400 /x/a.o\n") + BANK + check("bank right after the section before it: 0", pad(flush) == 0, pad(flush)) + check("image with no bank: 0", pad(HEADER + " .rodata.a\n 0x100edc00" + " 0x400 /x/a.o\n") == 0) + check("bank only in Discarded input sections: 0", + pad(DISCARDED_BANK + HEADER + " .rodata.a\n 0x100edc00 0x400 /x/a.o\n") == 0) + both = DISCARDED_BANK + real + check("a discarded bank above the map is not the bank: 548", pad(both) == 548, pad(both)) + further_up = HEADER + (" *fill* 0x100edbf0 0x10 \n" + " .rodata.a\n 0x100edc00 0x400 /x/a.o\n") + BANK + check("a fill further up is not the bank's: 0", pad(further_up) == 0, pad(further_up)) + zero = HEADER + (" .rodata.a\n 0x100edc00 0x100 /x/a.o\n" + " *fill* 0x100edd00 0x300 \n" + " .rodata.empty 0x100ee000 0x0 /x/b.o\n") + BANK + check("zero-size entries between the fill and the bank are skipped: 768", + pad(zero) == 0x300, pad(zero)) + short = HEADER + (" .rodata.a\n 0x100edc00 0x100 /x/a.o\n" + " *fill* 0x100edd00 0x200 \n") + BANK + check("a fill that does not reach the bank is refused, not 0", pad_raises(short)) + check("a map with no memory map is refused, not 0", pad_raises(REAL_TAIL)) + renamed = HEADER + (" .text._ZL24pico_flash_bank_get_sizePv\n" + " 0x10012340 0x6 /x/libFrameworkArduino.a(btstack_flash_bank.cpp.o)\n" + " *fill* 0x100edddc 0x224 \n" + " .rodata._ZL15__bluetooth_bank\n" + " 0x100ee000 0x2000 /x/libFrameworkArduino.a(btstack_flash_bank.cpp.o)\n") + check("the bank's object with no bank under its name is refused, not 0", pad_raises(renamed)) + + +class FakeBuild: + """Stands in for `pio run`: records how it was asked and writes the map it is given.""" + + def __init__(self, root, stdout=SIZES_OUT, returncode=0, map_text=None): + self.root, self.stdout, self.rc, self.map_text = root, stdout, returncode, map_text + self.calls = [] + + def __call__(self, env, linker_map=False): + self.calls.append((env, linker_map)) + if self.map_text is not None: + d = os.path.join(self.root, ".pio", "build", env) + os.makedirs(d, exist_ok=True) + with open(os.path.join(d, "firmware.map"), "w") as fh: + fh.write(self.map_text) + return types.SimpleNamespace(returncode=self.rc, stdout=self.stdout, stderr="") + + +def pio_build_cases(): + saved = ms.ROOT, ms.pio_run + try: + with tempfile.TemporaryDirectory() as root: + ms.ROOT = root + ms.pio_run = fake = FakeBuild(root, map_text=HEADER + REAL_TAIL) + got = ms.pio_build("pico_w_alpha") + check("pio_build asks for the linker map", fake.calls == [("pico_w_alpha", True)], fake.calls) + check("pio_build reads used, RAM and the fill", + got == {"flash": 976236, "ram": 125680, "pad": 548}, got) + with tempfile.TemporaryDirectory() as root: + ms.ROOT = root + ms.pio_run = FakeBuild(root, map_text=None) + check("a build that left no map is refused", ms.pio_build("pico_w_alpha") is None) + with tempfile.TemporaryDirectory() as root: + ms.ROOT = root + ms.pio_run = FakeBuild(root, map_text=HEADER + (" .rodata.a\n 0x100edc00 0x100 /x/a.o\n" + " *fill* 0x100edd00 0x200 \n") + BANK) + check("a build whose map this reader cannot follow is refused", ms.pio_build("pico_w_alpha") is None) + with tempfile.TemporaryDirectory() as root: + ms.ROOT = root + ms.pio_run = FakeBuild(root, stdout=ms.NO_ZOPFLI + "\n" + SIZES_OUT, map_text=HEADER + REAL_TAIL) + check("a build without zopfli is refused", ms.pio_build("pico_w_alpha") is None) + with tempfile.TemporaryDirectory() as root: + ms.ROOT = root + ms.pio_run = FakeBuild(root, returncode=1, map_text=HEADER + REAL_TAIL) + check("a failed build is refused", ms.pio_build("pico_w_alpha") is None) + finally: + ms.ROOT, ms.pio_run = saved + + +def pio_run_cases(): + seen = {} + + def fake_run(cmd, **kw): + seen.update(kw) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + + saved_run, saved_flags = ms.subprocess.run, os.environ.get("PLATFORMIO_BUILD_FLAGS") + try: + ms.subprocess.run = fake_run + os.environ["PLATFORMIO_BUILD_FLAGS"] = "-DX=1" + ms.pio_run("pico_w_alpha", linker_map=True) + flags = (seen.get("env") or {}).get("PLATFORMIO_BUILD_FLAGS", "") + check("the map flag reaches the build, after the flags already set", + flags == "-DX=1 " + ms.MAP_FLAG, flags) + seen.clear() + ms.pio_run("pico_w_alpha") + check("without linker_map the build inherits the environment untouched", seen.get("env") is None) + finally: + ms.subprocess.run = saved_run + if saved_flags is None: + os.environ.pop("PLATFORMIO_BUILD_FLAGS", None) + else: + os.environ["PLATFORMIO_BUILD_FLAGS"] = saved_flags + + +def measure_cases(): + """measure( ) with the builds faked, and the temporary environments written to a scratch file.""" + sizes = {"pico_w_alpha": {"flash": 976236, "ram": 125680, "pad": 548}, + "_sav_bluetooth_off": {"flash": 838880, "ram": 106332, "pad": 0}} + saved = ms.PROFILES_INI, ms.pio_build + with tempfile.NamedTemporaryFile("w", suffix=".ini", delete=False) as fh: + fh.write("; scratch\n") + try: + ms.PROFILES_INI = fh.name + ms.pio_build = lambda env: dict(sizes[env]) + r = ms.measure(["bluetooth"])["bluetooth"] + check("the saving takes each side's fill off: (976236-548) - (838880-0)", + r["flash_saved"] == 976236 - 548 - 838880, r) + check("the raw numbers and both fills are kept for the table", + (r["flash_base"], r["flash_off"], r["pad_base"], r["pad_off"]) == (976236, 838880, 548, 0), r) + with open(fh.name) as f2: + check("the temporary environments are taken out again", f2.read() == "; scratch\n") + finally: + ms.PROFILES_INI, ms.pio_build = saved + os.unlink(fh.name) + + +bank_pad_cases() +pio_build_cases() +pio_run_cases() +measure_cases() +print(f"\n{len(RAN) - len(FAILS)}/{len(RAN)} ok") +if FAILS: + sys.exit(1)