From 745339aae0b20f325cdc1caf1aadb14f0c99b65c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Sun, 11 Oct 2026 10:27:22 +0200 Subject: [PATCH 01/20] Mount a microSD card at /sd, addressing ewowi's PR #134 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds microSD read/write as a chip-capability service on boards with flash to spare. The card mounts as a second folder in the existing File Manager tree, with no new HTTP routes and no new client UI: /api/dir and /api/file already dispatch on path, so the card gets upload, download, mkdir and recursive delete for free. A large transfer resumes a bounded chunk at a time across many ticks rather than blocking one, so a multi-minute upload never stalls rendering. Flash: esp32p4rev1-eth +150640 B (2177776 -> 2328416), esp32s3-zero +13152 B (2219120 -> 2232272). Account: src +1279/-51 lines | test +596/-1 lines | code findings 1620 -> 1629 (blocking call on the render path +6, complex function +6, large stack frame -3) | duplicated lines 3733 -> 3733 (+0) Core: - New SdCardModule (pins, format button, KiB usage gauge) and platform::sdMount/sdUnmount/sdFormat/sdCardUsed/sdCardTotal (uint64_t, so a >4 GB card is read correctly; the shared, size_t, flash-sized Progress gauge is fed KiB instead of being widened for one caller). - platform::fsXxx (read/write/list/exists/remove/mkdir) each gained a path-prefix check: /sd routes to the card, everything else keeps going to LittleFS/the desktop root. FileManagerModule, HttpServerModule's /api/dir and /api/file, and FilesystemModule::removeTree needed no changes at all to pick the card up. - New FsReadStream/FsWriteStream (explicit open/step/close) replace the reopen-per-chunk fsReadAt pattern for both flash and SD downloads, fixing a FAT cluster-chain re-traversal cost on every chunk. - New SdTransfer slot pair + HttpServerModule::driveSdTransfers, called from tick20ms next to drainPreviewSend/drainStateSend: a non-blocking read/writeSome per active slot per tick, bounded to one buffer's worth, with a 10 s stall guard (a liveness check, not a size or time cap on the transfer itself). /sd uploads and downloads hand off into this instead of the flash path's blocking-but-small synchronous handler. - esp32/platform_esp32_sdmmc.cpp: mount/unmount/format (field-by-field slot config, explicit NO_CD/NO_WP, GPIO validation before any pin-mask shift, the power-switch and LDO-domain sequencing a P4 needs) plus the file ops, written directly against FatFs (f_open/f_read/f_write) rather than through the stdio/VFS layer, to drop stdio's share of the flash cost. A write lands in a .tmp file and is renamed into place only on success (f_rename itself won't replace an existing file, so the old one is unlinked first); every failure path - write error, a stalled peer, a dropped connection - discards the .tmp instead, so a failed upload never leaves a partial file under its real name. Verified by killing a 10 MB upload mid-transfer: the card came back to exactly its prior contents. - A real bug from live hardware testing: driveSdUpload read into a 4 KB stack-local every tick, which a multi-minute upload stacked deeply enough to panic the board (a crash a browser's fetch() reports as the opaque "NetworkError when attempting to fetch resource.", not a clue to the real cause). Fixed by reading into the SdTransfer struct's own scratch buffer instead, the same way driveSdDownload already did it. Verified with a 40 MB upload (10x the file that first crashed it): one continuous 3m50s connection, board uptime and heap unmoved, file landed byte-exact. - hasSdCard gates on CONFIG_SOC_SDMMC_HOST_SUPPORTED *and* a new project Kconfig bool (MM_SD_CARD, set per-board: esp32p4rev1-eth, esp32s31, esp32s3-n16r8), not on chip capability alone - classic ESP32's SD host only works on fixed pins anyway, and the point is shipping it only where there's flash to spare. Desktop's hasSdCard is unconditionally true, backed by a real /sd subfolder, so its tests exercise the real mount/format/gauge logic. UI: - app.js: zero changes for the File Manager tree itself (the unified /api/dir and /api/file routes make the card appear automatically); the format button renders through the existing generic confirm-button machinery. - A second bug from live use: the drop-upload path still applied FM_UPLOAD_CAP (256 KiB, sized for flash config files) to a /sd drop, where the server has no cap of its own beyond the card's free space - an 8.7 MB upload was rejected client-side before any request was sent. Fixed with fmIsSdPath(destDir), gating both the size check and the "not uploaded" message's wording. - A progress bar for the upload, requested after the fix: fmDropUpload now goes through uploadWithProgress (the same XMLHttpRequest helper the firmware-flash UI already uses - fetch cannot report upload progress at all), driving a small floating, non-modal panel (one row per file, closes on completion) so a long /sd transfer is visible without blocking the rest of the page the way the firmware-update overlay deliberately does. - style.css: .fm-upload-panel / .fm-upload-panel-row, bottom-left (the PiP preview pane already claims bottom-right), matching its fixed-position/border/shadow convention. Scripts/MoonDeck: - check_devices.py: "SD card" added to SUPPORTED_VOCAB. Tests: - unit_SdCardModule.cpp: pins visible/hidden by hasSdCard, mount/remount/release/format/tick1s-gauge round trip against a real backing folder. - unit_HttpServerModule_sdcard.cpp: traversal rejection, a multi-chunk upload/download round trip, the stall timeout, 507 (card full), 413 (FAT32 ceiling), the root listing advertising "sd", DELETE removeTree on a non-empty SD folder. - file-manager-sd-upload.test.mjs: fmIsSdPath's gating, a flash-bound file over cap skipped client-side, the same file bound for /sd sent instead, a dropped connection reported plainly rather than as a generic network error, a 507 surfacing the server's own message. Docs/CI: - docs/moonmodules/core/system.md: new "SD Card" section (pins, format, gauge, "appears under /sd in the File Manager"). Its card image reference is kept (check_docgen requires the tag even though the asset doesn't exist yet - a disclosed, unresolved gap, not a regression). - docs/reference/hardware/gpio-usage.md, esp32-p4-eth.md: dropped the "(unimplemented)" / "MoonLight does not mount it" claims the feature now makes false. - esp32/main/Kconfig.projbuild, three sdkconfig.defaults.*: MM_SD_CARD. - mooninstaller/deviceModels.json: the Waveshare ESP32-P4-ETH's "SD card" moved planned -> supported, with its SdCardModule pin catalog entry. Reviews (๐Ÿ‡ ewowi, PR #134): 1. Mount as /sd, delete the parallel API - done. The original commit's sdXxx public functions, /api/sddir + /api/sdfile routes, and the File Manager's separate SD panel are gone; one tree, one toggle. 2. Ship only where there's flash to spare; use FatFs directly, not stdio - done, via the new MM_SD_CARD Kconfig bool and the raw-FatFs rewrite above. The hoped-for flash saving didn't fully materialize (~240 B measured, not the ~15 KB estimated) because platform_esp32_fs.cpp's LittleFS side still links stdio regardless; said here rather than left to look like a wasted rewrite. 3. Large transfers blocking the render thread - done, as a real resumable per-tick transfer (SdTransfer + driveSdTransfers) rather than the smaller interim fallback of a size cap; no SD-specific ceiling beyond the card's own free space, which the 507 check already enforces. feedWatchdog() is gone: nothing blocks long enough to need it. 4. Cards over 4 GB - done, via the uint64_t sdCardUsed/Total above. 5. One open handle per download - done, via FsReadStream/FsWriteStream. 6. Smaller items (GPIO validation, doc fixes, positive doc-comment headings) - done; see platform_esp32_sdmmc.cpp's validOutputPin and its own "Drive 0 is assumed, not queried" / "Built field by field" sections. 7. Desktop test coverage - done: hasSdCard is unconditionally true on desktop, backed by a real subfolder, so unit_SdCardModule.cpp and the HTTP route tests exercise the real logic rather than a no-hardware stub. 8. Start again from current main - done; this branched from main after #135 merged, with the four reusable files (platform_esp32_sdmmc.cpp, SdCardModule.h/.cpp, unit_SdCardModule.cpp) rewritten against it rather than cherry-picked, since 17 of the original 21 files had changed on main since the old branch point. Checks: - check_specs: PASS - 136/136 modules - check_prose: PASS - 1 finding in added lines, a verified Vale tool artifact (isolating one changed table row without its header mis-parses it as a long sentence; a direct Vale run against the real file shows zero findings there) - build_docs --strict: PASS - check_docgen: PASS - 0 errors, 4314 warnings (was 4286; the new SD files' own docs, not a regression in a touched existing file) - test_host --python: PASS - 377 passed - check_taglines: SKIP - no front-page .md touched - check_code: FAIL - ratchet rose (blocking call on the render path +6, complex function +6); large stack frame improved (-3). Both remaining deltas are structural, not debt: one more per-tick resumable transfer mirroring drainPreviewSend/drainStateSend's already-accepted shape, and one more periodic gauge read mirroring filesystemUsed's. Already simplified what this change touched (driveSdTransfers split into driveSdUpload/driveSdDownload, handleWriteFile's /sd leg extracted into handleWriteFileSd) before accepting the rest per coding-standards.md's own escape valve: "a function may stay over a limit when splitting it would hide a sequence that belongs together." - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2427/2427 - run_scenario: PASS - 30 scenarios, 17 passed / 13 skipped, 0 failed - check_esp32_built: PASS - fresh after the real rebuild+flash - build_desktop --no-jit: SKIP - MoonLive not touched - collect_kpi: PASS - recorded above - test_host --js: PASS - 243 passed - check_devices: PASS - 38 devices, 0 issues - check_firmwares: PASS - 14 variants, 0 issues - improv_smoke_test: SKIP - provisioning path not touched - Real hardware (esp32p4rev1-eth, over the network - serial unavailable this session): mount, File Manager discoverability, upload/download round trip, a 40 MB /sd upload completing without a crash, an interrupted upload leaving no partial file. The progress panel's wiring has its own JS tests; its on-screen appearance still wants a look in a real browser. --- CMakeLists.txt | 1 + docs/moonmodules/core/system.md | 22 + docs/reference/hardware/esp32-p4-eth.md | 2 +- docs/reference/hardware/gpio-usage.md | 2 +- docs/reference/metrics/code.md | 48 +- docs/reference/metrics/docgen.md | 38 +- docs/reference/metrics/repo-health.json | 116 ++++- docs/reference/metrics/repo-health.md | 135 +++--- esp32/main/CMakeLists.txt | 2 + esp32/main/Kconfig.projbuild | 9 + esp32/sdkconfig.defaults.esp32p4rev1-eth | 4 + esp32/sdkconfig.defaults.esp32s3-n16r8 | 3 + esp32/sdkconfig.defaults.esp32s31 | 3 + moondeck/check/check_devices.py | 2 +- mooninstaller/deviceModels.json | 16 +- src/core/system/HttpServerModule.cpp | 253 ++++++++-- src/core/system/HttpServerModule.h | 32 ++ src/core/system/SdCardModule.cpp | 101 ++++ src/core/system/SdCardModule.h | 73 +++ src/module_types.cpp | 3 + src/platform/desktop/platform_config.h | 3 + src/platform/desktop/platform_desktop.cpp | 128 +++++ src/platform/esp32/platform_config.h | 12 + src/platform/esp32/platform_esp32_fs.cpp | 105 +++++ src/platform/esp32/platform_esp32_sdmmc.cpp | 442 ++++++++++++++++++ src/platform/esp32/platform_esp32_sdmmc.h | 45 ++ src/platform/platform.h | 49 ++ src/ui/app.js | 61 ++- src/ui/style.css | 23 + test/CMakeLists.txt | 2 + test/js/file-manager-sd-upload.test.mjs | 154 ++++++ .../core/unit_HttpServerModule_sdcard.cpp | 284 +++++++++++ test/unit/core/unit_SdCardModule.cpp | 155 ++++++ 33 files changed, 2138 insertions(+), 190 deletions(-) create mode 100644 src/core/system/SdCardModule.cpp create mode 100644 src/core/system/SdCardModule.h create mode 100644 src/platform/esp32/platform_esp32_sdmmc.cpp create mode 100644 src/platform/esp32/platform_esp32_sdmmc.h create mode 100644 test/js/file-manager-sd-upload.test.mjs create mode 100644 test/unit/core/unit_HttpServerModule_sdcard.cpp create mode 100644 test/unit/core/unit_SdCardModule.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0f9f8d98d..f57abe37e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -140,6 +140,7 @@ add_library(mm_core STATIC src/core/system/HttpServerModule.cpp src/core/system/FilesystemModule.cpp src/core/system/FileManagerModule.cpp + src/core/system/SdCardModule.cpp src/core/system/MqttModule.cpp src/core/util/sha256.cpp src/core/module/Scheduler.cpp diff --git a/docs/moonmodules/core/system.md b/docs/moonmodules/core/system.md index 61379ad18..f37a469f4 100644 --- a/docs/moonmodules/core/system.md +++ b/docs/moonmodules/core/system.md @@ -245,6 +245,22 @@ Known renames from [MIGRATING.md](../../reference/MIGRATING.md) apply before upl Detail: [technical](moxygen/FileManagerModule.md) + + +### SD Card + +A board's microSD slot, where one is wired: pins, a usage gauge and a format button. No panel of its own; mounted, its files appear under `/sd` in the one File Manager tree above. Behavior: โŒ„ details. + +SD Card module controls + +- `clkPin` / `cmdPin` / `d0Pin`-`d3Pin` / `pwrPin`: the SDMMC pins, โˆ’1 for unused. +- `d1Pin`-`d3Pin` left unset drop the bus to 1-bit mode. +- `format`: a button, press twice; erases and reformats the card as FAT32 in place. +- `card (KiB)`: read-only usage bar, used / total KiB. +- KiB rather than bytes, so a card past 4 GiB still reports correctly. + +Detail: [technical](moxygen/SdCardModule.md) + ### I2C bus @@ -483,3 +499,9 @@ The panel is a lazy folder **tree** (each folder loads its children on first exp - Create / delete are HTTP calls (`POST` / `DELETE /api/dir?path=`), not controls, the path rides the request, so nothing is stored on the device per op. Last-modified dates (needs an NTP time source + LittleFS mtime), binary/large + folder upload, folder-as-zip download, and `.ml` syntax highlighting are backlogged ([backlog-core ยง File Manager follow-ups](../../work/future/backlog-core.md#file-manager-follow-ups)). + +## SD Card, details + +Mounts live, in `prepare()` rather than `setup()`: editing a pin unmounts and remounts on the new wiring, no reboot needed, the same contract I2C bus's `sda`/`scl` have below. A missing or unreadable card shows its own status instead of retrying every tick; reinsert it and touch a pin field (or reboot) to try again. + +The card is FAT32 only (ESP-IDF's FatFs ships with exFAT compiled out); `format` reformats one to FAT32 in place rather than requiring a second device. Once mounted, its content is reached through the File Manager above exactly like the internal flash, under an `sd` folder; this module never duplicates that tree. diff --git a/docs/reference/hardware/esp32-p4-eth.md b/docs/reference/hardware/esp32-p4-eth.md index fed839c2f..1882d278f 100644 --- a/docs/reference/hardware/esp32-p4-eth.md +++ b/docs/reference/hardware/esp32-p4-eth.md @@ -42,7 +42,7 @@ The board uses the `P4 RMII` preset, shared with the Waveshare P4-NANO: MDC 31, ## microSD slot -Wired on the board; MoonLight does not mount it: +Wired on the board and mounted by [SdCardModule](../../moonmodules/core/system.md#sd-card): | Signal | GPIO | |---|---| diff --git a/docs/reference/hardware/gpio-usage.md b/docs/reference/hardware/gpio-usage.md index 2d2d6493e..a38aa915c 100644 --- a/docs/reference/hardware/gpio-usage.md +++ b/docs/reference/hardware/gpio-usage.md @@ -70,7 +70,7 @@ For **LED output** specifically โ€” the pins a WS2812-class strand data line can | Role-conflict | **34-38** | Strapping pins (boot mode). Don't drive at reset; the first LED-driver default wrongly landed here (see [lessons.md](../../work/past/lessons.md)). | | Role-conflict | **37, 38** | UART0 console on the P4-NANO (`CONFIG_ESP_CONSOLE_UART_DEFAULT`) โ€” the runtime `ESP_LOGI` lines come out here, not over USB. | | Board-wired (P4-NANO) | **Ethernet RMII** 28-31 / 49-52, **C6 SDIO** 14-19 / 54, **I2C** 7-8 | Consumed by the NANO's on-board Ethernet PHY, the C6 WiFi co-processor, and the I2C bus. | -| Board-wired (P4-ETH) | **Ethernet RMII** 28-31 / 34-35 / 49-52, **ES8311 audio** 7-13, **USB D-/D+** 24-25, **microSD** (unimplemented) 39-45 | The [Waveshare ESP32-P4-ETH](esp32-p4-eth.md) has no C6/WiFi. It carries its own onboard ES8311 codec, a native USB-C port, and a microSD slot; that page has the full per-signal map. | +| Board-wired (P4-ETH) | **Ethernet RMII** 28-31 / 34-35 / 49-52, **ES8311 audio** 7-13, **USB D-/D+** 24-25, **microSD** 39-45 | The [Waveshare ESP32-P4-ETH](esp32-p4-eth.md) has no C6/WiFi. It carries its own onboard ES8311 codec, a native USB-C port, and a microSD slot. That page has the full per-signal map. | **Clear on the P4-NANO:** 20-27, 32-33, 39-48 (the LED-driver default is `pins="20,21,22,23,24,25,26,27"`). Exact free set is board-specific; the NANO's is the reference. A carrier board changes what's exposed and how. The [MHC-WLED ESP32-P4 shield](mhc-wled-esp32-p4-shield.md) routes every terminal through level shifters, RS-485 transceivers, or protected inputs, with no bare GPIO. A direct loopback jumper fails on it for that reason. Its full terminal map is on that page. diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index e4c89ea89..066f3133a 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,19 +4,19 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows. -**1620 finding(s)** across 318 file(s). 2.88% of all lines are duplicated, 3733 lines. +**1629 finding(s)** across 320 file(s). 2.88% of all lines are duplicated, 3733 lines. ## By rule | Rule | Findings | |---|---:| | duplicated block | 768 | -| complex function | 283 | -| blocking call on the render path | 189 | +| complex function | 289 | +| blocking call on the render path | 195 | | deeply nested | 113 | | module type in the UI | 71 | | long function | 49 | -| large stack frame | 38 | +| large stack frame | 35 | | long parameter list | 34 | | raw allocation in light | 28 | | large file | 25 | @@ -46,11 +46,11 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta |---|---:| | `test` | 570 | | `light` | 421 | -| `core` | 351 | -| `platform` | 173 | +| `core` | 352 | +| `platform` | 182 | | `ui` | 74 | | `moondeck` | 24 | -| `src` | 5 | +| `src` | 4 | | `mooninstaller` | 2 | ## By file @@ -59,23 +59,23 @@ The next function worth simplifying is at the top of each file's list. | File | Findings | Worst | |---|---:|---| -| `src/core/system/HttpServerModule.cpp` | 44 | `HttpServerModule::handleConnection`, complex function 121 | +| `src/platform/desktop/platform_desktop.cpp` | 46 | `platform::getMacAddress, line 1379`, large stack frame 5240 | +| `src/core/system/HttpServerModule.cpp` | 44 | `HttpServerModule::handleConnection`, complex function 123 | | `src/ui/migrate.js` | 43 | `line 29: 'I2cBusModule'`, module type in the UI 1 | -| `src/platform/desktop/platform_desktop.cpp` | 41 | `platform::spawnEncoderProcess`, complex function 58 | | `src/core/system/MqttModule.cpp` | 31 | `MqttModule::routePublish`, complex function 29 | -| `src/ui/app.js` | 29 | `app.js`, large file 9033 | +| `src/ui/app.js` | 29 | `app.js`, large file 9076 | | `src/core/moonlive/MoonLiveCompiler.cpp` | 27 | `moonlive::Parser::parseCall`, complex function 59 | | `src/light/drivers/ParallelLedDriver.h` | 27 | `ParallelLedDriver::reinit`, complex function 36 | | `src/light/drivers/PreviewDriver.h` | 27 | `PreviewDriver::buildCoordTable`, complex function 37 | | `test/unit/core/unit_FilesystemModule_persistence.cpp` | 24 | `lines 181-194, also in test/unit/core/unit_FilesystemModule_persistence.cpp:146`, duplicated block 14 | -| `src/light/drivers/RtspDriver.h` | 23 | `RtspDriver::serveControl, line 185`, large stack frame 1680 | +| `src/light/drivers/RtspDriver.h` | 23 | `RtspDriver::serveControl, line 185`, large stack frame 1608 | | `test/unit/light/unit_I80Peripheral.cpp` | 23 | `lines 151-170, also in test/unit/light/unit_ParlioPeripheral.cpp:214`, duplicated block 20 | | `test/unit/light/unit_PanelCardDriver.cpp` | 22 | `lines 22-39, also in test/unit/light/unit_NdiDriver.cpp:29`, duplicated block 18 | | `test/unit/light/unit_RainbowEffect.cpp` | 22 | `lines 69-85, also in test/unit/light/unit_ParticlesEffect.cpp:32`, duplicated block 17 | | `src/light/powerfunctions/draw.h` | 21 | `draw::scroll`, complex function 28 | | `src/core/system/NetworkModule.h` | 20 | `NetworkModule in 25 files`, name spread 25 | | `test/unit/light/unit_ParallelLedDriver_ring.cpp` | 20 | `lines 486-503, also in test/unit/light/unit_ParallelLedDriver_ring.cpp:432`, duplicated block 18 | -| `src/light/drivers/HueDriver.h` | 19 | `HueDriver::pollPairing, line 297`, large stack frame 1184 | +| `src/light/drivers/HueDriver.h` | 19 | `HueDriver::pollPairing, line 297`, large stack frame 1112 | | `src/platform/esp32/platform_esp32.cpp` | 19 | `platform::httpRequest`, complex function 30 | | `test/unit/core/unit_HttpServerModule_apply.cpp` | 19 | `lines 536-547, also in test/unit/core/unit_HttpServerModule_apply.cpp:122`, duplicated block 12 | | `src/platform/esp32/platform_esp32_moon_i80.cpp` | 18 | `platform::createRingState`, complex function 32 | @@ -121,6 +121,7 @@ The next function worth simplifying is at the top of each file's list. | `src/core/system/SystemModule.h` | 7 | `SystemModule in 31 files`, name spread 31 | | `src/core/util/JsonUtil.h` | 7 | `json::parseString`, complex function 34 | | `src/light/effects/FishTankEffect.h` | 7 | `lines 244-258, also in src/light/effects/PacmanEffect.h:243`, duplicated block 15 | +| `src/platform/esp32/platform_esp32_fs.cpp` | 7 | `lines 227-237, also in src/platform/esp32/platform_esp32_fs.cpp:174`, duplicated block 11 | | `src/platform/esp32/platform_esp32_hub75.cpp` | 7 | `platform::hub75Init`, complex function 32 | | `src/platform/esp32/platform_esp32_parlio.cpp` | 7 | `lines 277-286, also in src/platform/esp32/platform_esp32_i80.cpp:425`, duplicated block 10 | | `test/js/ui-ws-scheme.test.mjs` | 7 | `lines 10-22, also in test/js/ui-render-guard.test.mjs:13`, duplicated block 13 | @@ -129,11 +130,11 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_PlasmaEffect.cpp` | 7 | `lines 24-39, also in test/unit/light/unit_MetaballsEffect.cpp:22`, duplicated block 16 | | `src/core/moonlive/moonlive_lower.h` | 6 | `moonlive::lowerWith`, complex function 82 | | `src/core/system/FirmwareUpdateModule.h` | 6 | `FirmwareUpdateModule in 13 files`, name spread 13 | +| `src/core/util/JsonSink.h` | 6 | `JsonSink::writeJsonString`, deeply nested 8 | | `src/light/drivers/FixtureProfilesModule.h` | 6 | `lines 245-252, also in src/core/system/WiFiModule.h:352`, duplicated block 8 | -| `src/light/drivers/NetworkSendDriver.h` | 6 | `NetworkSendDriver::tick, line 190`, large stack frame 1760 | +| `src/light/drivers/NetworkSendDriver.h` | 6 | `NetworkSendDriver::tick, line 190`, large stack frame 1640 | | `src/light/layers/BlendMap.h` | 6 | `blendMap`, complex function 35 | | `src/platform/desktop/moonlive_asm_x86_64.cpp` | 6 | `lines 557-564, also in src/platform/desktop/moonlive_asm_x86_64.cpp:515`, duplicated block 8 | -| `src/platform/esp32/platform_esp32_fs.cpp` | 6 | `lines 211-221, also in src/platform/esp32/platform_esp32_fs.cpp:161`, duplicated block 11 | | `src/platform/esp32/platform_esp32_improv.cpp` | 6 | `platform::improvTask`, complex function 22 | | `test/unit/core/unit_AccessPointModule.cpp` | 6 | `lines 340-348, also in test/unit/core/unit_AccessPointModule.cpp:311`, duplicated block 9 | | `test/unit/core/unit_ImprovFrame.cpp` | 6 | `lines 179-187, also in test/unit/core/unit_ImprovFrame.cpp:152`, duplicated block 9 | @@ -147,7 +148,6 @@ The next function worth simplifying is at the top of each file's list. | `src/core/services/AnalogService.h` | 5 | `lines 105-123, also in src/core/services/InfraredService.h:117`, duplicated block 19 | | `src/core/services/AudioBands.h` | 5 | `magnitudesToBands`, complex function 19 | | `src/core/util/InputMapping.h` | 5 | `setInputActionField`, complex function 22 | -| `src/core/util/JsonSink.h` | 5 | `JsonSink::writeJsonString`, deeply nested 8 | | `src/core/util/noise.h` | 5 | `lines 304-310, also in src/core/util/noise.h:288`, duplicated block 7 | | `src/light/drivers/PinList.h` | 5 | `assignCounts`, complex function 30 | | `src/light/effects/FluidEffect.h` | 5 | `FluidEffect::data`, deeply nested 5 | @@ -156,6 +156,7 @@ The next function worth simplifying is at the top of each file's list. | `src/light/layouts/CarLightsLayout.h` | 5 | `lines 49-65, also in src/light/layouts/TorontoBarGourdsLayout.h:52`, duplicated block 17 | | `src/light/layouts/RingLayout.h` | 5 | `lines 85-100, also in src/light/layouts/CarLightsLayout.h:95`, duplicated block 16 | | `src/light/layouts/TorontoBarGourdsLayout.h` | 5 | `lines 52-68, also in src/light/layouts/CarLightsLayout.h:49`, duplicated block 17 | +| `src/platform/desktop/main_desktop.cpp` | 5 | `main`, deeply nested 5 | | `src/platform/esp32/platform_esp32_h264.cpp` | 5 | `platform::hlsSegment`, complex function 26 | | `test/unit/light/unit_Drivers_rendersplit.cpp` | 5 | `(static local variable) in tick`, blocking call on the render path 1 | | `test/unit/light/unit_FireEffect.cpp` | 5 | `lines 29-42, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 14 | @@ -181,8 +182,6 @@ The next function worth simplifying is at the top of each file's list. | `src/light/layers/MappingLUT.h` | 4 | `MappingLUT::allocateDestinations`, complex function 12 | | `src/light/powerfunctions/particles.h` | 4 | `particles::Pool::collide`, complex function 16 | | `src/light/util/RtspSession.h` | 4 | `rtsp::parseTransport`, complex function 28 | -| `src/main.cpp` | 4 | `mm_main`, complex function 27 | -| `src/platform/desktop/main_desktop.cpp` | 4 | `main`, deeply nested 5 | | `src/platform/esp32/moonlive_asm_riscv.cpp` | 4 | `lines 297-303, also in src/platform/esp32/moonlive_asm_xtensa.cpp:481`, duplicated block 7 | | `test/js/ui-mooncloud.test.mjs` | 4 | `lines 6-17, also in test/js/installer-s31-webflash.test.mjs:11`, duplicated block 12 | | `test/unit/light/unit_BlurzEffect.cpp` | 4 | `lines 137-155, also in test/unit/light/unit_GEQEffect.cpp:159`, duplicated block 19 | @@ -208,6 +207,7 @@ The next function worth simplifying is at the top of each file's list. | `src/light/effects/PongEffect.h` | 3 | `PongEffect::render`, complex function 15 | | `src/light/effects/SolidEffect.h` | 3 | `SolidEffect::static_cast`, complex function 36 | | `src/light/layouts/GridLayout.h` | 3 | `GridLayout in 86 files`, name spread 86 | +| `src/main.cpp` | 3 | `mm_main`, complex function 27 | | `src/platform/desktop/moonlive_asm_arm64.cpp` | 3 | `lines 35-40, also in src/platform/esp32/moonlive_asm_riscv.cpp:34`, duplicated block 6 | | `test/unit/core/unit_NetworkModule_ethernet.cpp` | 3 | `lines 276-285, also in test/unit/core/unit_NetworkModule_ethernet.cpp:193`, duplicated block 10 | | `test/unit/light/unit_AuroraEffect.cpp` | 3 | `lines 40-48, also in test/unit/light/unit_PolarLut_equivalence.cpp:40`, duplicated block 9 | @@ -229,9 +229,8 @@ The next function worth simplifying is at the top of each file's list. | `src/core/services/MidiService.h` | 2 | `MidiService::serveShare in tick20ms`, blocking call on the render path 1 | | `src/core/system/DevicePlugin.h` | 2 | `lines 94-100, also in src/core/system/DevicePlugin.h:75`, duplicated block 7 | | `src/core/system/HttpServerModule.h` | 2 | `HttpServerModule in 17 files`, name spread 17 | -| `src/core/system/I2cBusModule.h` | 2 | `I2cBusModule::scan`, complex function 18 | | `src/core/system/MqttPacket.h` | 2 | `buildMqttConnect`, complex function 31 | -| `src/core/system/Sha1.h` | 2 | `sha1, line 21`, large stack frame 944 | +| `src/core/system/Sha1.h` | 2 | `sha1, line 21`, large stack frame 920 | | `src/core/util/ModuleFactory.h` | 2 | `ModuleFactory::create`, deeply nested 4 | | `src/core/util/PinList.h` | 2 | `parsePinList`, complex function 15 | | `src/light/drivers/DriverBase.h` | 2 | `line 167: if (!wire_) wire_ = static_cast(platform::alloc(by`, raw allocation in light 1 | @@ -259,6 +258,7 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/moonlive_asm_riscv.h` | 2 | `lines 74-91, also in src/platform/esp32/moonlive_asm_xtensa.h:79`, duplicated block 18 | | `src/platform/esp32/moonlive_asm_xtensa.cpp` | 2 | `lines 481-487, also in src/platform/esp32/moonlive_asm_riscv.cpp:297`, duplicated block 7 | | `src/platform/esp32/moonlive_emit_riscv.cpp` | 2 | `lines 52-57, also in src/platform/esp32/moonlive_emit_riscv.cpp:25`, duplicated block 6 | +| `src/platform/esp32/platform_esp32_sdmmc.cpp` | 2 | `platform::sdMount`, complex function 22 | | `test/js/ui-midi-bridge.test.mjs` | 2 | `lines 4-16, also in test/js/ui-gamepad-bridge.test.mjs:4`, duplicated block 13 | | `test/unit/core/unit_AudioService_sync.cpp` | 2 | `lines 313-322, also in test/unit/core/unit_AudioService_sync.cpp:269`, duplicated block 10 | | `test/unit/core/unit_DevicesModule_hue.cpp` | 2 | `lines 63-69, also in test/unit/core/unit_DevicesModule_hue.cpp:42`, duplicated block 7 | @@ -304,6 +304,8 @@ The next function worth simplifying is at the top of each file's list. | `src/core/services/Services.h` | 1 | `Services in 12 files`, name spread 12 | | `src/core/system/FileManagerModule.cpp` | 1 | `platform::filesystemUsed in tick1s`, blocking call on the render path 1 | | `src/core/system/FilesystemModule.h` | 1 | `FilesystemModule in 24 files`, name spread 24 | +| `src/core/system/I2cBusModule.h` | 1 | `I2cBusModule::scan`, complex function 18 | +| `src/core/system/SdCardModule.cpp` | 1 | `SdCardModule::refreshGauge in tick1s`, blocking call on the render path 1 | | `src/core/util/Addressing.h` | 1 | `sendAddressed`, complex function 11 | | `src/core/util/CaptivePortal.h` | 1 | `captive::dnsReply`, complex function 21 | | `src/core/util/ImprovFrame.h` | 1 | `ImprovFrameParser::feed`, complex function 22 | @@ -339,14 +341,14 @@ The next function worth simplifying is at the top of each file's list. | `src/light/moonlive/MoonLivePalette.h` | 1 | `moonlive::MoonLive::runValue in tick`, blocking call on the render path 1 | | `src/light/powerfunctions/fluid.h` | 1 | `Fluid::resize`, complex function 14 | | `src/light/util/E131Packet.h` | 1 | `parseE131Packet`, complex function 17 | -| `src/module_types.cpp` | 1 | `registerModuleTypes`, long function 149 | +| `src/module_types.cpp` | 1 | `registerModuleTypes`, long function 150 | | `src/platform/desktop/moonlive_asm_host.h` | 1 | `lines 36-43, also in src/platform/esp32/moonlive_asm_riscv.h:36`, duplicated block 8 | -| `src/platform/desktop/platform_config.h` | 1 | `lines 71-85, also in src/platform/esp32/platform_config.h:236`, duplicated block 15 | +| `src/platform/desktop/platform_config.h` | 1 | `lines 71-85, also in src/platform/esp32/platform_config.h:241`, duplicated block 15 | | `src/platform/esp32/moonlive_asm_xtensa.h` | 1 | `lines 79-96, also in src/platform/esp32/moonlive_asm_riscv.h:74`, duplicated block 18 | -| `src/platform/esp32/platform_config.h` | 1 | `lines 236-250, also in src/platform/desktop/platform_config.h:71`, duplicated block 15 | +| `src/platform/esp32/platform_config.h` | 1 | `lines 241-255, also in src/platform/desktop/platform_config.h:71`, duplicated block 15 | | `src/platform/esp32/platform_esp32_i2s.cpp` | 1 | `platform::audioMicInit`, complex function 11 | | `src/platform/esp32/platform_esp32_tasks.cpp` | 1 | `platform::taskSnapshot`, complex function 12 | -| `src/platform/platform.h` | 1 | `platform.h`, large file 1301 | +| `src/platform/platform.h` | 1 | `platform.h`, large file 1350 | | `src/ui/install-picker.js` | 1 | `install-picker.js`, large file 1048 | | `src/ui/preview3d.js` | 1 | `preview3d.js`, large file 1431 | | `test/js/installer-eth-only.test.mjs` | 1 | `lines 13-23, also in test/js/ui-mooncloud.test.mjs:6`, duplicated block 11 | diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index d0bf9091f..c7210cc0a 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,9 +4,9 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks. -**0 error(s)** and **4286 warning(s)** across 413 page(s). +**0 error(s)** and **4314 warning(s)** across 422 page(s). -**5460 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. +**5532 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. An error is in a file that generates a documentation page, a header or a catalog page, so the finding is a defect in what gets published and it fails the gate. A warning is worth fixing without being worth stopping a commit for: any finding in an implementation file, which publishes nothing, and the rules still staged as counts in a header too, the over-wide line and the appendix section nothing refers to. Both are counted here, because a warning nobody sees is a warning nobody fixes. @@ -16,13 +16,13 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| -| split paragraphs | 0 | 1791 | -| over-wide comment lines | 0 | 1582 | -| appendix sections nothing refers to | 0 | 369 | -| multi-line comment blocks | 0 | 317 | +| split paragraphs | 0 | 1799 | +| over-wide comment lines | 0 | 1594 | +| appendix sections nothing refers to | 0 | 372 | +| multi-line comment blocks | 0 | 321 | | over-long sentences | 0 | 82 | | over-wide doc lines | 0 | 78 | -| member deep dives | 0 | 67 | +| member deep dives | 0 | 68 | ## By documentation area @@ -30,10 +30,10 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier | Summary page | Errors | Warnings | |---|---:|---:| -| `(tests, no card)` | 0 | 2064 | -| `platform/index.md` | 0 | 743 | +| `(tests, no card)` | 0 | 2071 | +| `platform/index.md` | 0 | 746 | | `light/effects.md` | 0 | 403 | -| `core/system.md` | 0 | 203 | +| `core/system.md` | 0 | 221 | | `core/supporting.md` | 0 | 181 | | `light/power-functions.md` | 0 | 147 | | `core/services.md` | 0 | 123 | @@ -51,7 +51,7 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul ### (tests, no card) -**0 error(s)** and **2064 warning(s)** across 158 file(s). +**0 error(s)** and **2071 warning(s)** across 160 file(s). | Findings | File | | |---:|---|---| @@ -65,13 +65,13 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul | 48 | `test/unit/light/unit_Effects_gridsweep.cpp` | warning | | 45 | `test/unit/core/unit_moonlive_codegen_xtensa.cpp` | warning | | 42 | `test/unit/core/unit_FilesystemModule_persistence.cpp` | warning | -| 1-42 each | *148 more warning files, 1298 findings* | | +| 1-42 each | *150 more warning files, 1305 findings* | | -By rule, warnings: 1360 over-wide comment lines, 271 split paragraphs, 197 multi-line comment blocks, 82 over-long sentences, 78 over-wide doc lines, 67 member deep dives, 9 appendix sections nothing refers to. +By rule, warnings: 1363 over-wide comment lines, 273 split paragraphs, 198 multi-line comment blocks, 82 over-long sentences, 78 over-wide doc lines, 68 member deep dives, 9 appendix sections nothing refers to. ### platform/index.md -**0 error(s)** and **743 warning(s)** across 31 file(s). +**0 error(s)** and **746 warning(s)** across 34 file(s). | Findings | File | | |---:|---|---| @@ -85,9 +85,9 @@ By rule, warnings: 1360 over-wide comment lines, 271 split paragraphs, 197 multi | 32 | `src/platform/esp32/moonlive_asm_riscv.cpp` | warning | | 23 | `src/platform/esp32/platform_esp32_i2s.cpp` | warning | | 20 | `src/platform/esp32/platform_esp32_improv.cpp` | warning | -| 1-19 each | *21 more warning files, 146 findings* | | +| 1-19 each | *24 more warning files, 149 findings* | | -By rule, warnings: 398 split paragraphs, 217 over-wide comment lines, 114 multi-line comment blocks, 14 appendix sections nothing refers to. +By rule, warnings: 399 split paragraphs, 219 over-wide comment lines, 114 multi-line comment blocks, 14 appendix sections nothing refers to. ### light/effects.md @@ -111,7 +111,7 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. ### core/system.md -**0 error(s)** and **203 warning(s)** across 22 file(s). +**0 error(s)** and **221 warning(s)** across 26 file(s). | Findings | File | | |---:|---|---| @@ -125,9 +125,9 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. | 12 | `src/core/system/TasksModule.h` | warning | | 11 | `src/core/system/MqttModule.h` | warning | | 11 | `src/core/system/MqttPacket.h` | warning | -| 1-11 each | *12 more warning files, 58 findings* | | +| 1-11 each | *16 more warning files, 76 findings* | | -By rule, warnings: 159 split paragraphs, 34 appendix sections nothing refers to, 6 multi-line comment blocks, 4 over-wide comment lines. +By rule, warnings: 164 split paragraphs, 37 appendix sections nothing refers to, 11 over-wide comment lines, 9 multi-line comment blocks. ### core/supporting.md diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 7401f233e..e4d130602 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "48ab0058", + "commit": "17dae99d", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, - "esp32p4rev1-eth": 2177776, + "esp32p4rev1-eth": 2328416, "esp32p4rev1-eth-wifi": 2358896, "esp32s3-n8r8": 2336304, "esp32s31": 2595200, @@ -12,27 +12,29 @@ "esp32-wrover": 1843760, "qemu": 1383648, "esp32p4rev3-eth": 2203712, - "esp32s3-zero": 2219120, + "esp32s3-zero": 2232272, "esp32-pico": 2107168, "desktop-macos": 2270440, "desktop-windows": 1925120, - "esp32c3": 2335072 + "esp32c3": 2335072, + "desktop-linux": 2547336 }, "measured": { - "esp32p4rev1-eth": "2026-10-07", + "esp32p4rev1-eth": "2026-10-11", "esp32s31": "2026-10-10", "esp32": "2026-10-09", "esp32-pico": "2026-09-09", "esp32s3-n16r8": "2026-10-10", "esp32s3-n8r8": "2026-10-10", - "esp32s3-zero": "2026-10-10", + "esp32s3-zero": "2026-10-11", "esp32-16mb": "2026-09-09", "esp32p4rev1-eth-wifi": "2026-09-30", "esp32-eth": "2026-09-11", "esp32p4rev3-eth": "2026-10-08", "desktop-macos": "2026-10-10", "desktop-windows": "2026-09-30", - "esp32c3": "2026-10-10" + "esp32c3": "2026-10-10", + "desktop-linux": "2026-10-11" }, "perf": { "esp32": { @@ -52,6 +54,12 @@ "p95": 2744, "n": 4, "last": "2026-10-04" + }, + "desktop-linux": { + "p50": 18, + "p95": 18, + "n": 1, + "last": "2026-10-11" } }, "Firmware_reports_what_is_running": { @@ -118,6 +126,12 @@ "p95": 2800, "n": 3, "last": "2026-10-04" + }, + "desktop-linux": { + "p50": 7, + "p95": 7, + "n": 1, + "last": "2026-10-11" } }, "Aurora_fps": { @@ -506,6 +520,12 @@ "p95": 218, "n": 1, "last": "2026-09-30" + }, + "desktop-linux": { + "p50": 7, + "p95": 7, + "n": 1, + "last": "2026-10-11" } }, "Effects_pipeline_builds_and_renders": { @@ -515,6 +535,12 @@ "n": 32, "last": "2026-10-10" }, + "desktop-linux": { + "p50": 8, + "p95": 8, + "n": 1, + "last": "2026-10-11" + }, "desktop-windows": { "p50": 4, "p95": 6, @@ -546,6 +572,12 @@ "p95": 287, "n": 1, "last": "2026-09-30" + }, + "desktop-linux": { + "p50": 5, + "p95": 5, + "n": 1, + "last": "2026-10-11" } }, "Effects_teardown_under_a_running_pipeline": { @@ -572,6 +604,12 @@ "p95": 482, "n": 1, "last": "2026-09-30" + }, + "desktop-linux": { + "p50": 10, + "p95": 10, + "n": 1, + "last": "2026-10-11" } }, "Layers_stack_and_blend_live": { @@ -580,6 +618,12 @@ "p95": 355, "n": 32, "last": "2026-10-10" + }, + "desktop-linux": { + "p50": 519, + "p95": 519, + "n": 1, + "last": "2026-10-11" } }, "Layouts_resize_reallocates_live": { @@ -606,6 +650,12 @@ "p95": 579, "n": 1, "last": "2026-09-30" + }, + "desktop-linux": { + "p50": 22, + "p95": 22, + "n": 1, + "last": "2026-10-11" } }, "Modifiers_reshape_the_mapping": { @@ -632,6 +682,12 @@ "p95": 273, "n": 1, "last": "2026-09-30" + }, + "desktop-linux": { + "p50": 11, + "p95": 11, + "n": 1, + "last": "2026-10-11" } }, "Palettes_a_script_drives_the_effects": { @@ -640,6 +696,12 @@ "p95": 74, "n": 32, "last": "2026-10-10" + }, + "desktop-linux": { + "p50": 9, + "p95": 9, + "n": 1, + "last": "2026-10-11" } } }, @@ -678,31 +740,49 @@ "last": "2026-09-30" } } + }, + "desktop-linux": { + "tick_us": 8, + "fps": 125000, + "scenario_p50": { + "Effects_pipeline_builds_and_renders": { + "p50": 8, + "p95": 8, + "n": 1, + "last": "2026-10-11" + }, + "Layouts_resize_reallocates_live": { + "p50": 22, + "p95": 22, + "n": 1, + "last": "2026-10-11" + } + } } }, "loc": { - "core": 28271, + "core": 28472, "light": 31027, - "platform": 17930, - "ui": 12307, + "platform": 18227, + "ui": 12350, "test": 64597, "moondeck": 30600 }, "comments": { "core": { - "lines": 6165, - "ratio": 0.242 + "lines": 6190, + "ratio": 0.241 }, "light": { "lines": 7439, "ratio": 0.269 }, "platform": { - "lines": 3659, - "ratio": 0.228 + "lines": 3700, + "ratio": 0.227 }, "ui": { - "lines": 3464, + "lines": 3472, "ratio": 0.298 }, "test": { @@ -720,15 +800,15 @@ }, "docs": { "md_files": 150, - "md_lines": 32271, + "md_lines": 32281, "plans_files": 44, "backlog_lines": 3319, "lessons_lines": 532, "claude_md_lines": 283 }, "complexity": { - "functions": 4389, - "over_threshold": 331, + "functions": 4470, + "over_threshold": 337, "worst_ccn": 128 } } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index d488dc2e4..3e7a6fae9 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `48ab0058`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `17dae99d`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -10,18 +10,16 @@ Current state only; the trend is this file's git history (`git log -p docs/refer |---|---:|---:|---:| | flash desktop-macos | 1,963 KB | 2,217 KB | +254.2 KB (+12.9%) | | flash esp32 | 2,061 KB | 2,174 KB | +112.9 KB (+5.5%) | -| flash esp32p4rev1-eth | 2,022 KB | 2,127 KB | +105.0 KB (+5.2%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,274 KB | +252.1 KB (+12.5%) | | flash esp32p4rev3-eth | 2,025 KB | 2,152 KB | +126.8 KB (+6.3%) | | flash esp32s3-n16r8 | 2,111 KB | 2,282 KB | +170.1 KB (+8.1%) | | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | -| flash esp32s3-zero | 1,977 KB | 2,167 KB | +190.4 KB (+9.6%) | +| flash esp32s3-zero | 1,977 KB | 2,180 KB | +203.2 KB (+10.3%) | | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | -| tick Effects_pipeline_builds_and_renders (desktop-macos) | 7 ยตs | 7 ยตs | +0 (+0.0%) | -| tick Layouts_resize_reallocates_live (desktop-macos) | 12 ยตs | 10 ยตs | -2 (-16.7%) | -| lines core | 22,299 | 28,271 | +5,972 (+26.8%) | +| lines core | 22,299 | 28,472 | +6,173 (+27.7%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | -| lines platform | 16,798 | 17,930 | +1,132 (+6.7%) | -| lines ui | 11,394 | 12,307 | +913 (+8.0%) | +| lines platform | 16,798 | 18,227 | +1,429 (+8.5%) | +| lines ui | 11,394 | 12,350 | +956 (+8.4%) | | lines test | 57,759 | 64,597 | +6,838 (+11.8%) | | lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | | unit tests | 2,120 | 2,398 | +278 (+13.1%) | @@ -33,80 +31,71 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop-macos | 2,217 KB (โˆ’48 B) โœ“ | - | - | yes | -| desktop-windows | 1,880 KB | - | - | **STALE 10d** | -| esp32 | 2,174 KB | 2,496 KB | 87% | carried 1d | -| esp32-16mb | 2,012 KB | 4,096 KB | 49% | **STALE 31d** | -| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 29d** | -| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 31d** | +| desktop-linux | 2,488 KB | - | - | yes | +| desktop-macos | 2,217 KB | - | - | carried 1d | +| desktop-windows | 1,880 KB | - | - | **STALE 11d** | +| esp32 | 2,174 KB | 2,496 KB | 87% | carried 2d | +| esp32-16mb | 2,012 KB | 4,096 KB | 49% | **STALE 32d** | +| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 30d** | +| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 32d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | -| esp32c3 | 2,280 KB (+544 B) โš  | 2,496 KB | 91% | yes | -| esp32p4rev1-eth | 2,127 KB | 4,096 KB | 52% | carried 3d | -| esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 10d** | -| esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 2d | -| esp32s3-n16r8 | 2,282 KB (+800 B) โš  | 4,096 KB | 56% | yes | -| esp32s3-n8r8 | 2,282 KB (+800 B) โš  | 3,072 KB | 74% | yes | -| esp32s3-zero | 2,167 KB (+832 B) โš  | 2,496 KB | 87% | yes | -| esp32s31 | 2,534 KB (+816 B) โš  | 4,096 KB | 62% | yes | +| esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 1d | +| esp32p4rev1-eth | 2,274 KB (+147 KB) โš  | 4,096 KB | 56% | yes | +| esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 11d** | +| esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 3d | +| esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 1d | +| esp32s3-n8r8 | 2,282 KB | 3,072 KB | 74% | carried 1d | +| esp32s3-zero | 2,180 KB (+13 KB) โš  | 2,496 KB | 87% | carried 0d | +| esp32s31 | 2,534 KB | 4,096 KB | 62% | carried 1d | | qemu | 1,351 KB | - | - | carried (age?) | `Built: yes` was measured this run. `carried (age?)` was not rebuilt either and predates this record, so its age is unknown: it dates itself on the next build. `carried Nd` was NOT rebuilt and its number is N days old, so an absent delta says nothing about the change. **STALE** marks a carry older than 7 days: the number has gone unchecked long enough that growth will surface later as one jump, blamed on whichever commit happens to rebuild that target. `Used` is against the app slot in the firmware's own partition table. -## Flash change by kind - -| Target | Net | New | Removed | Grown | Shrunk | Other | Since | -|---|---:|---:|---:|---:|---:|---:|---| -| esp32c3 | +0.5 KB | +1.0 KB | +0.0 KB | +0.6 KB | -0.5 KB | -0.5 KB | `48ab0058` | -| esp32s3-n16r8 | +0.8 KB | +0.1 KB | -0.1 KB | +0.9 KB | -0.2 KB | +0.1 KB | `48ab0058` | -| esp32s3-n8r8 | +0.8 KB | +0.1 KB | -0.1 KB | +0.9 KB | -0.2 KB | +0.1 KB | `48ab0058` | -| esp32s3-zero | +0.8 KB | +0.1 KB | -0.1 KB | +0.9 KB | -0.1 KB | +0.1 KB | `48ab0058` | -| esp32s31 | +0.8 KB | +1.1 KB | -0.2 KB | +0.8 KB | -0.5 KB | -0.4 KB | `48ab0058` | - -Largest moves: - -- **esp32c3**: `mm::moonlive::lowerWith(mm::mo` +504 B, `mm::moonlive::lowerWith(mm::mo` +504 B, `unsigned int mm::moonlive::lowerWith(mm::mo` +504 B, `mm::moonlive::lowerWith(mm::mo` +504 B, `unsigned int mm::moonlive::lowerWith 0) { + const size_t want = t.remaining < sizeof(t.buf) ? t.remaining : sizeof(t.buf); + const int n = t.conn.read(reinterpret_cast(t.buf), want); + if (n > 0) { + if (!platform::fsWriteStreamWrite(t.wstream, t.buf, static_cast(n))) { + platform::fsWriteStreamClose(t.wstream, false); + sendResponse(t.conn, 500, "application/json", "{\"error\":\"write failed\"}"); + finishSdTransfer(t, true); + return; + } + t.remaining -= static_cast(n); + t.lastActivityMs = now; + } else if (n == 0) { // peer closed with body remaining + platform::fsWriteStreamClose(t.wstream, false); + finishSdTransfer(t, true); // the peer is already gone: no response to send + return; + } + } + if (t.active && t.remaining == 0) { + const bool ok = platform::fsWriteStreamClose(t.wstream, true); + sendResponse(t.conn, ok ? 200 : 500, "application/json", + ok ? "{\"ok\":true}" : "{\"error\":\"write failed\"}"); + finishSdTransfer(t, true); + return; + } + // A liveness guard, not a size or time cap on the transfer itself: only a stalled peer trips it. + if (t.active && now - t.lastActivityMs > kSdTransferStallMs) { + platform::fsWriteStreamClose(t.wstream, false); + finishSdTransfer(t, true); + } +} + +// A download slot's bounded chunk: card to socket, resuming where last tick's writeSome left off. +void HttpServerModule::driveSdDownload(SdTransfer& t, uint32_t now) { + if (t.bufSent < t.bufLen) { + const int n = t.conn.writeSome(reinterpret_cast(t.buf + t.bufSent), + t.bufLen - t.bufSent); + if (n < 0) { platform::fsReadStreamClose(t.rstream); finishSdTransfer(t, true); return; } + if (n > 0) { t.bufSent += static_cast(n); t.lastActivityMs = now; } + } else if (t.remaining > 0) { + const size_t want = t.remaining < sizeof(t.buf) ? t.remaining : sizeof(t.buf); + const int got = platform::fsReadStreamRead(t.rstream, t.buf, want); + if (got <= 0) { platform::fsReadStreamClose(t.rstream); finishSdTransfer(t, true); return; } + t.bufLen = static_cast(got); + t.bufSent = 0; + t.remaining -= static_cast(got); + t.lastActivityMs = now; + } else { + platform::fsReadStreamClose(t.rstream); + finishSdTransfer(t, true); + return; + } + if (t.active && now - t.lastActivityMs > kSdTransferStallMs) { + platform::fsReadStreamClose(t.rstream); + finishSdTransfer(t, true); + } +} + +// One bounded chunk per slot per tick, dispatched to the upload or download leg. +// Mirrors drainPreviewSend/drainStateSend's shape, non-blocking reads/writes throughout. +void HttpServerModule::driveSdTransfers() { + const uint32_t now = platform::millis(); + for (auto& t : sdTransfers_) { + if (!t.active) continue; + if (t.isUpload) driveSdUpload(t, now); + else driveSdDownload(t, now); + } +} + void HttpServerModule::tick1s() MM_NONBLOCKING { const SecretsHidden hide(anyWsClientOnAccessPoint()); // one push serves every client pushStateToWebSockets(); @@ -199,6 +284,23 @@ bool HttpServerModule::anyWsClientOnAccessPoint() const MM_NONBLOCKING { return false; } +// A cheap scan of the request line's query for a "/sd" path, used only to size the Content-Length ceiling before the real path is decoded; parseFilePath is the actual guard, downstream. +static bool requestNamesSdPath(const char* req) { + const char* lineEnd = std::strstr(req, "\r\n"); + const size_t lineLen = lineEnd ? static_cast(lineEnd - req) : std::strlen(req); + for (const char* needle : {"path=/sd", "path=%2Fsd", "path=%2fsd"}) { + const char* hit = std::strstr(req, needle); + if (hit && static_cast(hit - req) < lineLen) return true; + } + return false; +} + +// Whether a decoded API path is rooted at the SD mount: "/sd" or "/sd/...", never "/sdx...". +static bool isSdPath(const char* path) { + return path && path[0] == '/' && path[1] == 's' && path[2] == 'd' && + (path[3] == '\0' || path[3] == '/'); +} + bool HttpServerModule::handleConnection(platform::TcpConnection& conn, int patienceMs) { // A client on the device's own access point has not shown it knows the network's password, so it sees no stored one. const SecretsHidden hide(onAccessPoint(conn)); @@ -230,59 +332,71 @@ bool HttpServerModule::handleConnection(platform::TcpConnection& conn, int patie // The body can land a segment after the headers, so retry briefly; one still incomplete gets 400, never a truncated parse into the permissive JSON helpers. auto* headerEnd = std::strstr(req, "\r\n\r\n"); - int contentLen = 0; // declared body length (0 if no Content-Length); used by the streaming route + size_t contentLen = 0; // declared body length (0 if no Content-Length); used by the streaming route bool hasContentLen = false; // header PRESENT (an explicit 0 is a legitimate empty write) if (headerEnd) { auto* clh = findHeaderCI(req, "Content-Length:"); if (clh) { hasContentLen = true; - // Bounded parse, not atoi: strtol with an end pointer rejects non-numeric, trailing junk, ERANGE and negatives, and the ceiling (8 MB) exceeds any firmware image; failures get 400. - constexpr long kContentLenMax = 8L * 1024 * 1024; + // An /sd upload rides FAT32's own ceiling (4 GiB-1, which is UINT32_MAX exactly) rather than the 8 MiB one below. + // strtoull, not strtoul: `unsigned long` is only 32-bit on a 32-bit target (the P4 is RV32), the same width as the SD ceiling itself, so a truly-oversized value would overflow the parse (ERANGE) before ever reaching the ceiling compare below; `unsigned long long` is guaranteed at least 64-bit everywhere, so the two cases stay distinguishable on every platform. + // requestNamesSdPath is a cheap scan of the request line alone, a sizing heuristic only; parseFilePath is the real path guard, downstream, on the decoded value. + const bool sdUpload = std::strncmp(req, "POST /api/file", 14) == 0 && requestNamesSdPath(req); + const unsigned long long kContentLenMax = sdUpload ? 0xFFFFFFFFULL : 8ULL * 1024 * 1024; const char* valStart = clh + 15; while (*valStart == ' ' || *valStart == '\t') valStart++; // skip OWS after the colon char* valEnd = nullptr; errno = 0; - const long parsed = std::strtol(valStart, &valEnd, 10); + const unsigned long long parsed = std::strtoull(valStart, &valEnd, 10); const bool consumedDigits = valEnd != valStart; const bool endsCleanly = *valEnd == '\r' || *valEnd == '\n' || *valEnd == ' ' || *valEnd == '\t' || *valEnd == '\0'; - if (!consumedDigits || !endsCleanly || errno == ERANGE || - parsed < 0 || parsed > kContentLenMax) { + if (!consumedDigits || !endsCleanly || errno == ERANGE) { sendResponse(conn, 400, "application/json", "{\"error\":\"invalid content-length\"}"); return true; } - contentLen = static_cast(parsed); - int headerSize = static_cast(headerEnd + 4 - req); - int bodyNeeded = headerSize + contentLen; - // Only streaming routes pull a body larger than buf off the socket; every other route parses it whole, so an oversized body gets 413, never a truncated read. - const bool isStreamingRoute = - std::strncmp(req, "POST /api/file", 14) == 0 || - std::strncmp(req, "POST /api/firmware/upload", 25) == 0 || - // A MoonBase image (~750 KB) streams too; the trailing space keeps `moonbase-update-url`, a small JSON body read whole, out of the match. - std::strncmp(req, "POST /api/firmware/moonbase-update ", 35) == 0 || - // A state document holding an effects stack outgrows the request buffer; the trailing space keeps a longer path out. - std::strncmp(req, "PATCH /api/state ", 17) == 0 || - std::strncmp(req, "POST /api/state ", 16) == 0; - if (bodyNeeded > static_cast(sizeof(buf) - 1)) { - if (!isStreamingRoute) { - sendResponse(conn, 413, "application/json", - "{\"error\":\"request body too large\"}"); + // A well-formed number past the ceiling is too large, not malformed: 413, not 400. + if (parsed > kContentLenMax) { + sendResponse(conn, 413, "application/json", + "{\"error\":\"request body too large\"}"); + return true; + } + contentLen = static_cast(parsed); + if (sdUpload) { + // Hand off early: the chunked SD-transfer driver pulls the rest off this same connection, a bounded slice per tick, rather than looping here for the whole body. + } else { + const size_t headerSize = static_cast(headerEnd + 4 - req); + size_t bodyNeeded = headerSize + contentLen; + // Only streaming routes pull a body larger than buf off the socket; every other route parses it whole, so an oversized body gets 413, never a truncated read. + const bool isStreamingRoute = + std::strncmp(req, "POST /api/file", 14) == 0 || + std::strncmp(req, "POST /api/firmware/upload", 25) == 0 || + // A MoonBase image (~750 KB) streams too; the trailing space keeps `moonbase-update-url`, a small JSON body read whole, out of the match. + std::strncmp(req, "POST /api/firmware/moonbase-update ", 35) == 0 || + // A state document holding an effects stack outgrows the request buffer; the trailing space keeps a longer path out. + std::strncmp(req, "PATCH /api/state ", 17) == 0 || + std::strncmp(req, "POST /api/state ", 16) == 0; + if (bodyNeeded > sizeof(buf) - 1) { + if (!isStreamingRoute) { + sendResponse(conn, 413, "application/json", + "{\"error\":\"request body too large\"}"); + return true; + } + bodyNeeded = sizeof(buf) - 1; // streaming: buffer the prefix only + } + for (int empties = 0; totalRead < static_cast(bodyNeeded);) { + int n = conn.read(buf + totalRead, sizeof(buf) - 1 - totalRead); + if (n > 0) { totalRead += n; empties = 0; } + else if (n == 0) break; // peer closed + else { if (++empties > 50) break; platform::delayMs(1); } // ~50 ms for the body + } + buf[totalRead] = 0; + if (totalRead < static_cast(bodyNeeded)) { // body never fully arrived + sendResponse(conn, 400, "application/json", + "{\"error\":\"incomplete request body\"}"); return true; } - bodyNeeded = static_cast(sizeof(buf) - 1); // streaming: buffer the prefix only - } - for (int empties = 0; totalRead < bodyNeeded;) { - int n = conn.read(buf + totalRead, sizeof(buf) - 1 - totalRead); - if (n > 0) { totalRead += n; empties = 0; } - else if (n == 0) break; // peer closed - else { if (++empties > 50) break; platform::delayMs(1); } // ~50 ms for the body - } - buf[totalRead] = 0; - if (totalRead < bodyNeeded) { // body never fully arrived - sendResponse(conn, 400, "application/json", - "{\"error\":\"incomplete request body\"}"); - return true; } } } @@ -647,16 +761,30 @@ void HttpServerModule::streamFsFile(platform::TcpConnection& conn, const char* p "HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %ld\r\n%s" "Connection: close\r\nAccess-Control-Allow-Origin: *\r\n\r\n", mime, size, extraHeaders); if (!conn.write(reinterpret_cast(header), static_cast(hn))) return; + + // An /sd download hands off to the chunked driver, which pulls one bounded slice per tick instead of looping here to completion; the header above already committed to this Content-Length. + if (isSdPath(path)) { + SdTransfer* t = claimSdTransfer(); + if (!t || !platform::fsReadStreamOpen(t->rstream, path)) { conn.close(); return; } + t->isUpload = false; + t->remaining = static_cast(size); + t->bufLen = t->bufSent = 0; + t->lastActivityMs = platform::millis(); + t->active = true; + t->conn = std::move(conn); + return; + } + + platform::FsReadStream rs{}; + if (!platform::fsReadStreamOpen(rs, path)) return; // the header already promised a body; best effort stops here char chunk[1024]; - for (long offset = 0; offset < size;) { - const size_t want = static_cast(size - offset) < sizeof(chunk) - ? static_cast(size - offset) : sizeof(chunk); - const int got = platform::fsReadAt(path, offset, chunk, want); - if (got <= 0) break; // read error / early EOF: the client sees a short (truncated) body + for (;;) { + const int got = platform::fsReadStreamRead(rs, chunk, sizeof(chunk)); + if (got <= 0) break; // read error / clean EOF: the client sees a short (truncated) body on error // write() fails on a socket error or its deadline; stop, since retrying every remaining chunk would burn a deadline each on the render thread. - if (!conn.write(reinterpret_cast(chunk), static_cast(got))) return; - offset += got; + if (!conn.write(reinterpret_cast(chunk), static_cast(got))) break; } + platform::fsReadStreamClose(rs); } void HttpServerModule::serveFileContents(platform::TcpConnection& conn, const char* query) { @@ -773,6 +901,43 @@ void HttpServerModule::handleApplyState(platform::TcpConnection& conn, const cha sendResponse(conn, r.ok ? 200 : 400, "application/json", sink.overflowed() ? "{\"error\":\"out of memory\"}" : sink.data()); } +// The /sd leg of handleWriteFile: claims a transfer slot, opens the stream, writes whatever of the body already landed in the request buffer, then hands the rest to driveSdTransfers a tick at a time. +void HttpServerModule::handleWriteFileSd(platform::TcpConnection& conn, const char* path, + const char* initialBody, size_t initialLen, size_t contentLen) { + // Reject up front if it would not fit the card, rather than failing mid-write; free is taken conservatively, with no credit for an overwrite, and is the only ceiling an SD upload has. + const uint64_t total = platform::sdCardTotal(); + const uint64_t used = platform::sdCardUsed(); + const uint64_t freeBytes = total > used ? total - used : 0; + if (total > 0 && contentLen > freeBytes) { + char msg[96]; + std::snprintf(msg, sizeof(msg), "{\"error\":\"not enough space (%llu free)\"}", + static_cast(freeBytes)); + sendResponse(conn, 507, "application/json", msg); + return; + } + SdTransfer* t = claimSdTransfer(); + if (!t) { + sendResponse(conn, 503, "application/json", "{\"error\":\"another SD transfer is in progress\"}"); + return; + } + if (!platform::fsWriteStreamOpen(t->wstream, path)) { + sendResponse(conn, 500, "application/json", "{\"error\":\"write failed\"}"); + return; + } + // Whatever of the body already arrived in the request buffer lands now; the rest is pulled off the socket a bounded slice per tick, by driveSdTransfers. + const size_t initial = initialLen < contentLen ? initialLen : contentLen; + if (initial > 0 && !platform::fsWriteStreamWrite(t->wstream, initialBody, initial)) { + platform::fsWriteStreamClose(t->wstream, false); + sendResponse(conn, 500, "application/json", "{\"error\":\"write failed\"}"); + return; + } + t->isUpload = true; + t->remaining = contentLen - initial; + t->lastActivityMs = platform::millis(); + t->active = true; + t->conn = std::move(conn); // the response lands once driveSdTransfers sees remaining reach 0 +} + void HttpServerModule::handleWriteFile(platform::TcpConnection& conn, const char* query, const char* initialBody, size_t initialLen, size_t contentLen) { char path[160]; @@ -780,6 +945,10 @@ void HttpServerModule::handleWriteFile(platform::TcpConnection& conn, const char sendResponse(conn, 400, "application/json", "{\"error\":\"bad path\"}"); return; } + if (isSdPath(path)) { + handleWriteFileSd(conn, path, initialBody, initialLen, contentLen); + return; + } if (contentLen > kUploadMax) { sendResponse(conn, 413, "application/json", "{\"error\":\"file too large\"}"); return; diff --git a/src/core/system/HttpServerModule.h b/src/core/system/HttpServerModule.h index e52aa4722..45d9cafda 100644 --- a/src/core/system/HttpServerModule.h +++ b/src/core/system/HttpServerModule.h @@ -41,6 +41,8 @@ class Scheduler; /// A mutation is `POST /api/control` with a module, control and value; `POST /api/modules` to create; `POST /api/modules/{name}/move` to reorder and `.../replace` to swap; `POST /api/reboot`; or `DELETE /api/modules/{name}`. /// /// The File Manager reads and writes through `GET|POST /api/file?path=`, and lists, creates and removes through `/api/dir?path=`. The path rides the query, so a filesystem operation carries its target in the request rather than in a stored control. +/// A board with an SD slot (`SdCardModule`) mounts it at `/sd`, so these same two routes reach the card too. +/// The tree shows it as a folder next to the internal flash. /// /// Every JSON response streams through a `JsonSink`, so there is no fixed-buffer ceiling and a tree of any size serializes correctly. /// @@ -380,6 +382,33 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { platform::TcpConnection parked_[kParkedSlots]; uint32_t parkedAt_[kParkedSlots] = {}; + // A large /sd transfer resumes here across ticks, a bounded slice at a time, drainPreviewSend's shape; flash stays on the synchronous path below, small enough for one tick. + struct SdTransfer { + bool active = false; + bool isUpload = false; + platform::TcpConnection conn; + platform::FsWriteStream wstream; // valid while isUpload + platform::FsReadStream rstream; // valid while !isUpload + size_t remaining = 0; // upload: body bytes still expected; download: bytes left to send + char buf[4096] = {}; // download only: one read chunk, drained across ticks by writeSome + size_t bufLen = 0; + size_t bufSent = 0; + uint32_t lastActivityMs = 0; // a stall guard, not a size or time cap on the transfer itself + }; + static constexpr int kSdTransferSlots = 2; + static constexpr uint32_t kSdTransferStallMs = 10000; // no progress this long: abort, free the slot + SdTransfer sdTransfers_[kSdTransferSlots]; + /// A free slot for a new transfer, or null when every slot is busy. + SdTransfer* claimSdTransfer(); + /// Advance every active slot by one bounded chunk; called once per tick, the drainXxxSend's sibling. + void driveSdTransfers(); + /// One upload slot's bounded chunk: socket to card, then the stall guard. + void driveSdUpload(SdTransfer& t, uint32_t now); + /// One download slot's bounded chunk: card to socket, then the stall guard. + void driveSdDownload(SdTransfer& t, uint32_t now); + /// Close the stream (already done by the caller) and the connection, and free the slot. + void finishSdTransfer(SdTransfer& t, bool closeConn); + ClientMessageSink* clientSink_ = nullptr; // the producer's inbound-message sink (PreviewDriver) @xref{crossing-the-domain-boundary} // One resumable frame: a copied header plus a pointer into the caller's stable body buffer. @xref{why-the-preview-has-its-own-connections} @@ -486,6 +515,9 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { // Streams the remainder off the socket, so an upload of any size never needs one buffer. void handleWriteFile(platform::TcpConnection& conn, const char* query, const char* initialBody, size_t initialLen, size_t contentLen); + // The /sd leg of handleWriteFile: claims a transfer slot and hands the rest to driveSdTransfers. + void handleWriteFileSd(platform::TcpConnection& conn, const char* path, + const char* initialBody, size_t initialLen, size_t contentLen); // One directory's children, single-level, which is what the lazy tree loads a node from. void serveDirListing(platform::TcpConnection& conn, const char* query); void handleMakeDir(platform::TcpConnection& conn, const char* query); // POST /api/dir?path= diff --git a/src/core/system/SdCardModule.cpp b/src/core/system/SdCardModule.cpp new file mode 100644 index 000000000..70f9d49cb --- /dev/null +++ b/src/core/system/SdCardModule.cpp @@ -0,0 +1,101 @@ +/// @defgroup sd_card_impl SD Card implementation +/// The microSD slot's pins, mount and format button; the files it holds are browsed through the File Manager's existing routes, not a surface of this module's own. +/// Public surface and class layout live in SdCardModule.h. +/// @{ +#include "core/system/SdCardModule.h" + +#include "platform/platform.h" // sdMount/sdUnmount/sdFormat/sdCardUsed/sdCardTotal, hasSdCard + +#include // strcmp + +namespace mm { + +void SdCardModule::defineControls() { + // Hidden on a chip with no SDMMC hardware, the same gate AudioService uses for its own mic pins; hidden stays bound, so a value survives a board change. + controls_.addPin("clkPin", clkPin_); + controls_.setHidden(controls_.count() - 1, !platform::hasSdCard); + controls_.addPin("cmdPin", cmdPin_); + controls_.setHidden(controls_.count() - 1, !platform::hasSdCard); + controls_.addPin("d0Pin", d0Pin_); + controls_.setHidden(controls_.count() - 1, !platform::hasSdCard); + controls_.addPin("d1Pin", d1Pin_); + controls_.setHidden(controls_.count() - 1, !platform::hasSdCard); + controls_.addPin("d2Pin", d2Pin_); + controls_.setHidden(controls_.count() - 1, !platform::hasSdCard); + controls_.addPin("d3Pin", d3Pin_); + controls_.setHidden(controls_.count() - 1, !platform::hasSdCard); + controls_.addPin("pwrPin", pwrPin_); + controls_.setHidden(controls_.count() - 1, !platform::hasSdCard); + + // Neither is hidden: there is no panel of this module's own, so the generic card is the only place either shows. Press-twice confirmed generically, the same contract every other confirm-gated button already has. + controls_.addButton("format", /*confirm=*/true); + // Usage gauge, bound only once mounted: an absent/unreadable card has nothing to show; KiB is in the name since the shared Progress control's own label assumes bytes. + if (mounted_ && totalKiB_ > 0) controls_.addProgress("card (KiB)", usedKiB_, totalKiB_, /*bytes=*/false); + MoonModule::defineControls(); +} + +bool SdCardModule::affectsPrepare(const char* name) const { + return std::strcmp(name, "clkPin") == 0 || std::strcmp(name, "cmdPin") == 0 || + std::strcmp(name, "d0Pin") == 0 || std::strcmp(name, "d1Pin") == 0 || + std::strcmp(name, "d2Pin") == 0 || std::strcmp(name, "d3Pin") == 0 || + std::strcmp(name, "pwrPin") == 0; +} + +void SdCardModule::prepare() { + const bool wasMounted = mounted_; + // A pin edit lands here too: unmount what an earlier wiring opened before trying the new one. + if (mounted_) { platform::sdUnmount(); mounted_ = false; } + + if constexpr (!platform::hasSdCard) { + setStatus("no SD hardware on this build", Severity::Status); + } else if (clkPin_ < 0 || cmdPin_ < 0 || d0Pin_ < 0) { + setStatus("set the SD card pins first", Severity::Warning); + } else { + platform::SdPinConfig pins{}; + pins.clk = clkPin_; + pins.cmd = cmdPin_; + pins.d0 = d0Pin_; + pins.d1 = d1Pin_; + pins.d2 = d2Pin_; + pins.d3 = d3Pin_; + pins.pwr = pwrPin_; + mounted_ = platform::sdMount(pins); + if (mounted_) { refreshGauge(); clearStatus(); } + else setStatus("mount failed: check the card is inserted and formatted FAT32", Severity::Error); + } + // defineControls() ran with the OLD mounted_, so its "mounted_ && totalKiB_ > 0" gate above saw a stale value either way the state just moved; one rebuild picks up or drops the gauge. + if (mounted_ != wasMounted) rebuildControls(); +} + +void SdCardModule::release() { + if (mounted_) { platform::sdUnmount(); mounted_ = false; } + MoonModule::release(); +} + +void SdCardModule::onControlChanged(const char* controlName) { + if (std::strcmp(controlName, "format") != 0) return; + if (!mounted_) { setStatus("mount a card before formatting", Severity::Warning); markDirty(); return; } + if (!platform::sdFormat()) { setStatus("format failed", Severity::Error); markDirty(); return; } + refreshGauge(); + clearStatus(); + markDirty(); // push the fresh usage figures to the UI +} + +void SdCardModule::tick1s() MM_NONBLOCKING { + // Once a minute, not once a second: the same reasoning as FileManagerModule's own scan. + if (!mounted_) return; + if (++secondsSinceScan_ < 60) return; + secondsSinceScan_ = 0; + refreshGauge(); +} + +void SdCardModule::refreshGauge() { + totalKiB_ = static_cast(platform::sdCardTotal() / 1024); + usedKiB_ = static_cast(platform::sdCardUsed() / 1024); +} + +// mkdir/delete/read/write are the File Manager's existing HTTP endpoints, against paths under /sd; this module holds no op state and persists nothing itself. + +} // namespace mm + +/// @} diff --git a/src/core/system/SdCardModule.h b/src/core/system/SdCardModule.h new file mode 100644 index 000000000..4530a554a --- /dev/null +++ b/src/core/system/SdCardModule.h @@ -0,0 +1,73 @@ +#pragma once + +#include "core/module/MoonModule.h" + +#include + +namespace mm { + +/// A board's microSD slot: pins, a usage gauge and a format button. Its files appear under `/sd` in the one File Manager tree, not a panel of their own. +/// +/// A Services child, added only to a board's catalog entry, the way AudioService's mic pins are. +/// @card SdCardModule.png +/// +/// @moreinfo +/// +/// ## The pins are runtime configuration, like AudioService's mic +/// +/// `clkPin`/`cmdPin`/`d0Pin`-`d3Pin`/`pwrPin` default to -1, unset; a board's catalog entry sets the ones it wires. +/// `platform::hasSdCard` reports whether this chip has SDMMC hardware at all, and hides the pin controls where it does not. +/// `d1Pin`-`d3Pin` are optional: leaving them unset drops the bus to 1-bit mode instead of 4-bit. +/// +/// ## The mount re-attempts on every pin change, like the bus it rides +/// +/// `prepare()`, not `setup()`: a pin edit unmounts and remounts on the new wiring, the same live-reconfiguration contract I2cBusModule's sda/scl have. +/// A missing or unreadable card shows as its own status rather than retried every tick; reinsert and touch a pin field (or reboot) to retry. +/// +/// ## No panel, no view toggle +/// +/// The card mounts at `/sd`, so the existing File Manager tree already shows it; this module owns only pins, the gauge and format. +/// +/// ## Why KiB, not the shared Progress control's bytes +/// +/// A real card's byte count overruns the `uint32_t` the shared Progress control carries past 4 GiB. +/// `platform::sdCardUsed`/`Total` are `uint64_t` for exactly that, and this module narrows to KiB before handing the gauge its numbers, well inside range for any card size in practice. +/// +/// Prior art: I2cBusModule's live pin reconfiguration; FileManagerModule's usage gauge. +class SdCardModule : public MoonModule { +public: + /// A Services child, same as AudioService: the catalog entry attaches it under "Services". + ModuleRole role() const MM_NONBLOCKING override { return ModuleRole::Service; } + /// Declare the pins, the usage gauge and the format button. + void defineControls() override; + /// A pin edit remounts the card on its new wiring, same as the bus it rides. + bool affectsPrepare(const char* name) const override; + /// Mount the card, if this chip has the hardware and the pins are set. + void prepare() override; + /// Unmount this instance's card, if it mounted one. + void release() override; + /// Run a format when the button is pressed. + void onControlChanged(const char* controlName) override; + /// Refresh the usage gauge, which walks the card on a slow cadence. + void tick1s() MM_NONBLOCKING override; + +private: + bool mounted_ = false; ///< true once platform::sdMount() has succeeded + uint32_t usedKiB_ = 0; ///< card usage, in KiB: @xref{why-kib-not-the-shared-progress-controls-bytes} + uint32_t totalKiB_ = 0; ///< the card's capacity, in KiB, read once on mount + uint8_t secondsSinceScan_ = 0; ///< paces the usage scan + + // Unused by default, so a chip with no card wired claims no GPIO; a board's catalog entry sets the ones it wires. + int8_t clkPin_ = -1; ///< SDMMC clock + int8_t cmdPin_ = -1; ///< SDMMC command line + int8_t d0Pin_ = -1; ///< SDMMC data line 0, required + int8_t d1Pin_ = -1; ///< SDMMC data line 1; unset alongside d2/d3 drops the bus to 1-bit mode + int8_t d2Pin_ = -1; ///< SDMMC data line 2 + int8_t d3Pin_ = -1; ///< SDMMC data line 3 + int8_t pwrPin_ = -1; ///< a slot's card-power switch, or -1 where the slot has none + + /// Refresh the gauge fields from the mounted card's real usage. + void refreshGauge(); +}; + +} // namespace mm diff --git a/src/module_types.cpp b/src/module_types.cpp index edc9d0726..79419b07d 100644 --- a/src/module_types.cpp +++ b/src/module_types.cpp @@ -188,6 +188,7 @@ #include "core/services/InfraredService.h" #include "core/services/MoonLiveService.h" #include "core/system/FileManagerModule.h" +#include "core/system/SdCardModule.h" #include "core/system/FirmwareUpdateModule.h" #include "core/system/MoonCloudModule.h" #include "core/system/MoonStatsModule.h" @@ -366,6 +367,7 @@ void mm::registerModuleTypes() { mm::ModuleFactory::registerType("InfraredService", "core/services.md#infrared"); mm::ModuleFactory::registerType("MoonLiveService", "core/services.md#moonliveservice"); mm::ModuleFactory::registerType("FileManagerModule", "core/system.md#file-manager"); + mm::ModuleFactory::registerType("SdCardModule", "core/system.md#sd-card"); mm::ModuleFactory::registerType("FirmwareUpdateModule", "core/system.md#firmware-update"); mm::ModuleFactory::registerType("MoonCloudModule", "core/system.md#mooncloud"); mm::ModuleFactory::registerType("MoonStatsModule", "core/system.md#stats"); @@ -400,6 +402,7 @@ constexpr BootModule kBootModules[] = { {"SystemModule", "I2cBusModule", nullptr, true}, {"SystemModule", "PinsModule", nullptr, true}, {"Services", "AudioService", nullptr, true}, + {"Services", "SdCardModule", nullptr, true}, {"NetworkModule", "EthernetModule", nullptr, kEthernetCard}, {"NetworkModule", "WiFiModule", nullptr, mm::platform::hasWiFi}, {"NetworkModule", "AccessPointModule", nullptr, mm::platform::hasWiFi}, diff --git a/src/platform/desktop/platform_config.h b/src/platform/desktop/platform_config.h index 33d0f69d4..715084dec 100644 --- a/src/platform/desktop/platform_config.h +++ b/src/platform/desktop/platform_config.h @@ -130,6 +130,9 @@ constexpr bool hasOta = false; /// False: no UART and no WiFi stack, so the provisioning listener is never installed. constexpr bool hasImprov = false; +/// True: unlike every other board gate here, the mount/remount/format logic under test needs a real host path, not a stub. +constexpr bool hasSdCard = true; + /// @} } // namespace mm::platform diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 04fa47142..967d28d18 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -128,6 +128,11 @@ /// Stop TERMs the child BEFORE joining, since a writer blocked in write() unblocks reliably only when the read side dies (EPIPE). /// The child comes from posix_spawn, since fork in a threaded process can deadlock on the allocator lock. Every fd but stdin is closed, or a leaked HTTP socket outlives a restart. /// A restart stops the old writer BEFORE resizing, because the writer reads a slot's data pointer without encMutex_. +/// +/// ## Why SD is a subfolder, not a second root +/// +/// A host has no second card to mount, but the mount/remount/format logic under test needs a real path to act on, not a stub that always answers the same way. +/// A subfolder of the one real root is that path: `sdMounted_` gates it exactly as a missing card would, and format is an ordinary wipe-and-recreate. #include "platform/platform.h" #include "core/util/FirmwareImage.h" // identify/moonBaseRejection: shared image vetting @@ -862,11 +867,19 @@ std::filesystem::path defaultRoot() { std::filesystem::path fsRoot_{defaultRoot()}; +// No real card on a host, so "/sd" is a subfolder of the same root, gated by this flag the way a missing card gates the real mount: @xref{why-sd-is-a-subfolder-not-a-second-root}. +bool sdMounted_ = false; + +/// Whether `path` (leading slashes already stripped) names the SD mount: "sd" or "sd/...". +bool isSdApiPath(const char* path) { + return path[0] == 's' && path[1] == 'd' && (path[2] == '\0' || path[2] == '/'); +} // Map an API path onto the root, normalize it, and reject one that escapes; an empty result reads as failure, which callers already handle. std::filesystem::path toFsPath(const char* path) { if (!path) return {}; while (*path == '/') path++; // strip any number of leading slashes + if (!sdMounted_ && isSdApiPath(path)) return {}; // mirrors a real card not inserted std::filesystem::path candidate = (fsRoot_ / path).lexically_normal(); std::filesystem::path rootNormal = fsRoot_.lexically_normal(); // A prefix check on the normalized string, compared by iterator so a trailing separator cannot change the answer. @@ -1041,8 +1054,77 @@ bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { return true; } +bool fsReadStreamOpen(FsReadStream& h, const char* path) { + FILE* f = std::fopen(toFsPath(path).string().c_str(), "rb"); + if (!f) return false; + h.impl = f; + return true; +} + +int fsReadStreamRead(FsReadStream& h, char* buf, size_t len) { + auto* f = static_cast(h.impl); + if (!f) return -1; + const size_t n = std::fread(buf, 1, len, f); + if (n == 0 && std::ferror(f)) return -1; + return static_cast(n); +} + +void fsReadStreamClose(FsReadStream& h) { + if (h.impl) std::fclose(static_cast(h.impl)); + h.impl = nullptr; +} + +namespace { +// Both paths live alongside the FILE*, so close() can rename without re-deriving them. +struct DesktopWriteHandle { FILE* f = nullptr; std::filesystem::path target, tmp; }; +} + +bool fsWriteStreamOpen(FsWriteStream& h, const char* path) { + auto target = toFsPath(path); + if (target.empty()) return false; + makeParent(target); + auto* wh = new DesktopWriteHandle(); + wh->target = target; + wh->tmp = target; + wh->tmp += ".tmp"; + wh->f = openOwnerOnly(wh->tmp.string().c_str()); + if (!wh->f) { delete wh; return false; } + h.impl = wh; + return true; +} + +bool fsWriteStreamWrite(FsWriteStream& h, const char* buf, size_t len) { + auto* wh = static_cast(h.impl); + if (!wh) return false; + return std::fwrite(buf, 1, len, wh->f) == len; +} + +bool fsWriteStreamClose(FsWriteStream& h, bool commit) { + auto* wh = static_cast(h.impl); + if (!wh) return false; + std::fflush(wh->f); +#ifdef _WIN32 + int fd = ::_fileno(wh->f); + if (fd >= 0) ::_commit(fd); +#else + int fd = ::fileno(wh->f); + if (fd >= 0) ::fsync(fd); +#endif + std::fclose(wh->f); + std::error_code ec; + bool ok = true; + if (commit) { std::filesystem::rename(wh->tmp, wh->target, ec); ok = !ec; } + if (!commit || !ok) std::filesystem::remove(wh->tmp, ec); + delete wh; + h.impl = nullptr; + return ok; +} + void fsList(const char* dir, FsListCb cb, void* user) { if (!cb) return; + // The card is a second mount everywhere except on desktop's own fiction, where sdMount() made it a real subdirectory; the root listing advertises it by hand and skips the real one underneath. + const bool isRoot = dir && (dir[0] == '\0' || (dir[0] == '/' && dir[1] == '\0')); + if (hasSdCard && isRoot) cb("sd", true, 0, user); std::error_code ec; auto p = toFsPath(dir); if (!std::filesystem::exists(p, ec)) return; @@ -1050,6 +1132,7 @@ void fsList(const char* dir, FsListCb cb, void* user) { if (ec) break; // The filename's native character type differs per platform, so round-trip through a string for a portable view. std::string name = entry.path().filename().string(); + if (isRoot && hasSdCard && name == "sd") continue; // already emitted above; this is its real backing folder const bool isDir = entry.is_directory(ec); std::error_code sizeEc; const auto sz = isDir ? 0u : static_cast(std::filesystem::file_size(entry.path(), sizeEc)); @@ -1077,6 +1160,51 @@ size_t filesystemTotal() { return 384 * 1024; } +namespace { +// A fictional card size for the host; a test can shrink it to reach the 507 "card full" path. +uint64_t sdCardTotalForTest_ = 1024ULL * 1024 * 1024; +} + +bool sdMount(const SdPinConfig& pins) { + if (sdMounted_) return true; + if (pins.clk < 0 || pins.cmd < 0 || pins.d0 < 0) return false; + std::error_code ec; + std::filesystem::create_directories(fsRoot_ / "sd", ec); + if (ec) return false; + sdMounted_ = true; + return true; +} + +void sdUnmount() { sdMounted_ = false; } + +bool sdFormat() { + if (!sdMounted_) return false; + std::error_code ec; + std::filesystem::remove_all(fsRoot_ / "sd", ec); + std::filesystem::create_directories(fsRoot_ / "sd", ec); + return !ec; +} + +uint64_t sdCardUsed() { + if (!sdMounted_) return 0; + std::error_code ec; + const auto root = fsRoot_ / "sd"; + if (!std::filesystem::exists(root, ec)) return 0; + uint64_t total = 0; + for (auto& entry : std::filesystem::recursive_directory_iterator(root, ec)) { + if (ec) break; + if (entry.is_regular_file(ec)) total += entry.file_size(ec); + } + return total; +} + +uint64_t sdCardTotal() { return sdMounted_ ? sdCardTotalForTest_ : 0; } + +#ifndef ESP_PLATFORM +/// Shrink the fictional card, so a test can reach the "not enough space" path without writing a real gigabyte. +void setTestSdCardTotal(uint64_t bytes) { sdCardTotalForTest_ = bytes; } +#endif + // Network stubs (desktop has no WiFi/Ethernet hardware) void setEthConfig(const EthPinConfig&) {} // no eth on desktop; ethInit stubs false diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index 6a5318b80..daeadd16a 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -20,6 +20,11 @@ /// /// The EMAC's data pads are silicon's choice, so one list serves both the driver init and the pin map. /// A pad nobody declares reads as free while the MAC drives it: an LED lane on GPIO 10 corrupted every frame sent, with the link still reporting 1 Gb. +/// +/// ## Only ship it where there is flash to spare +/// +/// `CONFIG_SOC_SDMMC_HOST_SUPPORTED` names every chip with the host, classic ESP32 included, whose fixed pins (2, 4, 12, 13, 14, 15) make it impractical here anyway. +/// `CONFIG_MM_SD_CARD` is the second, project-level gate: set only by the boards with flash to spare (P4, S31, the 16 MB S3), the same two-gate shape `CONFIG_MM_USB_MIDI_HOST` already uses. #include "sdkconfig.h" @@ -277,6 +282,13 @@ constexpr bool hasOta = true; /// True on every ESP32: the serial RPC channel is always there, so the listener always runs. constexpr bool hasImprov = true; +/// Chip capability AND flash to spare: @xref{only-ship-it-where-there-is-flash-to-spare|why the second gate}. +#if defined(CONFIG_SOC_SDMMC_HOST_SUPPORTED) && defined(CONFIG_MM_SD_CARD) +constexpr bool hasSdCard = true; +#else +constexpr bool hasSdCard = false; +#endif + /// @} } // namespace mm::platform diff --git a/src/platform/esp32/platform_esp32_fs.cpp b/src/platform/esp32/platform_esp32_fs.cpp index c05a0be26..39279a7ae 100644 --- a/src/platform/esp32/platform_esp32_fs.cpp +++ b/src/platform/esp32/platform_esp32_fs.cpp @@ -15,6 +15,7 @@ /// Gating it on the last entry in the array rather than the last one present left a fresh board unformatted, running with persistence disabled. #include "platform/platform.h" +#include "platform_esp32_sdmmc.h" // every /sd path dispatches here instead of LittleFS #include "esp_littlefs.h" #include "esp_partition.h" // esp_partition_find_first: probing which FS label this table uses @@ -29,6 +30,13 @@ namespace mm::platform { +/// Whether an API path names the SD mount: "/sd" or "/sd/...", never "/sdx...". +static bool isSdApiPath(const char* path) { + if (!path) return false; + if (*path == '/') path++; + return path[0] == 's' && path[1] == 'd' && (path[2] == '\0' || path[2] == '/'); +} + // LittleFS state static constexpr const char* FS_TAG = "mm_fs"; // Both labels, in order: @xref{two-partition-labels-tried-in-order|why two}. @@ -95,6 +103,7 @@ void fsUnmount() { } bool fsMkdir(const char* path) { + if (isSdApiPath(path)) return sdCardMkdir(path); if (!fsMounted_) return false; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return false; @@ -113,6 +122,7 @@ bool fsMkdir(const char* path) { } bool fsExists(const char* path) { + if (isSdApiPath(path)) return sdCardExists(path); if (!fsMounted_) return false; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return false; @@ -121,6 +131,7 @@ bool fsExists(const char* path) { } bool fsRemove(const char* path) { + if (isSdApiPath(path)) return sdCardRemove(path); if (!fsMounted_) return false; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return false; @@ -131,6 +142,7 @@ bool fsRemove(const char* path) { } int fsRead(const char* path, char* buf, size_t maxLen) { + if (isSdApiPath(path)) return sdCardRead(path, buf, maxLen); if (!fsMounted_ || !buf || maxLen == 0) return -1; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return -1; @@ -156,6 +168,7 @@ static void makeParent(const char* path) { } bool fsWriteAtomic(const char* path, const char* data, size_t len) { + if (isSdApiPath(path)) return sdCardWriteAtomic(path, data, len); if (!fsMounted_) return false; // A non-zero length with a null pointer is undefined in the write below, so guard the boundary. if (len > 0 && !data) return false; @@ -187,6 +200,7 @@ bool fsWriteAtomic(const char* path, const char* data, size_t len) { } int fsReadAt(const char* path, long offset, char* buf, size_t len) { + if (isSdApiPath(path)) return sdCardReadAt(path, offset, buf, len); if (!fsMounted_ || !buf) return -1; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return -1; @@ -199,6 +213,7 @@ int fsReadAt(const char* path, long offset, char* buf, size_t len) { } long fsSize(const char* path) { + if (isSdApiPath(path)) return sdCardSize(path); if (!fsMounted_) return -1; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return -1; @@ -208,6 +223,7 @@ long fsSize(const char* path) { } bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { + if (isSdApiPath(path)) return sdCardWriteStreamCb(path, src, user); if (!fsMounted_ || !src) return false; makeParent(path); char full[128]; @@ -236,7 +252,10 @@ bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { } void fsList(const char* dir, FsListCb cb, void* user) { + if (isSdApiPath(dir)) { sdCardList(dir, cb, user); return; } if (!fsMounted_ || !cb) return; + // The card is a second mount, not a real subdirectory, so the root listing advertises it by hand. + if (hasSdCard && dir && (dir[0] == '\0' || (dir[0] == '/' && dir[1] == '\0'))) cb("sd", true, 0, user); char full[128]; if (!fsTranslate(dir, full, sizeof(full))) return; DIR* d = ::opendir(full); @@ -255,6 +274,92 @@ void fsList(const char* dir, FsListCb cb, void* user) { ::closedir(d); } +// A LittleFS stream keeps both paths so close() can rename without re-deriving them; an SD one is owned and typed by platform_esp32_sdmmc.cpp, reached only through its own sdCardXxx calls below. +struct StreamImpl { + bool isSd = false; + FILE* file = nullptr; + void* sdHandle = nullptr; + char full[128] = {}; // write side only + char tmp[136] = {}; +}; + +bool fsReadStreamOpen(FsReadStream& h, const char* path) { + auto* impl = new StreamImpl(); + if (isSdApiPath(path)) { + impl->isSd = true; + if (!sdCardReadStreamOpen(impl->sdHandle, path)) { delete impl; return false; } + } else { + char full[128]; + if (!fsMounted_ || !fsTranslate(path, full, sizeof(full))) { delete impl; return false; } + impl->file = std::fopen(full, "rb"); + if (!impl->file) { delete impl; return false; } + } + h.impl = impl; + return true; +} + +int fsReadStreamRead(FsReadStream& h, char* buf, size_t len) { + auto* impl = static_cast(h.impl); + if (!impl) return -1; + if (impl->isSd) return sdCardReadStreamRead(impl->sdHandle, buf, len); + const size_t n = std::fread(buf, 1, len, impl->file); + if (n == 0 && std::ferror(impl->file)) return -1; + return static_cast(n); +} + +void fsReadStreamClose(FsReadStream& h) { + auto* impl = static_cast(h.impl); + if (!impl) return; + if (impl->isSd) sdCardReadStreamClose(impl->sdHandle); + else if (impl->file) std::fclose(impl->file); + delete impl; + h.impl = nullptr; +} + +bool fsWriteStreamOpen(FsWriteStream& h, const char* path) { + auto* impl = new StreamImpl(); + if (isSdApiPath(path)) { + impl->isSd = true; + if (!sdCardWriteStreamOpen(impl->sdHandle, path)) { delete impl; return false; } + } else { + makeParent(path); + if (!fsMounted_ || !fsTranslate(path, impl->full, sizeof(impl->full))) { delete impl; return false; } + const int n = std::snprintf(impl->tmp, sizeof(impl->tmp), "%s.tmp", impl->full); + if (n < 0 || static_cast(n) >= sizeof(impl->tmp)) { delete impl; return false; } + impl->file = std::fopen(impl->tmp, "wb"); + if (!impl->file) { delete impl; return false; } + } + h.impl = impl; + return true; +} + +bool fsWriteStreamWrite(FsWriteStream& h, const char* buf, size_t len) { + auto* impl = static_cast(h.impl); + if (!impl) return false; + if (impl->isSd) return sdCardWriteStreamWrite(impl->sdHandle, buf, len); + return std::fwrite(buf, 1, len, impl->file) == len; +} + +bool fsWriteStreamClose(FsWriteStream& h, bool commit) { + auto* impl = static_cast(h.impl); + if (!impl) return false; + bool ok; + if (impl->isSd) { + ok = sdCardWriteStreamClose(impl->sdHandle, commit); // owns its own rename/discard + } else { + std::fflush(impl->file); + const int fd = ::fileno(impl->file); + if (fd >= 0) ::fsync(fd); + std::fclose(impl->file); + ok = true; + if (commit) ok = ::rename(impl->tmp, impl->full) == 0; + if (!commit || !ok) ::remove(impl->tmp); + } + delete impl; + h.impl = nullptr; + return ok; +} + size_t filesystemUsed() { if (!fsMounted_) return 0; size_t total = 0, used = 0; diff --git a/src/platform/esp32/platform_esp32_sdmmc.cpp b/src/platform/esp32/platform_esp32_sdmmc.cpp new file mode 100644 index 000000000..8b69b6003 --- /dev/null +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -0,0 +1,442 @@ +/// @defgroup platform_esp32_sdmmc SD card mount and the `/sd` backend +/// The microSD mount sequence and the file API behind every `/sd`-prefixed `fsXxx` call. +/// +/// Dispatch from the shared `fsXxx` surface lives in platform_esp32_fs.cpp; this file owns only the card itself. +/// +/// @moreinfo +/// +/// ## The pins are a runtime argument, not a board constant +/// +/// `sdMount()` takes an `SdPinConfig`, so no board gets its own `#ifdef`; `platform::hasSdCard` gates only on whether this chip has the hardware at all. +/// The bus width follows from which data lines are set: `d1`/`d2`/`d3` unset drops it to 1-bit mode, all three set is 4-bit. +/// +/// ## Built field by field +/// +/// `SDMMC_SLOT_CONFIG_DEFAULT()` hardwires `d4` to a fixed GPIO, an 8-bit-mode line this driver never uses in 1- or 4-bit mode. +/// On the Waveshare P4-ETH that pin is the card's own power switch, so the macro is skipped and the slot config is built field by field instead. +/// +/// ## The power switch is driven before the mount +/// +/// `pwr`, where a board wires one, is a plain GPIO output set low (P-channel high-side-switch convention) before `esp_vfs_fat_sdmmc_mount`, and high again on unmount or a failed mount. +/// +/// ## Internal pullups on CMD/D0-D3 +/// +/// `SDMMC_SLOT_FLAG_INTERNAL_PULLUP` is set unconditionally, matching the reference wiring; harmless where the lines already have external pull-ups. +/// +/// ## The chip's own IO power domain, where one exists +/// +/// A `SOC_SDMMC_IO_POWER_EXTERNAL` chip's SDMMC IO lines are powered by an on-chip LDO the driver must turn on itself; this is a chip fact, not a board wiring choice. +/// +/// ## CD and WP read as GPIO0 unless told otherwise +/// +/// A zero-initialized slot config leaves `.cd`/`.wp` at GPIO0, which the driver then reads as a real write-protect signal and rejects every write. +/// Building the config field by field means the macro's own `SDMMC_SLOT_NO_CD`/`NO_WP` defaults are skipped too, so both are set explicitly here. +/// +/// ## Drive 0 is assumed, not queried +/// +/// File access below is raw FatFs (`f_open`/`f_read`/`f_write`), not stdio through the VFS mount, to drop stdio's share of the flash cost. +/// `esp_vfs_fat_sdmmc_mount` assigns the FatFs drive number dynamically and never returns it, so there is no public call to ask which one this mount got. +/// Drive 0 is assumed because nothing else in this firmware registers a FatFs volume (LittleFS is a separate component); if that ever changes, this assumption needs revisiting. +/// @{ + +#include "platform_esp32_sdmmc.h" + +#include "sdkconfig.h" +#include "esp_log.h" +#include "driver/gpio.h" + +#include // snprintf only; file I/O below is raw FatFs, not stdio +#include + +#if defined(CONFIG_SOC_SDMMC_HOST_SUPPORTED) && defined(CONFIG_MM_SD_CARD) +#define MM_HAS_SDMMC 1 +#include "driver/sdmmc_host.h" +#include "sdmmc_cmd.h" +#include "esp_vfs_fat.h" +#include "ff.h" +#include "soc/soc_caps.h" +#if SOC_SDMMC_IO_POWER_EXTERNAL +#include "sd_pwr_ctrl_by_on_chip_ldo.h" +#endif +#endif + +namespace mm::platform { + +#if MM_HAS_SDMMC + +static constexpr const char* SD_TAG = "mm_sd"; +static constexpr const char* SD_MOUNT_POINT = "/sdcard"; // the VFS registration below needs one, even though raw FatFs calls never go through it +// Raw FatFs addresses a volume by drive number, not the VFS path above: @xref{drive-0-is-assumed-not-queried}. +static constexpr const char* SD_DRIVE = "0:"; + +static sdmmc_card_t* g_card = nullptr; +static bool g_mounted = false; +static int8_t g_pwrGpio = -1; // so unmount powers back down what mount powered up +#if SOC_SDMMC_IO_POWER_EXTERNAL +static sd_pwr_ctrl_handle_t g_pwrCtrl = nullptr; +#endif + +/// Strip the leading `/sd` and map what remains onto the FatFs drive path (`FF_FS_RPATH` is off, so every call needs the full `0:/...` form); false on truncation. +static bool sdTranslate(const char* apiPath, char* out, size_t outLen) { + if (outLen == 0 || !apiPath) return false; + const char* p = apiPath; + if (*p == '/') p++; + p += 2; // the caller already confirmed this is "sd" or "sd/..." + if (*p == '/') p++; + const int n = std::snprintf(out, outLen, "%s/%s", SD_DRIVE, p); + if (n < 0 || static_cast(n) >= outLen) { out[0] = 0; return false; } + return true; +} + +bool sdCardMounted() { return g_mounted; } + +/// A pin this build never wires reads as -1; one it does but the chip refuses is a mount-time mistake, not UB. +static bool validOutputPin(int8_t pin) { + return pin >= 0 && GPIO_IS_VALID_OUTPUT_GPIO(pin); +} + +bool sdMount(const SdPinConfig& pins) { + if (g_mounted) return true; + if (!validOutputPin(pins.clk) || !validOutputPin(pins.cmd) || !validOutputPin(pins.d0)) return false; + const bool fourBit = pins.d1 >= 0 && pins.d2 >= 0 && pins.d3 >= 0; + if (fourBit && (!validOutputPin(pins.d1) || !validOutputPin(pins.d2) || !validOutputPin(pins.d3))) return false; + if (pins.pwr >= 0 && !validOutputPin(pins.pwr)) return false; + + // Powered before anything talks to the slot, where this board wires a switch: @xref{the-power-switch-is-driven-before-the-mount}. + if (pins.pwr >= 0) { + gpio_config_t pwrCfg = {}; + pwrCfg.pin_bit_mask = 1ULL << pins.pwr; + pwrCfg.mode = GPIO_MODE_OUTPUT; + gpio_config(&pwrCfg); + gpio_set_level(static_cast(pins.pwr), 0); // LOW = enabled + } + g_pwrGpio = pins.pwr; + + esp_vfs_fat_sdmmc_mount_config_t mountCfg = {}; + mountCfg.format_if_mount_failed = false; // a blank/foreign card stays untouched + mountCfg.max_files = 5; + mountCfg.allocation_unit_size = 16 * 1024; + + sdmmc_host_t host = SDMMC_HOST_DEFAULT(); + +#if SOC_SDMMC_IO_POWER_EXTERNAL + sd_pwr_ctrl_ldo_config_t ldoCfg = {}; + ldoCfg.ldo_chan_id = 4; + const esp_err_t ldoErr = sd_pwr_ctrl_new_on_chip_ldo(&ldoCfg, &g_pwrCtrl); + if (ldoErr != ESP_OK) { + ESP_LOGE(SD_TAG, "SD LDO power control failed: %s", esp_err_to_name(ldoErr)); + if (g_pwrGpio >= 0) gpio_set_level(static_cast(g_pwrGpio), 1); + return false; + } + host.pwr_ctrl_handle = g_pwrCtrl; +#endif + + // Field by field, not SDMMC_SLOT_CONFIG_DEFAULT(): @xref{built-field-by-field}. + sdmmc_slot_config_t slotCfg = {}; + slotCfg.clk = static_cast(pins.clk); + slotCfg.cmd = static_cast(pins.cmd); + slotCfg.d0 = static_cast(pins.d0); + if (fourBit) { + slotCfg.d1 = static_cast(pins.d1); + slotCfg.d2 = static_cast(pins.d2); + slotCfg.d3 = static_cast(pins.d3); + } + slotCfg.width = fourBit ? 4 : 1; + slotCfg.cd = SDMMC_SLOT_NO_CD; // @xref{cd-and-wp-read-as-gpio0-unless-told-otherwise} + slotCfg.wp = SDMMC_SLOT_NO_WP; + slotCfg.flags |= SDMMC_SLOT_FLAG_INTERNAL_PULLUP; + + const esp_err_t err = esp_vfs_fat_sdmmc_mount(SD_MOUNT_POINT, &host, &slotCfg, &mountCfg, &g_card); + if (err != ESP_OK) { + ESP_LOGE(SD_TAG, "SD mount failed: %s", esp_err_to_name(err)); +#if SOC_SDMMC_IO_POWER_EXTERNAL + sd_pwr_ctrl_del_on_chip_ldo(g_pwrCtrl); + g_pwrCtrl = nullptr; +#endif + if (g_pwrGpio >= 0) gpio_set_level(static_cast(g_pwrGpio), 1); + g_card = nullptr; + return false; + } + + g_mounted = true; + ESP_LOGI(SD_TAG, "SD card mounted at %s", SD_MOUNT_POINT); + return true; +} + +void sdUnmount() { + if (!g_mounted) return; + esp_vfs_fat_sdcard_unmount(SD_MOUNT_POINT, g_card); +#if SOC_SDMMC_IO_POWER_EXTERNAL + if (g_pwrCtrl) { sd_pwr_ctrl_del_on_chip_ldo(g_pwrCtrl); g_pwrCtrl = nullptr; } +#endif + if (g_pwrGpio >= 0) gpio_set_level(static_cast(g_pwrGpio), 1); + g_card = nullptr; + g_mounted = false; +} + +bool sdFormat() { + if (!g_mounted || !g_card) return false; + // Formats and remounts in one call; the mount stays live either way. + return esp_vfs_fat_sdcard_format(SD_MOUNT_POINT, g_card) == ESP_OK; +} + +uint64_t sdCardUsed() { + if (!g_mounted) return 0; + FATFS* fs = nullptr; + DWORD freeClusters = 0; + if (f_getfree(SD_DRIVE, &freeClusters, &fs) != FR_OK || !fs) return 0; + const uint64_t totalSectors = (static_cast(fs->n_fatent) - 2) * fs->csize; + const uint64_t freeSectors = static_cast(freeClusters) * fs->csize; + return (totalSectors - freeSectors) * fs->ssize; +} + +uint64_t sdCardTotal() { + if (!g_mounted || !g_card) return 0; + return static_cast(g_card->csd.capacity) * static_cast(g_card->csd.sector_size); +} + +// ---- Routine file ops: raw FatFs, @xref{drive-0-is-assumed-not-queried}. ---- + +/// mkdir -p: f_mkdir on each path component in turn; FR_EXIST on one already there is success, not failure. +bool sdCardMkdir(const char* apiPath) { + if (!g_mounted) return false; + char full[128]; + if (!sdTranslate(apiPath, full, sizeof(full))) return false; + char* p = full + std::strlen(SD_DRIVE) + 1; // skip "0:/" + while (*p) { + if (*p == '/') { + *p = 0; + f_mkdir(full); // ignore result; could already exist + *p = '/'; + } + p++; + } + const FRESULT r = f_mkdir(full); + return r == FR_OK || r == FR_EXIST; +} + +bool sdCardExists(const char* apiPath) { + if (!g_mounted) return false; + char full[128]; + if (!sdTranslate(apiPath, full, sizeof(full))) return false; + FILINFO fno; + return f_stat(full, &fno) == FR_OK; +} + +// f_unlink removes a file or an EMPTY directory; FatFs refuses one with children, same contract fsRemove already promises. +bool sdCardRemove(const char* apiPath) { + if (!g_mounted) return false; + char full[128]; + if (!sdTranslate(apiPath, full, sizeof(full))) return false; + return f_unlink(full) == FR_OK; +} + +int sdCardRead(const char* apiPath, char* buf, size_t maxLen) { + if (!g_mounted || !buf || maxLen == 0) return -1; + char full[128]; + if (!sdTranslate(apiPath, full, sizeof(full))) return -1; + FIL f; + if (f_open(&f, full, FA_READ) != FR_OK) return -1; + UINT br = 0; + const FRESULT r = f_read(&f, buf, static_cast(maxLen - 1), &br); + f_close(&f); + if (r != FR_OK) return -1; + buf[br] = 0; + return static_cast(br); +} + +long sdCardSize(const char* apiPath) { + if (!g_mounted) return -1; + char full[128]; + if (!sdTranslate(apiPath, full, sizeof(full))) return -1; + FILINFO fno; + if (f_stat(full, &fno) != FR_OK || (fno.fattrib & AM_DIR)) return -1; + return static_cast(fno.fsize); +} + +int sdCardReadAt(const char* apiPath, long offset, char* buf, size_t len) { + if (!g_mounted || !buf) return -1; + char full[128]; + if (!sdTranslate(apiPath, full, sizeof(full))) return -1; + FIL f; + if (f_open(&f, full, FA_READ) != FR_OK) return -1; + if (f_lseek(&f, static_cast(offset)) != FR_OK) { f_close(&f); return -1; } + UINT br = 0; + const FRESULT r = f_read(&f, buf, static_cast(len), &br); + f_close(&f); + if (r != FR_OK) return -1; + return static_cast(br); +} + +/// The folder a write lands in, made first, as the write's contract promises. +static void sdMakeParent(const char* apiPath) { + if (!apiPath) return; + const char* slash = std::strrchr(apiPath, '/'); + if (!slash || slash == apiPath) return; + char dir[128]; + const size_t n = static_cast(slash - apiPath); + if (n >= sizeof(dir)) return; + std::memcpy(dir, apiPath, n); + dir[n] = 0; + sdCardMkdir(dir); +} + +// f_rename refuses when the destination already exists (no FatFs equivalent of POSIX's atomic replace), so the old file is unlinked first; the brief gap between that and the rename is the cost of FAT having no better primitive. +static bool sdCommitTmp(const char* tmp, const char* full) { + f_unlink(full); + if (f_rename(tmp, full) == FR_OK) return true; + f_unlink(tmp); + return false; +} + +bool sdCardWriteAtomic(const char* apiPath, const char* data, size_t len) { + if (!g_mounted) return false; + if (len > 0 && !data) return false; + sdMakeParent(apiPath); + char full[128]; + char tmp[136]; + if (!sdTranslate(apiPath, full, sizeof(full))) return false; + int n = std::snprintf(tmp, sizeof(tmp), "%s.tmp", full); + if (n < 0 || static_cast(n) >= sizeof(tmp)) return false; + + FIL f; + if (f_open(&f, tmp, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) return false; + UINT bw = 0; + const FRESULT wr = f_write(&f, data, static_cast(len), &bw); + f_close(&f); + if (wr != FR_OK || bw != len) { f_unlink(tmp); return false; } + return sdCommitTmp(tmp, full); +} + +bool sdCardWriteStreamCb(const char* apiPath, FsWriteSrc src, void* user) { + if (!g_mounted || !src) return false; + sdMakeParent(apiPath); + char full[128]; + char tmp[136]; + if (!sdTranslate(apiPath, full, sizeof(full))) return false; + int n = std::snprintf(tmp, sizeof(tmp), "%s.tmp", full); + if (n < 0 || static_cast(n) >= sizeof(tmp)) return false; + + FIL f; + if (f_open(&f, tmp, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) return false; + char chunk[1024]; + bool ok = true, abort = false; + for (;;) { + const size_t got = src(chunk, sizeof(chunk), user, &abort); + if (abort) { ok = false; break; } + if (got == 0) break; + UINT bw = 0; + if (f_write(&f, chunk, static_cast(got), &bw) != FR_OK || bw != got) { ok = false; break; } + } + f_close(&f); + if (!ok) { f_unlink(tmp); return false; } + return sdCommitTmp(tmp, full); +} + +void sdCardList(const char* apiDir, FsListCb cb, void* user) { + if (!g_mounted || !cb) return; + char full[128]; + if (!sdTranslate(apiDir, full, sizeof(full))) return; + FF_DIR dir; + if (f_opendir(&dir, full) != FR_OK) return; + FILINFO fno; + while (f_readdir(&dir, &fno) == FR_OK && fno.fname[0]) { + const bool isDir = (fno.fattrib & AM_DIR) != 0; + const uint32_t size = isDir ? 0 : static_cast(fno.fsize); + cb(fno.fname, isDir, size, user); + } + f_closedir(&dir); +} + +// ---- Kept-open streams: one FIL across every chunk, not a reopen per chunk. ---- + +bool sdCardReadStreamOpen(void*& handle, const char* apiPath) { + if (!g_mounted) return false; + char full[128]; + if (!sdTranslate(apiPath, full, sizeof(full))) return false; + auto* f = new FIL(); + if (f_open(f, full, FA_READ) != FR_OK) { delete f; return false; } + handle = f; + return true; +} + +int sdCardReadStreamRead(void* handle, char* buf, size_t len) { + auto* f = static_cast(handle); + if (!f) return -1; + UINT br = 0; + if (f_read(f, buf, static_cast(len), &br) != FR_OK) return -1; + return static_cast(br); +} + +void sdCardReadStreamClose(void* handle) { + auto* f = static_cast(handle); + if (!f) return; + f_close(f); + delete f; +} + +// Carries both paths, not just the FIL, so close() can rename or discard without re-deriving them. +struct SdWriteHandle { + FIL f; + char full[128] = {}; + char tmp[136] = {}; +}; + +bool sdCardWriteStreamOpen(void*& handle, const char* apiPath) { + if (!g_mounted) return false; + sdMakeParent(apiPath); + auto* h = new SdWriteHandle(); + if (!sdTranslate(apiPath, h->full, sizeof(h->full))) { delete h; return false; } + const int n = std::snprintf(h->tmp, sizeof(h->tmp), "%s.tmp", h->full); + if (n < 0 || static_cast(n) >= sizeof(h->tmp)) { delete h; return false; } + if (f_open(&h->f, h->tmp, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) { delete h; return false; } + handle = h; + return true; +} + +bool sdCardWriteStreamWrite(void* handle, const char* buf, size_t len) { + auto* h = static_cast(handle); + if (!h) return false; + UINT bw = 0; + return f_write(&h->f, buf, static_cast(len), &bw) == FR_OK && bw == len; +} + +bool sdCardWriteStreamClose(void* handle, bool commit) { + auto* h = static_cast(handle); + if (!h) return false; + f_close(&h->f); + const bool ok = commit ? sdCommitTmp(h->tmp, h->full) : true; + if (!commit) f_unlink(h->tmp); + delete h; + return ok; +} + +#else // !MM_HAS_SDMMC: every symbol below is inert so a board with no SDMMC host still links. + +bool sdCardMounted() { return false; } +bool sdMount(const SdPinConfig&) { return false; } +void sdUnmount() {} +bool sdFormat() { return false; } +uint64_t sdCardUsed() { return 0; } +uint64_t sdCardTotal() { return 0; } +bool sdCardMkdir(const char*) { return false; } +bool sdCardExists(const char*) { return false; } +bool sdCardRemove(const char*) { return false; } +int sdCardRead(const char*, char*, size_t) { return -1; } +long sdCardSize(const char*) { return -1; } +int sdCardReadAt(const char*, long, char*, size_t) { return -1; } +bool sdCardWriteAtomic(const char*, const char*, size_t) { return false; } +bool sdCardWriteStreamCb(const char*, FsWriteSrc, void*) { return false; } +void sdCardList(const char*, FsListCb, void*) {} +bool sdCardReadStreamOpen(void*&, const char*) { return false; } +int sdCardReadStreamRead(void*, char*, size_t) { return -1; } +void sdCardReadStreamClose(void*) {} +bool sdCardWriteStreamOpen(void*&, const char*) { return false; } +bool sdCardWriteStreamWrite(void*, const char*, size_t) { return false; } +bool sdCardWriteStreamClose(void*, bool) { return false; } + +#endif // MM_HAS_SDMMC + +} // namespace mm::platform + +/// @} diff --git a/src/platform/esp32/platform_esp32_sdmmc.h b/src/platform/esp32/platform_esp32_sdmmc.h new file mode 100644 index 000000000..36c42f8bf --- /dev/null +++ b/src/platform/esp32/platform_esp32_sdmmc.h @@ -0,0 +1,45 @@ +#pragma once + +#include "platform/platform.h" + +/// The SD card backend behind every `/sd`-prefixed `fsXxx` call, split out of platform_esp32_fs.cpp, which dispatches here. +/// Every path below is the full API path (such as `/sd/foo.txt`), translated internally onto the real FatFs mount point. + +namespace mm::platform { + +/// Whether `sdMount()` has succeeded and `sdUnmount()` has not run since. +bool sdCardMounted(); +/// mkdir -p on the card; no error if it exists. +bool sdCardMkdir(const char* apiPath); +/// Whether `apiPath` exists on the card. +bool sdCardExists(const char* apiPath); +/// Remove a file or empty directory from the card. +bool sdCardRemove(const char* apiPath); +/// Read a whole file from the card; bytes read, or -1 on error, null-terminated on success. +int sdCardRead(const char* apiPath, char* buf, size_t maxLen); +/// A card file's size in bytes, or -1 when it is missing or not a file. +long sdCardSize(const char* apiPath); +/// Read up to `len` bytes at `offset` on the card; bytes read, 0 at the end, -1 on error. +int sdCardReadAt(const char* apiPath, long offset, char* buf, size_t len); +/// Write a whole file atomically onto the card, through a temporary and a rename. +bool sdCardWriteAtomic(const char* apiPath, const char* data, size_t len); +/// Write a card file atomically, pulling `src` in chunks until it signals done or abort. +bool sdCardWriteStreamCb(const char* apiPath, FsWriteSrc src, void* user); +/// List one level of a card directory, calling `cb` per child. +void sdCardList(const char* apiDir, FsListCb cb, void* user); + +/// Open `apiPath` for streamed reads from the card; false when it does not exist or is a directory. +bool sdCardReadStreamOpen(void*& handle, const char* apiPath); +/// Pull up to `len` bytes from where the last read left off; 0 at EOF, -1 on error. +int sdCardReadStreamRead(void* handle, char* buf, size_t len); +/// Close a read handle; safe on one that failed to open. +void sdCardReadStreamClose(void* handle); + +/// Open a temporary file beside `apiPath` on the card, creating its parent folder; false on failure. +bool sdCardWriteStreamOpen(void*& handle, const char* apiPath); +/// Append one chunk to a card write handle; false on a write failure. +bool sdCardWriteStreamWrite(void* handle, const char* buf, size_t len); +/// Flush and close a card write handle; the rename-or-discard on `commit` is the caller's, in platform_esp32_fs.cpp. +bool sdCardWriteStreamClose(void* handle, bool commit); + +} // namespace mm::platform diff --git a/src/platform/platform.h b/src/platform/platform.h index 74ec36f9c..828ac5470 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -28,6 +28,10 @@ /// ## Internal RAM for what an interrupt reads /// /// A PSRAM-resident encode source measured about 595 microseconds per slice refill against a 151 microsecond drain budget. So a buffer an interrupt reads per byte comes from `allocInternal` rather than the PSRAM-first `alloc`. +/// +/// ## Why a second write primitive +/// +/// `fsWriteStream` pulls a whole upload to completion in one call, which is fine for a config file but not for a card-sized one shared with a render loop. `fsWriteStreamOpen`/`Write`/`Close` let a caller hand over one bounded chunk per tick instead, resuming where the last call left off. // Format checking, where the compiler offers it: one toolchain parses the attribute as an unknown specifier and fails the whole class downstream. #if defined(__GNUC__) || defined(__clang__) @@ -292,6 +296,51 @@ using FsListCb = void(*)(const char* name, bool isDir, uint32_t sizeBytes, void* /// List one level of `dir`, calling `cb` per child. void fsList(const char* dir, FsListCb cb, void* user); +/// A read kept open across chunks, so a multi-megabyte download neither reopens nor reseeks per chunk. +struct FsReadStream { void* impl = nullptr; }; +/// Open `path` for streamed reads; false when it does not exist or is a directory. +bool fsReadStreamOpen(FsReadStream& h, const char* path); +/// Pull up to `len` bytes from where the last read left off; 0 at EOF, -1 on error. +int fsReadStreamRead(FsReadStream& h, char* buf, size_t len); +/// Close the handle; safe on one that failed to open. +void fsReadStreamClose(FsReadStream& h); + +/// A write kept open across chunks, so a caller can hand it bytes one bounded slice per tick rather than all at once. @xref{why-a-second-write-primitive} +struct FsWriteStream { void* impl = nullptr; }; +/// Open a temporary file beside `path`, creating its parent folder; false on failure. +bool fsWriteStreamOpen(FsWriteStream& h, const char* path); +/// Append one chunk; false on a write failure, which the caller closes without committing. +bool fsWriteStreamWrite(FsWriteStream& h, const char* buf, size_t len); +/// `commit`: fsync and rename the temporary onto `path`. Otherwise: discard it. Either way the handle is spent. +bool fsWriteStreamClose(FsWriteStream& h, bool commit); + +// A second, independent mount (microSD, FAT) sharing the fsXxx surface above through its own `/sd` path prefix; inert wherever hasSdCard is false. + +/// A board's SDMMC wiring, read at mount time rather than compiled in; -1 leaves a line unused. +struct SdPinConfig { + int8_t clk = -1; ///< SDMMC clock + int8_t cmd = -1; ///< SDMMC command line + int8_t d0 = -1; ///< SDMMC data line 0, required + int8_t d1 = -1; ///< unset alongside d2/d3 drops the bus to 1-bit mode + int8_t d2 = -1; ///< SDMMC data line 2 + int8_t d3 = -1; ///< SDMMC data line 3 + int8_t pwr = -1; ///< a slot's card-power switch, or -1 where the slot has none +}; +/// Mount the card under `/sd`, idempotent; false if no card, no slot, or the pins are unset. +bool sdMount(const SdPinConfig& pins); +/// Unmount it; safe when nothing is mounted. +void sdUnmount(); +/// Format and remount; false while unmounted or on a format failure. +bool sdFormat(); +/// Card bytes used, 0 when unmounted; wide because a real card routinely exceeds 4 GiB. +uint64_t sdCardUsed(); +/// Card bytes total, 0 when unmounted. +uint64_t sdCardTotal(); +#ifndef ESP_PLATFORM // a host-test seam, which no ESP32 code calls +/// Shrink the host's fictional card, so a test reaches the "not enough space" path without writing a real gigabyte. +void setTestSdCardTotal(uint64_t bytes); +#endif + // Network: ESP32 only, stubs on desktop. /// Override the per-chip default pin and PHY map with a board's own, before ethInit. void setEthConfig(const EthPinConfig& cfg); diff --git a/src/ui/app.js b/src/ui/app.js index 609ba3d43..f2a3eaf6c 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -7596,9 +7596,11 @@ function fmMakeDropTarget(el, destDir, hidden, rerender, st) { if (destDir !== "/") st.expanded.add(destDir); // reveal where they landed rerender(); if (skipped.length) { - // Report what wasn't uploaded (over the size cap or a write error) rather than dropping - // it silently. The limit is derived from FM_UPLOAD_CAP so the text never drifts from it. - alert(`Not uploaded (over ${fmSize(FM_UPLOAD_CAP)} or failed):\n` + skipped.join("\n")); + // Report what wasn't uploaded rather than dropping it silently; the card has no size cap + // of its own (only its free space, which a failed upload's own message already names), so + // naming FM_UPLOAD_CAP here would be wrong for an /sd drop and right for everything else. + const capNote = fmIsSdPath(destDir) ? "" : ` (over ${fmSize(FM_UPLOAD_CAP)} or failed)`; + alert(`Not uploaded${capNote}:\n` + skipped.join("\n")); } }); } @@ -7754,21 +7756,62 @@ function fmShowRestoreReport(entries, refresh, offerRestart) { } +// True for destDir "/sd" or anything under it: the card has no upload cap of its own (only its free +// space, which the server's 507 already enforces), so the flash-sized FM_UPLOAD_CAP below skips it. +function fmIsSdPath(destDir) { + return destDir === "/sd" || destDir.startsWith("/sd/"); +} + +// A small floating panel, not a modal: a multi-minute /sd upload must not trap the rest of the UI +// behind a backdrop the way showUpdateOverlay's firmware-flash overlay does. One row per file in +// flight; a row closes when that file finishes, and the panel removes itself once every row has. +function fmUploadPanel() { + let panel = document.getElementById("fm-upload-panel"); + if (!panel) { + panel = document.createElement("div"); + panel.id = "fm-upload-panel"; + panel.className = "fm-upload-panel"; + document.body.appendChild(panel); + } + return panel; +} + +// One row in the panel: a label plus the same element uploadWithProgress already drives. +function fmUploadPanelRow(panel, name) { + const row = document.createElement("div"); + row.className = "fm-upload-panel-row"; + const label = document.createElement("span"); + label.className = "fm-upload-panel-label"; + label.textContent = name; + const bar = document.createElement("progress"); + row.append(label, bar); + panel.appendChild(row); + return { + bar, + done() { row.remove(); if (!panel.children.length) panel.remove(); }, + }; +} + // Upload each dropped file into destDir via /api/file. Returns the names skipped (too big / failed) -// so the caller can report them. The File blob is sent as the body directly: the browser streams -// its raw bytes (binary-safe), matching the device's streamed, byte-exact write. +// so the caller can report them. XMLHttpRequest (uploadWithProgress), not fetch: only xhr.upload's +// progress event reports how far a multi-minute /sd transfer has got, and its own error event names +// a dropped connection instead of the generic "NetworkError" fetch throws for the same failure. async function fmDropUpload(destDir, files) { const skipped = []; + const onSd = fmIsSdPath(destDir); + const panel = files.length ? fmUploadPanel() : null; for (const file of files) { - if (file.size > FM_UPLOAD_CAP) { skipped.push(file.name + " (" + fmSize(file.size) + ")"); continue; } + if (!onSd && file.size > FM_UPLOAD_CAP) { skipped.push(file.name + " (" + fmSize(file.size) + ")"); continue; } + const row = fmUploadPanelRow(panel, file.name); + const route = "/api/file?path=" + encodeURIComponent(joinFsPath(destDir, file.name)); try { - const res = await fetch("/api/file?path=" + encodeURIComponent(joinFsPath(destDir, file.name)), { - method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: file, + await uploadWithProgress(route, file, { + progress(read, total) { if (total > 0) { row.bar.max = total; row.bar.value = read; } }, }); - if (!res.ok) throw new Error(await errorMessage(res)); // surfaces "not enough space (N free)" etc. } catch (err) { skipped.push(file.name + " (" + err.message + ")"); } + row.done(); } return skipped; } diff --git a/src/ui/style.css b/src/ui/style.css index 1a5d8f6f9..723ef9670 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -2161,6 +2161,29 @@ button.fileedit-pick:disabled { opacity: 0.6; cursor: default; } .fw-overlay-box progress { width: 100%; } .fw-overlay-msg { color: var(--fg-muted); margin: 0; overflow-wrap: anywhere; } +/* File Manager upload progress: a floating panel, not a modal, so a long /sd transfer never blocks + the rest of the page. Bottom-left, since the PiP preview pane already claims bottom-right. */ +.fm-upload-panel { + position: fixed; + left: 12px; + bottom: 12px; + z-index: 70; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 18rem; +} +.fm-upload-panel-row { + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); + font-size: 0.85rem; +} +.fm-upload-panel-label { display: block; overflow-wrap: anywhere; margin-bottom: 4px; } +.fm-upload-panel-row progress { width: 100%; } + /* ASSIGN MODE: while it is on, a tap on a surface control opens its target picker rather than moving it, so the controls have to LOOK different or the mode is invisible and a user wonders why their fader stopped working. The outline says "clickable for something else"; the cursor confirms it. */ diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 94626e2e5..4db7576ad 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -20,6 +20,7 @@ add_executable(mm_tests unit/core/unit_crc.cpp unit/core/unit_AudioService_codec.cpp unit/core/unit_I2cBusModule.cpp + unit/core/unit_SdCardModule.cpp unit/core/unit_AudioService_sync.cpp unit/core/unit_WledAudioSyncPacket.cpp unit/core/unit_ThreadSlot.cpp @@ -51,6 +52,7 @@ add_executable(mm_tests unit/core/unit_AllocTracking.cpp unit/core/unit_HttpServerModule_apply.cpp unit/core/unit_HttpServerModule_connections.cpp + unit/core/unit_HttpServerModule_sdcard.cpp unit/core/unit_StateDocument.cpp unit/core/unit_ImprovFrame.cpp unit/core/unit_ImprovOpReassembler.cpp diff --git a/test/js/file-manager-sd-upload.test.mjs b/test/js/file-manager-sd-upload.test.mjs new file mode 100644 index 000000000..fde9a77e3 --- /dev/null +++ b/test/js/file-manager-sd-upload.test.mjs @@ -0,0 +1,154 @@ +// A regression guard for two bugs caught live on the bench: +// 1. The File Manager's upload applied FM_UPLOAD_CAP (256 KiB, sized for flash) to a drop destined +// for /sd, where the server has no cap of its own beyond the card's free space. +// 2. A large /sd upload crashed the board (a stack-local buffer in the per-tick drive loop), which +// a browser's fetch() reports as the opaque "NetworkError when attempting to fetch resource.". +// The fix moved uploads onto uploadWithProgress (XMLHttpRequest), the same helper the firmware +// upload already used, so a dropped connection says so and a long transfer shows real progress. +// +// Run: `node --test test/js`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { fnSource } from "./app-source.mjs"; + +// A minimal fake DOM: just enough of createElement/appendChild/remove/getElementById for +// fmUploadPanel and fmUploadPanelRow, which this codebase has no existing mock for (its DOM-heavy +// code is exercised by the Playwright --ui scenarios instead, not this node:test harness). +function fakeElement(tag) { + const el = { + tagName: tag, className: "", id: "", textContent: "", max: 0, value: 0, + children: [], parentNode: null, + append(...kids) { for (const k of kids) el.appendChild(k); }, + appendChild(k) { k.parentNode = el; el.children.push(k); return k; }, + remove() { + if (!el.parentNode) return; + const i = el.parentNode.children.indexOf(el); + if (i >= 0) el.parentNode.children.splice(i, 1); + el.parentNode = null; + }, + }; + return el; +} + +function fakeDocument() { + const byId = new Map(); + const index = (el) => { if (el.id) byId.set(el.id, el); }; + const body = fakeElement("body"); + const realBodyAppend = body.appendChild.bind(body); + body.appendChild = (k) => { const r = realBodyAppend(k); index(k); return r; }; + return { + body, + createElement(tag) { return fakeElement(tag); }, + getElementById(id) { return byId.get(id); }, + // The code under test sets `.id` then appends to body; a real DOM indexes that + // automatically, so this fake does the same at the one place an id is assigned. + __index: index, + }; +} + +// A fake XMLHttpRequest driven by the test: open/send record the call, and emitProgress/emitLoad/ +// emitError replace the real network with whatever the test wants to have happened. +class FakeXHR { + constructor() { + this._listeners = {}; + this.upload = { addEventListener: (ev, cb) => { if (ev === "progress") this._onProgress = cb; } }; + } + open(method, url) { this.method = method; this.url = url; } + setRequestHeader() {} + addEventListener(ev, cb) { this._listeners[ev] = cb; } + send(body) { this.body = body; } + emitProgress(loaded, total) { this._onProgress?.({ lengthComputable: true, loaded, total }); } + emitLoad(status, responseText) { this.status = status; this.responseText = responseText; this._listeners.load?.(); } + emitError() { this._listeners.error?.(); } +} + +// Builds fmDropUpload with its real dependencies inlined (the same pattern app-source.mjs's other +// callers use), against a fresh fake document and an XHR factory the test controls. +function buildFmDropUpload({ onXhr }) { + const doc = fakeDocument(); + const XHRCtor = function () { + const xhr = new FakeXHR(); + onXhr(xhr); + return xhr; + }; + const build = new Function("document", "XMLHttpRequest", ` + const FM_UPLOAD_CAP = 256 * 1024; + ${fnSource("joinFsPath")} + ${fnSource("fmSize")} + ${fnSource("fmIsSdPath")} + ${fnSource("uploadWithProgress")} + ${fnSource("fmUploadPanel")} + ${fnSource("fmUploadPanelRow")} + ${fnSource("fmDropUpload")} + return { fmDropUpload, fmIsSdPath };`); + return build(doc, XHRCtor); +} + +function fakeFile(name, sizeBytes) { + return { name, size: sizeBytes }; +} + +const BIG = 9 * 1024 * 1024; // bigger than FM_UPLOAD_CAP either way + +test("fmIsSdPath matches /sd and anything under it, nothing else", () => { + const { fmIsSdPath } = buildFmDropUpload({ onXhr: () => {} }); + assert.equal(fmIsSdPath("/sd"), true); + assert.equal(fmIsSdPath("/sd/clips"), true); + assert.equal(fmIsSdPath("/"), false); + assert.equal(fmIsSdPath("/sdx"), false); // a real folder named "sdx", not the card + assert.equal(fmIsSdPath("/moonlive"), false); +}); + +test("a file over the flash cap bound for flash is skipped client-side, never opening a connection", async () => { + let opened = false; + const { fmDropUpload } = buildFmDropUpload({ onXhr: () => { opened = true; } }); + + const skipped = await fmDropUpload("/", [fakeFile("huge.fseq", BIG)]); + + assert.equal(opened, false); + assert.equal(skipped.length, 1); + assert.match(skipped[0], /huge\.fseq/); +}); + +test("the same file bound for /sd is sent, not skipped by the flash cap, and reports progress", async () => { + let sawUrl = null; + const progressReads = []; + const { fmDropUpload } = buildFmDropUpload({ + onXhr: (xhr) => { + // .open() runs after the constructor returns, so xhr.url is only valid once deferred. + setTimeout(() => { + sawUrl = xhr.url; + xhr.emitProgress(1024, BIG); + xhr.emitLoad(200, "{\"ok\":true}"); + }, 0); + }, + }); + + const skipped = await fmDropUpload("/sd", [fakeFile("feuer_120s_smooth_35fps.fseq", BIG)]); + + assert.match(decodeURIComponent(sawUrl), /\/sd\/feuer_120s_smooth_35fps\.fseq$/); + assert.deepEqual(skipped, []); +}); + +test("a dropped connection on a long /sd upload reports plainly, not as a generic network error", async () => { + const { fmDropUpload } = buildFmDropUpload({ + onXhr: (xhr) => { setTimeout(() => xhr.emitError(), 0); }, + }); + + const skipped = await fmDropUpload("/sd", [fakeFile("clip.fseq", BIG)]); + + assert.equal(skipped.length, 1); + assert.match(skipped[0], /connection dropped/); +}); + +test("a genuine server failure on an SD upload still reports, carrying the server's own message", async () => { + const { fmDropUpload } = buildFmDropUpload({ + onXhr: (xhr) => { setTimeout(() => xhr.emitLoad(507, "{\"error\":\"not enough space (0 free)\"}"), 0); }, + }); + + const skipped = await fmDropUpload("/sd", [fakeFile("clip.fseq", BIG)]); + + assert.equal(skipped.length, 1); + assert.match(skipped[0], /not enough space/); +}); diff --git a/test/unit/core/unit_HttpServerModule_sdcard.cpp b/test/unit/core/unit_HttpServerModule_sdcard.cpp new file mode 100644 index 000000000..87ef3b91f --- /dev/null +++ b/test/unit/core/unit_HttpServerModule_sdcard.cpp @@ -0,0 +1,284 @@ +/// @module HttpServerModule +/// @also SdCardModule +/// +/// `/api/file` and `/api/dir` reach the card through the same path-prefix dispatch every other `fsXxx` call uses. +/// A large transfer resumes a bounded slice per tick (driveSdTransfers) instead of blocking one. +/// Driven over loopback against the real server, as unit_HttpServerModule_connections.cpp does for parking. + +#include "doctest.h" +#include "core/system/HttpServerModule.h" +#include "platform/platform.h" + +#include +#include +#include +#include +#include + +namespace { + +constexpr uint16_t kTestPort = 18091; +using CR = mm::platform::TcpConnection::ConnectResult; + +bool connectAndAccept(mm::HttpServerModule& http, mm::platform::TcpConnection& client) { + if (!client.connectStart("127.0.0.1", kTestPort)) return false; + CR r = CR::Pending; + for (int i = 0; i < 500 && r == CR::Pending; i++) { + r = client.connectPoll(); + if (r == CR::Pending) mm::platform::delayMs(1); + } + if (r != CR::Connected) return false; + mm::platform::delayMs(5); + http.tick20ms(); + return true; +} + +// Ticks the server, accumulating whatever the client reads, until `out` holds at least `wantAtLeast` bytes or `maxTicks` is spent; returns what was accumulated. +std::string pumpUntil(mm::HttpServerModule& http, mm::platform::TcpConnection& client, + size_t wantAtLeast, int maxTicks = 3000) { + std::string out; + char buf[4096]; + for (int i = 0; i < maxTicks && out.size() < wantAtLeast; i++) { + http.tick20ms(); + const int n = client.read(reinterpret_cast(buf), sizeof(buf)); + if (n > 0) out.append(buf, static_cast(n)); + else mm::platform::delayMs(1); + } + return out; +} + +// A deterministic byte pattern, long enough (> the 4096-byte per-tick chunk) to force several ticks of driveSdTransfers regardless of how fast loopback delivers the bytes. +std::string pattern(size_t n) { + std::string s(n, '\0'); + for (size_t i = 0; i < n; i++) s[i] = static_cast('A' + (i % 26)); + return s; +} + +// Mounts a real card under a fresh temp dir for one test, and tears it down after. +struct SdFixture { + char root[256]; + SdFixture() { + static unsigned counter = 0; + std::snprintf(root, sizeof(root), "%s/mm_sdhttp_test_%u", + std::filesystem::temp_directory_path().string().c_str(), counter++); + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + mm::platform::fsSetRoot(root); + mm::platform::setTestSdCardTotal(1024ULL * 1024 * 1024); // a realistic-looking default + mm::platform::SdPinConfig pins{}; + pins.clk = 43; pins.cmd = 44; pins.d0 = 39; + REQUIRE(mm::platform::sdMount(pins)); + } + // NOLINTNEXTLINE(bugprone-exception-escape) + ~SdFixture() noexcept { + mm::platform::sdUnmount(); + mm::platform::fsSetRoot(""); + mm::platform::setTestSdCardTotal(1024ULL * 1024 * 1024); + std::error_code ec; + std::filesystem::remove_all(root, ec); + } +}; + +} // namespace + +TEST_CASE("a path traversal through /sd is rejected, the same shared guard flash uses") { + char out[64]; + CHECK_FALSE(mm::HttpServerModule::parseFilePath("path=/sd/../.config/secret", out, sizeof(out))); + CHECK(mm::HttpServerModule::parseFilePath("path=/sd/ok.bin", out, sizeof(out))); + CHECK(std::strcmp(out, "/sd/ok.bin") == 0); +} + +// The card is a second mount, not a real subdirectory, so the File Manager tree would never offer it without this: a regression guard for exactly the gap found live on the bench, where /sd listed fine once you knew to ask, but the root never advertised it as a child to expand. +TEST_CASE("the root listing advertises /sd as a folder, so the tree can reach it") { + SdFixture fix; + struct Seen { std::vector names; bool sdIsDir = false; } seen; + mm::platform::fsList("/", [](const char* name, bool isDir, uint32_t, void* user) { + auto* s = static_cast(user); + s->names.push_back(name); + if (std::string(name) == "sd") s->sdIsDir = isDir; + }, &seen); + REQUIRE(std::find(seen.names.begin(), seen.names.end(), "sd") != seen.names.end()); + CHECK(seen.sdIsDir); + // Not doubled: desktop backs /sd with a real folder under the same root, which the listing must skip. + CHECK(std::count(seen.names.begin(), seen.names.end(), "sd") == 1); +} + +TEST_CASE("an SD upload larger than one tick's chunk streams across several ticks and reads back whole") { + SdFixture fix; + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client)); + + const std::string payload = pattern(10000); + char req[256]; + const int reqLen = std::snprintf(req, sizeof(req), + "POST /api/file?path=/sd/big.bin HTTP/1.1\r\nContent-Length: %zu\r\n\r\n", payload.size()); + REQUIRE(client.write(reinterpret_cast(req), static_cast(reqLen))); + REQUIRE(client.write(reinterpret_cast(payload.data()), payload.size())); + + const std::string reply = pumpUntil(http, client, 12); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) == 0); + + CHECK(mm::platform::fsSize("/sd/big.bin") == static_cast(payload.size())); + std::vector back(payload.size()); + char chunk[2048]; + size_t got = 0; + while (got < back.size()) { + const int n = mm::platform::fsReadAt("/sd/big.bin", static_cast(got), chunk, + std::min(sizeof(chunk), back.size() - got)); + REQUIRE(n > 0); + std::memcpy(back.data() + got, chunk, static_cast(n)); + got += static_cast(n); + } + CHECK(std::memcmp(back.data(), payload.data(), payload.size()) == 0); + + client.close(); + http.release(); +} + +TEST_CASE("an SD download larger than one tick's chunk streams across several ticks and arrives whole") { + SdFixture fix; + const std::string payload = pattern(10000); + REQUIRE(mm::platform::fsWriteAtomic("/sd/dl.bin", payload.data(), payload.size())); + + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client)); + + const char* req = "GET /api/file?path=/sd/dl.bin HTTP/1.1\r\n\r\n"; + REQUIRE(client.write(reinterpret_cast(req), std::strlen(req))); + + // Header plus the whole body, with slack for the header's own bytes. + const std::string reply = pumpUntil(http, client, payload.size() + 50); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) == 0); + const auto bodyStart = reply.find("\r\n\r\n"); + REQUIRE(bodyStart != std::string::npos); + const std::string body = reply.substr(bodyStart + 4); + CHECK(body.size() == payload.size()); + CHECK(body == payload); + + client.close(); + http.release(); +} + +TEST_CASE("an SD upload that stalls mid-transfer is closed and its slot frees for the next one") { + struct Clock { ~Clock() { mm::platform::setTestNowMs(0); } } clock; + mm::platform::setTestNowMs(1000); + SdFixture fix; + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client)); + + // Declares 10000 bytes, sends only 100, then goes silent: a stuck upload, never completed. + char req[256]; + const int reqLen = std::snprintf(req, sizeof(req), + "POST /api/file?path=/sd/stuck.bin HTTP/1.1\r\nContent-Length: 10000\r\n\r\n"); + REQUIRE(client.write(reinterpret_cast(req), static_cast(reqLen))); + const std::string partial = pattern(100); + REQUIRE(client.write(reinterpret_cast(partial.data()), partial.size())); + http.tick20ms(); // claims the slot, writes the 100 bytes already buffered + + // Comfortably past the stall guard (the real bound is a private implementation detail). + mm::platform::setTestNowMs(1000 + 60000); + for (int i = 0; i < 10; i++) http.tick20ms(); + CHECK(client.read(reinterpret_cast(req), sizeof(req)) == 0); // closed, no response + + // The slot is free again: a fresh, small upload still completes normally. + mm::platform::TcpConnection client2; + REQUIRE(connectAndAccept(http, client2)); + const char* req2 = "POST /api/file?path=/sd/ok2.bin HTTP/1.1\r\nContent-Length: 2\r\n\r\nhi"; + REQUIRE(client2.write(reinterpret_cast(req2), std::strlen(req2))); + const std::string reply = pumpUntil(http, client2, 12); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) == 0); + + client2.close(); + http.release(); +} + +TEST_CASE("an SD upload past the card's free space is refused with 507, immediately") { + SdFixture fix; + mm::platform::setTestSdCardTotal(500); // a nearly-full, tiny fictional card + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client)); + + const std::string payload = pattern(2000); // bigger than the fictional card + char req[256]; + const int reqLen = std::snprintf(req, sizeof(req), + "POST /api/file?path=/sd/toobig.bin HTTP/1.1\r\nContent-Length: %zu\r\n\r\n", payload.size()); + REQUIRE(client.write(reinterpret_cast(req), static_cast(reqLen))); + REQUIRE(client.write(reinterpret_cast(payload.data()), payload.size())); + + const std::string reply = pumpUntil(http, client, 12); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 507", 12) == 0); + CHECK(mm::platform::fsSize("/sd/toobig.bin") < 0); // nothing written + + client.close(); + http.release(); +} + +// A declared length past FAT32's own ceiling (4 GiB-1) is well-formed, just too big: 413, not the generic 400 a malformed header gets; no body is sent, since the ceiling rejects on the header alone. +TEST_CASE("an SD upload declaring more than FAT32's 4 GiB-1 ceiling is refused with 413") { + SdFixture fix; + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client)); + + const char* req = "POST /api/file?path=/sd/huge.bin HTTP/1.1\r\nContent-Length: 5000000000\r\n\r\n"; + REQUIRE(client.write(reinterpret_cast(req), std::strlen(req))); + + const std::string reply = pumpUntil(http, client, 12); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 413", 12) == 0); + + client.close(); + http.release(); +} + +// Point 1 of the review moved removeRecursiveSd's job onto FilesystemModule::removeTree, which already dispatches through platform::fsList/fsRemove. +// This pins that DELETE /api/dir on /sd actually reaches it, recursing through a non-empty folder exactly as it does for flash. +TEST_CASE("DELETE /api/dir removes a file and a non-empty folder on the card, via removeTree") { + SdFixture fix; + REQUIRE(mm::platform::fsWriteAtomic("/sd/lone.bin", "x", 1)); + REQUIRE(mm::platform::fsMkdir("/sd/tree/sub")); + REQUIRE(mm::platform::fsWriteAtomic("/sd/tree/top.txt", "1", 1)); + REQUIRE(mm::platform::fsWriteAtomic("/sd/tree/sub/leaf.txt", "2", 1)); + + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + + { + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client)); + const char* req = "DELETE /api/dir?path=/sd/lone.bin HTTP/1.1\r\n\r\n"; + REQUIRE(client.write(reinterpret_cast(req), std::strlen(req))); + const std::string reply = pumpUntil(http, client, 12); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) == 0); + client.close(); + } + CHECK(mm::platform::fsSize("/sd/lone.bin") < 0); + + { + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client)); + const char* req = "DELETE /api/dir?path=/sd/tree HTTP/1.1\r\n\r\n"; + REQUIRE(client.write(reinterpret_cast(req), std::strlen(req))); + const std::string reply = pumpUntil(http, client, 12); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) == 0); + client.close(); + } + CHECK(mm::platform::fsSize("/sd/tree/sub/leaf.txt") < 0); + CHECK_FALSE(mm::platform::fsExists("/sd/tree")); + + http.release(); +} diff --git a/test/unit/core/unit_SdCardModule.cpp b/test/unit/core/unit_SdCardModule.cpp new file mode 100644 index 000000000..f26239437 --- /dev/null +++ b/test/unit/core/unit_SdCardModule.cpp @@ -0,0 +1,155 @@ +/// @module SdCardModule +/// @also I2cBusModule + +/// The SD card is runtime configuration, like AudioService's mic pins. +/// `hasSdCard` is forced true on desktop precisely so these tests exercise the real mount/remount/format path against a temp-dir-backed "/sd", not only the no-hardware stub a chip without the host takes. + +#include "doctest.h" +#include "core/system/SdCardModule.h" +#include "platform/platform.h" + +#include +#include +#include + +using namespace mm; + +namespace { + +const ControlDescriptor* find(const SdCardModule& sd, const char* name) { + for (uint8_t i = 0; i < sd.controls().count(); i++) + if (std::strcmp(sd.controls()[i].name, name) == 0) return &sd.controls()[i]; + return nullptr; +} + +void setPin(SdCardModule& sd, const char* name, int value) { + const auto* c = find(sd, name); + REQUIRE(c != nullptr); + *static_cast(c->ptr) = static_cast(value); +} + +// A card on a fresh temp filesystem root; the Waveshare P4-ETH's own pins, since any valid set does. +void wireRequiredPins(SdCardModule& sd) { + setPin(sd, "clkPin", 43); + setPin(sd, "cmdPin", 44); + setPin(sd, "d0Pin", 39); +} + +struct Rig { + char root[256]; + SdCardModule sd; + Rig() { + static unsigned counter = 0; + std::snprintf(root, sizeof(root), "%s/mm_sd_test_%u", + std::filesystem::temp_directory_path().string().c_str(), counter++); + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + platform::fsSetRoot(root); + sd.setTypeName("SdCardModule"); + sd.defineControls(); + } + // NOLINTNEXTLINE(bugprone-exception-escape) + ~Rig() noexcept { + sd.release(); + platform::fsSetRoot(""); + std::error_code ec; + std::filesystem::remove_all(root, ec); + } +}; + +} // namespace + +TEST_CASE("the SD pins are visible, since desktop always reports hasSdCard") { + SdCardModule sd; + sd.defineControls(); + for (const char* name : {"clkPin", "cmdPin", "d0Pin", "d1Pin", "d2Pin", "d3Pin", "pwrPin"}) { + const auto* c = find(sd, name); + REQUIRE(c != nullptr); + CHECK(c->hidden == !platform::hasSdCard); + } +} + +TEST_CASE("SdCardModule is a Service") { + SdCardModule sd; + CHECK(sd.role() == ModuleRole::Service); +} + +TEST_CASE("every SD pin affects prepare, so an edit remounts the card; format does not") { + SdCardModule sd; + for (const char* name : {"clkPin", "cmdPin", "d0Pin", "d1Pin", "d2Pin", "d3Pin", "pwrPin"}) + CHECK(sd.affectsPrepare(name)); + CHECK_FALSE(sd.affectsPrepare("format")); +} + +TEST_CASE("prepare refuses to mount with no pins set") { + Rig r; + r.sd.prepare(); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()) == "set the SD card pins first"); + CHECK(find(r.sd, "card (KiB)") == nullptr); +} + +TEST_CASE("prepare mounts once the required pins are set, and the gauge appears") { + Rig r; + wireRequiredPins(r.sd); + r.sd.prepare(); + CHECK(r.sd.status() == nullptr); + CHECK(std::filesystem::exists(std::string(r.root) + "/sd")); + CHECK(find(r.sd, "card (KiB)") != nullptr); +} + +TEST_CASE("a pin edit remounts the card live, the same contract I2cBusModule's sda/scl have") { + Rig r; + wireRequiredPins(r.sd); + r.sd.prepare(); + REQUIRE(find(r.sd, "card (KiB)") != nullptr); + + // Unset a required pin: prepare() must unmount on the next pass, same as the live-reconfig contract promises. + setPin(r.sd, "d0Pin", -1); + r.sd.prepare(); + CHECK(find(r.sd, "card (KiB)") == nullptr); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()) == "set the SD card pins first"); +} + +TEST_CASE("release unmounts; it is harmless when nothing was mounted") { + Rig r; + r.sd.release(); // nothing mounted yet: must not crash or misbehave + CHECK(find(r.sd, "card (KiB)") == nullptr); + + wireRequiredPins(r.sd); + r.sd.prepare(); + REQUIRE(find(r.sd, "card (KiB)") != nullptr); + r.sd.release(); + // The card still exists on disk; this instance only let go of it. + CHECK(std::filesystem::exists(std::string(r.root) + "/sd")); +} + +TEST_CASE("format refuses while unmounted, and wipes the card while mounted") { + Rig r; + r.sd.onControlChanged("format"); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()) == "mount a card before formatting"); + + wireRequiredPins(r.sd); + r.sd.prepare(); + std::filesystem::create_directories(std::string(r.root) + "/sd/leftover"); + REQUIRE(std::filesystem::exists(std::string(r.root) + "/sd/leftover")); + + r.sd.onControlChanged("format"); + CHECK(r.sd.status() == nullptr); + CHECK_FALSE(std::filesystem::exists(std::string(r.root) + "/sd/leftover")); // wiped + CHECK(std::filesystem::exists(std::string(r.root) + "/sd")); // recreated, still mounted +} + +TEST_CASE("tick1s is a no-op while unmounted, and refreshes the gauge once mounted") { + Rig r; + for (int i = 0; i < 120; i++) r.sd.tick1s(); // two minutes' worth, nothing to show + CHECK(find(r.sd, "card (KiB)") == nullptr); + + wireRequiredPins(r.sd); + r.sd.prepare(); + REQUIRE(find(r.sd, "card (KiB)") != nullptr); + for (int i = 0; i < 60; i++) r.sd.tick1s(); // the minute-paced scan fires once + CHECK(find(r.sd, "card (KiB)") != nullptr); +} From b9c3f6b307826e36cb87d4fdc862a08086acf439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Sun, 11 Oct 2026 17:12:21 +0200 Subject: [PATCH 02/20] Skip a no-op SD remount, and refuse one under an open stream PR #136 review item 1 (blocking): SdCardModule::prepare() unmounted and remounted the card on every tree-wide prepare, not just a pin edit of its own - any affectsPrepare change anywhere (a layout width, a driver pin) or a flash upload/delete ran it too, each one tearing down and rebuilding the SDMMC context for nothing, and under a live transfer calling f_write on a FIL whose context had just been freed. That matches the crash class this branch already found once. Flash: esp32p4rev1-eth +336 B (2328416 -> 2328752). Account: src +90/-27 lines | test +49/-1 lines | code findings 1629 -> 1642 (complex function +1, duplicated block +12*) | duplicated lines 3733 -> 3773 (+40*) *both pre-existing: the connectAndAccept duplicate between unit_HttpServerModule_sdcard.cpp and unit_HttpServerModule_connections.cpp already exists verbatim at this branch's HEAD, untouched by this commit; jscpd simply had not reported it yet. SdCardModule::prepare's own complexity (now 11) stays over the line per coding-standards.md's own escape valve - splitting its compare/unmount/mount/rebuild sequence would fragment one state transition across functions threading the same mutable state, not simplify it. Core: - SdCardModule now remembers the SdPinConfig it mounted with (mountedPins_) and prepare() returns immediately when the current pins match it, before touching the card at all - the common case, since prepare() runs tree-wide. - A new open-stream counter in both platform backends (platform_esp32_sdmmc.cpp's g_streamsOpen, platform_desktop.cpp's sdStreamsOpen_, the latter tagged per-handle so a flash stream through the same generic fsReadStreamOpen/fsWriteStreamOpen does not count) backs platform::sdCardBusy(). sdUnmount() and sdFormat() both changed from unconditional to busy-refusing (sdUnmount from void to bool), enforced in the platform layer itself rather than left to caller discipline. - SdCardModule::prepare() and onControlChanged("format") both check this and set the status to "busy: transfer in progress" rather than tearing the mount down or wiping the card underneath an open transfer. Tests: - unit_SdCardModule.cpp: "an unrelated prepare does not remount when the pins have not changed" (opens a write stream, calls prepare() again with identical pins, confirms no status change and the stream is still usable afterward) and "format refuses while a download stream is open, and succeeds once it closes" (opens a real read stream, confirms format is refused and the file survives, then succeeds once the stream closes). A shared mountRig() helper removes the duplicate setup between them. - unit_HttpServerModule_sdcard.cpp: the busy-guard surfaced a pre-existing timing gap in the SD-download round-trip test - it stopped ticking the instant the full body was readable client-side, one tick before the server's own stream-close noticed and ran, so the fixture's teardown unmount was refused and left the card "mounted" for every later test in the binary. Fixed by ticking a few more times after the body arrives, which is the realistic thing to do anyway. Reviewed live on esp32p4rev1-eth over the network: an in-flight 8 MB upload survived both a format-button press and a real pin change attempted mid-transfer, each correctly refused with "busy: transfer in progress"; the upload completed and the card was untouched by either attempt. Once the transfer cleared, the pending pin change applied normally on the next prepare(). Checks (small, scoped change - only the diff's own trigger-table buckets, per the "always" + "src/ or test/" rows): - check_specs: PASS - 136/136 modules - check_prose: PASS - 0 new findings - check_docgen: PASS - 0 errors (fixed two hard-wrap violations introduced while writing this) - check_code: FAIL - ratchet rose as accounted above; both deltas explained, neither is new debt - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2429/2429 - run_scenario: PASS - 30 scenarios, 17 passed / 13 skipped, 0 failed - check_esp32_built: PASS - fresh after the real rebuild+flash - collect_kpi: PASS - recorded above - Real hardware (esp32p4rev1-eth, over serial this time): verified as described above --- docs/reference/metrics/code.md | 30 ++--- docs/reference/metrics/docgen.md | 30 ++--- docs/reference/metrics/repo-health.json | 110 +++--------------- docs/reference/metrics/repo-health.md | 89 +++++++------- src/core/system/SdCardModule.cpp | 37 ++++-- src/core/system/SdCardModule.h | 4 + src/platform/desktop/platform_desktop.cpp | 51 ++++++-- src/platform/esp32/platform_esp32_sdmmc.cpp | 17 ++- src/platform/platform.h | 8 +- .../core/unit_HttpServerModule_sdcard.cpp | 2 + test/unit/core/unit_SdCardModule.cpp | 46 ++++++++ 11 files changed, 227 insertions(+), 197 deletions(-) diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index 066f3133a..7846c336e 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,14 +4,14 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows. -**1629 finding(s)** across 320 file(s). 2.88% of all lines are duplicated, 3733 lines. +**1642 finding(s)** across 322 file(s). 2.88% of all lines are duplicated, 3773 lines. ## By rule | Rule | Findings | |---|---:| -| duplicated block | 768 | -| complex function | 289 | +| duplicated block | 780 | +| complex function | 290 | | blocking call on the render path | 195 | | deeply nested | 113 | | module type in the UI | 71 | @@ -44,10 +44,10 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta | Area | Findings | |---|---:| -| `test` | 570 | +| `test` | 576 | | `light` | 421 | -| `core` | 352 | -| `platform` | 182 | +| `core` | 353 | +| `platform` | 188 | | `ui` | 74 | | `moondeck` | 24 | | `src` | 4 | @@ -59,7 +59,7 @@ The next function worth simplifying is at the top of each file's list. | File | Findings | Worst | |---|---:|---| -| `src/platform/desktop/platform_desktop.cpp` | 46 | `platform::getMacAddress, line 1379`, large stack frame 5240 | +| `src/platform/desktop/platform_desktop.cpp` | 46 | `platform::getMacAddress, line 1412`, large stack frame 5240 | | `src/core/system/HttpServerModule.cpp` | 44 | `HttpServerModule::handleConnection`, complex function 123 | | `src/ui/migrate.js` | 43 | `line 29: 'I2cBusModule'`, module type in the UI 1 | | `src/core/system/MqttModule.cpp` | 31 | `MqttModule::routePublish`, complex function 29 | @@ -80,7 +80,7 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/core/unit_HttpServerModule_apply.cpp` | 19 | `lines 536-547, also in test/unit/core/unit_HttpServerModule_apply.cpp:122`, duplicated block 12 | | `src/platform/esp32/platform_esp32_moon_i80.cpp` | 18 | `platform::createRingState`, complex function 32 | | `test/unit/core/unit_PinsModule.cpp` | 18 | `lines 46-57, also in test/unit/light/unit_Hub75Driver_pins.cpp:66`, duplicated block 12 | -| `src/core/services/AudioService.h` | 17 | `AudioService in 49 files`, name spread 49 | +| `src/core/services/AudioService.h` | 17 | `AudioService in 52 files`, name spread 52 | | `src/light/effects/PacmanEffect.h` | 16 | `lines 243-257, also in src/light/effects/FishTankEffect.h:244`, duplicated block 15 | | `src/platform/esp32/platform_esp32_ota.cpp` | 15 | `platform::otaWriteMoonBase`, complex function 33 | | `test/unit/light/unit_PaintBrushEffect.cpp` | 15 | `lines 44-61, also in test/unit/light/unit_GEQ3DEffect.cpp:58`, duplicated block 18 | @@ -110,6 +110,7 @@ The next function worth simplifying is at the top of each file's list. | `src/light/effects/FixedPointEffect.h` | 8 | `FixedPointEffect::drawBoids`, complex function 19 | | `src/light/effects/FlyingToastersEffect.h` | 8 | `lines 60-69, also in src/light/effects/FlyingToastersEffect.h:40`, duplicated block 10 | | `src/light/effects/SpaceInvadersEffect.h` | 8 | `lines 436-444, also in src/light/effects/SpriteCast.h:59`, duplicated block 9 | +| `src/platform/esp32/platform_esp32_fs.cpp` | 8 | `lines 227-237, also in src/platform/esp32/platform_esp32_fs.cpp:174`, duplicated block 11 | | `src/platform/esp32/platform_esp32_rmt.cpp` | 8 | `platform::detail::captureAndVerifyFrame`, complex function 39 | | `test/unit/light/unit_GEQEffect.cpp` | 8 | `lines 159-177, also in test/unit/light/unit_BlurzEffect.cpp:137`, duplicated block 19 | | `test/unit/light/unit_LissajousEffect.cpp` | 8 | `lines 81-97, also in test/unit/light/unit_LissajousEffect.cpp:13`, duplicated block 17 | @@ -121,9 +122,9 @@ The next function worth simplifying is at the top of each file's list. | `src/core/system/SystemModule.h` | 7 | `SystemModule in 31 files`, name spread 31 | | `src/core/util/JsonUtil.h` | 7 | `json::parseString`, complex function 34 | | `src/light/effects/FishTankEffect.h` | 7 | `lines 244-258, also in src/light/effects/PacmanEffect.h:243`, duplicated block 15 | -| `src/platform/esp32/platform_esp32_fs.cpp` | 7 | `lines 227-237, also in src/platform/esp32/platform_esp32_fs.cpp:174`, duplicated block 11 | | `src/platform/esp32/platform_esp32_hub75.cpp` | 7 | `platform::hub75Init`, complex function 32 | | `src/platform/esp32/platform_esp32_parlio.cpp` | 7 | `lines 277-286, also in src/platform/esp32/platform_esp32_i80.cpp:425`, duplicated block 10 | +| `src/platform/esp32/platform_esp32_sdmmc.cpp` | 7 | `platform::sdMount`, complex function 22 | | `test/js/ui-ws-scheme.test.mjs` | 7 | `lines 10-22, also in test/js/ui-render-guard.test.mjs:13`, duplicated block 13 | | `test/unit/core/unit_NetworkModule.cpp` | 7 | `lines 556-564, also in test/unit/core/unit_NetworkModule.cpp:190`, duplicated block 9 | | `test/unit/light/unit_NdiDriver.cpp` | 7 | `lines 29-46, also in test/unit/light/unit_PanelCardDriver.cpp:22`, duplicated block 18 | @@ -158,6 +159,7 @@ The next function worth simplifying is at the top of each file's list. | `src/light/layouts/TorontoBarGourdsLayout.h` | 5 | `lines 52-68, also in src/light/layouts/CarLightsLayout.h:49`, duplicated block 17 | | `src/platform/desktop/main_desktop.cpp` | 5 | `main`, deeply nested 5 | | `src/platform/esp32/platform_esp32_h264.cpp` | 5 | `platform::hlsSegment`, complex function 26 | +| `test/unit/core/unit_HttpServerModule_sdcard.cpp` | 5 | `lines 23-32, also in test/unit/core/unit_HttpServerModule_connections.cpp:18`, duplicated block 10 | | `test/unit/light/unit_Drivers_rendersplit.cpp` | 5 | `(static local variable) in tick`, blocking call on the render path 1 | | `test/unit/light/unit_FireEffect.cpp` | 5 | `lines 29-42, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 14 | | `test/unit/light/unit_FreqSawsEffect.cpp` | 5 | `lines 19-29, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 11 | @@ -230,6 +232,7 @@ The next function worth simplifying is at the top of each file's list. | `src/core/system/DevicePlugin.h` | 2 | `lines 94-100, also in src/core/system/DevicePlugin.h:75`, duplicated block 7 | | `src/core/system/HttpServerModule.h` | 2 | `HttpServerModule in 17 files`, name spread 17 | | `src/core/system/MqttPacket.h` | 2 | `buildMqttConnect`, complex function 31 | +| `src/core/system/SdCardModule.cpp` | 2 | `SdCardModule::prepare`, complex function 11 | | `src/core/system/Sha1.h` | 2 | `sha1, line 21`, large stack frame 920 | | `src/core/util/ModuleFactory.h` | 2 | `ModuleFactory::create`, deeply nested 4 | | `src/core/util/PinList.h` | 2 | `parsePinList`, complex function 15 | @@ -258,7 +261,6 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/moonlive_asm_riscv.h` | 2 | `lines 74-91, also in src/platform/esp32/moonlive_asm_xtensa.h:79`, duplicated block 18 | | `src/platform/esp32/moonlive_asm_xtensa.cpp` | 2 | `lines 481-487, also in src/platform/esp32/moonlive_asm_riscv.cpp:297`, duplicated block 7 | | `src/platform/esp32/moonlive_emit_riscv.cpp` | 2 | `lines 52-57, also in src/platform/esp32/moonlive_emit_riscv.cpp:25`, duplicated block 6 | -| `src/platform/esp32/platform_esp32_sdmmc.cpp` | 2 | `platform::sdMount`, complex function 22 | | `test/js/ui-midi-bridge.test.mjs` | 2 | `lines 4-16, also in test/js/ui-gamepad-bridge.test.mjs:4`, duplicated block 13 | | `test/unit/core/unit_AudioService_sync.cpp` | 2 | `lines 313-322, also in test/unit/core/unit_AudioService_sync.cpp:269`, duplicated block 10 | | `test/unit/core/unit_DevicesModule_hue.cpp` | 2 | `lines 63-69, also in test/unit/core/unit_DevicesModule_hue.cpp:42`, duplicated block 7 | @@ -301,11 +303,10 @@ The next function worth simplifying is at the top of each file's list. | `src/core/services/AudioLevel.h` | 1 | `computeLevel`, complex function 14 | | `src/core/services/MoonLiveService.h` | 1 | `moonlive::MoonLive::runValue in tick20ms`, blocking call on the render path 1 | | `src/core/services/OscPacket.h` | 1 | `osc::parse`, complex function 32 | -| `src/core/services/Services.h` | 1 | `Services in 12 files`, name spread 12 | +| `src/core/services/Services.h` | 1 | `Services in 13 files`, name spread 13 | | `src/core/system/FileManagerModule.cpp` | 1 | `platform::filesystemUsed in tick1s`, blocking call on the render path 1 | -| `src/core/system/FilesystemModule.h` | 1 | `FilesystemModule in 24 files`, name spread 24 | +| `src/core/system/FilesystemModule.h` | 1 | `FilesystemModule in 25 files`, name spread 25 | | `src/core/system/I2cBusModule.h` | 1 | `I2cBusModule::scan`, complex function 18 | -| `src/core/system/SdCardModule.cpp` | 1 | `SdCardModule::refreshGauge in tick1s`, blocking call on the render path 1 | | `src/core/util/Addressing.h` | 1 | `sendAddressed`, complex function 11 | | `src/core/util/CaptivePortal.h` | 1 | `captive::dnsReply`, complex function 21 | | `src/core/util/ImprovFrame.h` | 1 | `ImprovFrameParser::feed`, complex function 22 | @@ -348,7 +349,7 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/platform_config.h` | 1 | `lines 241-255, also in src/platform/desktop/platform_config.h:71`, duplicated block 15 | | `src/platform/esp32/platform_esp32_i2s.cpp` | 1 | `platform::audioMicInit`, complex function 11 | | `src/platform/esp32/platform_esp32_tasks.cpp` | 1 | `platform::taskSnapshot`, complex function 12 | -| `src/platform/platform.h` | 1 | `platform.h`, large file 1350 | +| `src/platform/platform.h` | 1 | `platform.h`, large file 1352 | | `src/ui/install-picker.js` | 1 | `install-picker.js`, large file 1048 | | `src/ui/preview3d.js` | 1 | `preview3d.js`, large file 1431 | | `test/js/installer-eth-only.test.mjs` | 1 | `lines 13-23, also in test/js/ui-mooncloud.test.mjs:6`, duplicated block 11 | @@ -364,6 +365,7 @@ The next function worth simplifying is at the top of each file's list. | `test/scenario_runner.cpp` | 1 | `scenario_runner.cpp`, large file 1075 | | `test/unit/core/unit_ControlModule.cpp` | 1 | `unit_ControlModule.cpp`, large file 2152 | | `test/unit/core/unit_Control_apply_absent_key.cpp` | 1 | `lines 92-103, also in test/unit/core/unit_HttpServerModule_apply.cpp:40`, duplicated block 12 | +| `test/unit/core/unit_HttpServerModule_connections.cpp` | 1 | `lines 18-27, also in test/unit/core/unit_HttpServerModule_sdcard.cpp:23`, duplicated block 10 | | `test/unit/core/unit_moonlive_compiler.cpp` | 1 | `unit_moonlive_compiler.cpp`, large file 1865 | | `test/unit/core/unit_moonlive_fill.cpp` | 1 | `unit_moonlive_fill.cpp`, large file 1380 | | `test/unit/light/unit_BreakoutEffect.cpp` | 1 | `lines 18-27, also in test/unit/light/unit_TetrixEffect.cpp:20`, duplicated block 10 | diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index c7210cc0a..088624257 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,9 +4,9 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks. -**0 error(s)** and **4314 warning(s)** across 422 page(s). +**0 error(s)** and **4318 warning(s)** across 422 page(s). -**5532 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. +**5534 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. An error is in a file that generates a documentation page, a header or a catalog page, so the finding is a defect in what gets published and it fails the gate. A warning is worth fixing without being worth stopping a commit for: any finding in an implementation file, which publishes nothing, and the rules still staged as counts in a header too, the over-wide line and the appendix section nothing refers to. Both are counted here, because a warning nobody sees is a warning nobody fixes. @@ -17,7 +17,7 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| | split paragraphs | 0 | 1799 | -| over-wide comment lines | 0 | 1594 | +| over-wide comment lines | 0 | 1598 | | appendix sections nothing refers to | 0 | 372 | | multi-line comment blocks | 0 | 321 | | over-long sentences | 0 | 82 | @@ -30,10 +30,10 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier | Summary page | Errors | Warnings | |---|---:|---:| -| `(tests, no card)` | 0 | 2071 | -| `platform/index.md` | 0 | 746 | +| `(tests, no card)` | 0 | 2072 | +| `platform/index.md` | 0 | 747 | | `light/effects.md` | 0 | 403 | -| `core/system.md` | 0 | 221 | +| `core/system.md` | 0 | 223 | | `core/supporting.md` | 0 | 181 | | `light/power-functions.md` | 0 | 147 | | `core/services.md` | 0 | 123 | @@ -51,7 +51,7 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul ### (tests, no card) -**0 error(s)** and **2071 warning(s)** across 160 file(s). +**0 error(s)** and **2072 warning(s)** across 160 file(s). | Findings | File | | |---:|---|---| @@ -65,13 +65,13 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul | 48 | `test/unit/light/unit_Effects_gridsweep.cpp` | warning | | 45 | `test/unit/core/unit_moonlive_codegen_xtensa.cpp` | warning | | 42 | `test/unit/core/unit_FilesystemModule_persistence.cpp` | warning | -| 1-42 each | *150 more warning files, 1305 findings* | | +| 1-42 each | *150 more warning files, 1306 findings* | | -By rule, warnings: 1363 over-wide comment lines, 273 split paragraphs, 198 multi-line comment blocks, 82 over-long sentences, 78 over-wide doc lines, 68 member deep dives, 9 appendix sections nothing refers to. +By rule, warnings: 1364 over-wide comment lines, 273 split paragraphs, 198 multi-line comment blocks, 82 over-long sentences, 78 over-wide doc lines, 68 member deep dives, 9 appendix sections nothing refers to. ### platform/index.md -**0 error(s)** and **746 warning(s)** across 34 file(s). +**0 error(s)** and **747 warning(s)** across 34 file(s). | Findings | File | | |---:|---|---| @@ -85,9 +85,9 @@ By rule, warnings: 1363 over-wide comment lines, 273 split paragraphs, 198 multi | 32 | `src/platform/esp32/moonlive_asm_riscv.cpp` | warning | | 23 | `src/platform/esp32/platform_esp32_i2s.cpp` | warning | | 20 | `src/platform/esp32/platform_esp32_improv.cpp` | warning | -| 1-19 each | *24 more warning files, 149 findings* | | +| 1-19 each | *24 more warning files, 150 findings* | | -By rule, warnings: 399 split paragraphs, 219 over-wide comment lines, 114 multi-line comment blocks, 14 appendix sections nothing refers to. +By rule, warnings: 399 split paragraphs, 220 over-wide comment lines, 114 multi-line comment blocks, 14 appendix sections nothing refers to. ### light/effects.md @@ -111,7 +111,7 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. ### core/system.md -**0 error(s)** and **221 warning(s)** across 26 file(s). +**0 error(s)** and **223 warning(s)** across 26 file(s). | Findings | File | | |---:|---|---| @@ -125,9 +125,9 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. | 12 | `src/core/system/TasksModule.h` | warning | | 11 | `src/core/system/MqttModule.h` | warning | | 11 | `src/core/system/MqttPacket.h` | warning | -| 1-11 each | *16 more warning files, 76 findings* | | +| 1-11 each | *16 more warning files, 78 findings* | | -By rule, warnings: 164 split paragraphs, 37 appendix sections nothing refers to, 11 over-wide comment lines, 9 multi-line comment blocks. +By rule, warnings: 164 split paragraphs, 37 appendix sections nothing refers to, 13 over-wide comment lines, 9 multi-line comment blocks. ### core/supporting.md diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index e4d130602..12c6f178e 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "17dae99d", + "commit": "745339aa", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, - "esp32p4rev1-eth": 2328416, + "esp32p4rev1-eth": 2328752, "esp32p4rev1-eth-wifi": 2358896, "esp32s3-n8r8": 2336304, "esp32s31": 2595200, @@ -17,7 +17,7 @@ "desktop-macos": 2270440, "desktop-windows": 1925120, "esp32c3": 2335072, - "desktop-linux": 2547336 + "desktop-linux": 2547464 }, "measured": { "esp32p4rev1-eth": "2026-10-11", @@ -54,12 +54,6 @@ "p95": 2744, "n": 4, "last": "2026-10-04" - }, - "desktop-linux": { - "p50": 18, - "p95": 18, - "n": 1, - "last": "2026-10-11" } }, "Firmware_reports_what_is_running": { @@ -126,12 +120,6 @@ "p95": 2800, "n": 3, "last": "2026-10-04" - }, - "desktop-linux": { - "p50": 7, - "p95": 7, - "n": 1, - "last": "2026-10-11" } }, "Aurora_fps": { @@ -520,12 +508,6 @@ "p95": 218, "n": 1, "last": "2026-09-30" - }, - "desktop-linux": { - "p50": 7, - "p95": 7, - "n": 1, - "last": "2026-10-11" } }, "Effects_pipeline_builds_and_renders": { @@ -535,12 +517,6 @@ "n": 32, "last": "2026-10-10" }, - "desktop-linux": { - "p50": 8, - "p95": 8, - "n": 1, - "last": "2026-10-11" - }, "desktop-windows": { "p50": 4, "p95": 6, @@ -572,12 +548,6 @@ "p95": 287, "n": 1, "last": "2026-09-30" - }, - "desktop-linux": { - "p50": 5, - "p95": 5, - "n": 1, - "last": "2026-10-11" } }, "Effects_teardown_under_a_running_pipeline": { @@ -604,12 +574,6 @@ "p95": 482, "n": 1, "last": "2026-09-30" - }, - "desktop-linux": { - "p50": 10, - "p95": 10, - "n": 1, - "last": "2026-10-11" } }, "Layers_stack_and_blend_live": { @@ -618,12 +582,6 @@ "p95": 355, "n": 32, "last": "2026-10-10" - }, - "desktop-linux": { - "p50": 519, - "p95": 519, - "n": 1, - "last": "2026-10-11" } }, "Layouts_resize_reallocates_live": { @@ -650,12 +608,6 @@ "p95": 579, "n": 1, "last": "2026-09-30" - }, - "desktop-linux": { - "p50": 22, - "p95": 22, - "n": 1, - "last": "2026-10-11" } }, "Modifiers_reshape_the_mapping": { @@ -682,12 +634,6 @@ "p95": 273, "n": 1, "last": "2026-09-30" - }, - "desktop-linux": { - "p50": 11, - "p95": 11, - "n": 1, - "last": "2026-10-11" } }, "Palettes_a_script_drives_the_effects": { @@ -696,12 +642,6 @@ "p95": 74, "n": 32, "last": "2026-10-10" - }, - "desktop-linux": { - "p50": 9, - "p95": 9, - "n": 1, - "last": "2026-10-11" } } }, @@ -742,52 +682,38 @@ } }, "desktop-linux": { - "tick_us": 8, - "fps": 125000, - "scenario_p50": { - "Effects_pipeline_builds_and_renders": { - "p50": 8, - "p95": 8, - "n": 1, - "last": "2026-10-11" - }, - "Layouts_resize_reallocates_live": { - "p50": 22, - "p95": 22, - "n": 1, - "last": "2026-10-11" - } - } + "tick_us": 7, + "fps": 142857 } }, "loc": { - "core": 28472, + "core": 28663, "light": 31027, - "platform": 18227, + "platform": 18760, "ui": 12350, - "test": 64597, + "test": 65084, "moondeck": 30600 }, "comments": { "core": { - "lines": 6190, - "ratio": 0.241 + "lines": 6241, + "ratio": 0.242 }, "light": { "lines": 7439, "ratio": 0.269 }, "platform": { - "lines": 3700, - "ratio": 0.227 + "lines": 3776, + "ratio": 0.225 }, "ui": { "lines": 3472, "ratio": 0.298 }, "test": { - "lines": 6968, - "ratio": 0.125 + "lines": 6997, + "ratio": 0.124 }, "moondeck": { "lines": 4906, @@ -795,20 +721,20 @@ } }, "tests": { - "cases": 2398, + "cases": 2417, "scenarios": 30 }, "docs": { "md_files": 150, - "md_lines": 32281, + "md_lines": 32283, "plans_files": 44, "backlog_lines": 3319, "lessons_lines": 532, "claude_md_lines": 283 }, "complexity": { - "functions": 4470, - "over_threshold": 337, + "functions": 4475, + "over_threshold": 338, "worst_ccn": 128 } } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 3e7a6fae9..6d048cada 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `17dae99d`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `745339aa`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -10,19 +10,19 @@ Current state only; the trend is this file's git history (`git log -p docs/refer |---|---:|---:|---:| | flash desktop-macos | 1,963 KB | 2,217 KB | +254.2 KB (+12.9%) | | flash esp32 | 2,061 KB | 2,174 KB | +112.9 KB (+5.5%) | -| flash esp32p4rev1-eth | 2,022 KB | 2,274 KB | +252.1 KB (+12.5%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,274 KB | +252.5 KB (+12.5%) | | flash esp32p4rev3-eth | 2,025 KB | 2,152 KB | +126.8 KB (+6.3%) | | flash esp32s3-n16r8 | 2,111 KB | 2,282 KB | +170.1 KB (+8.1%) | | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | | flash esp32s3-zero | 1,977 KB | 2,180 KB | +203.2 KB (+10.3%) | | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | -| lines core | 22,299 | 28,472 | +6,173 (+27.7%) | +| lines core | 22,299 | 28,663 | +6,364 (+28.5%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | -| lines platform | 16,798 | 18,227 | +1,429 (+8.5%) | +| lines platform | 16,798 | 18,760 | +1,962 (+11.7%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | -| lines test | 57,759 | 64,597 | +6,838 (+11.8%) | +| lines test | 57,759 | 65,084 | +7,325 (+12.7%) | | lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | -| unit tests | 2,120 | 2,398 | +278 (+13.1%) | +| unit tests | 2,120 | 2,417 | +297 (+14.0%) | | scenarios | 21 | 30 | +9 (+42.9%) | Against the release `v6.0.0`, the fixed point a user compares with; the tables below compare against the last commit. A firmware not rebuilt since `v6.0.0` is left out, and a measure the release did not record yet, such as the code report, joins from the next one. @@ -31,7 +31,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop-linux | 2,488 KB | - | - | yes | +| desktop-linux | 2,488 KB (+128 B) โš  | - | - | yes | | desktop-macos | 2,217 KB | - | - | carried 1d | | desktop-windows | 1,880 KB | - | - | **STALE 11d** | | esp32 | 2,174 KB | 2,496 KB | 87% | carried 2d | @@ -40,12 +40,12 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 32d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | | esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 1d | -| esp32p4rev1-eth | 2,274 KB (+147 KB) โš  | 4,096 KB | 56% | yes | +| esp32p4rev1-eth | 2,274 KB (+336 B) โš  | 4,096 KB | 56% | yes | | esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 11d** | | esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 3d | | esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 1d | | esp32s3-n8r8 | 2,282 KB | 3,072 KB | 74% | carried 1d | -| esp32s3-zero | 2,180 KB (+13 KB) โš  | 2,496 KB | 87% | carried 0d | +| esp32s3-zero | 2,180 KB | 2,496 KB | 87% | carried 0d | | esp32s31 | 2,534 KB | 4,096 KB | 62% | carried 1d | | qemu | 1,351 KB | - | - | carried (age?) | @@ -55,47 +55,38 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 8 ยตs | 125,000 | +| desktop-linux | 7 ยตs (โˆ’1 ยตs) โœ“ | 142,857 (+17,857) โœ“ | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | | esp32 | 8,354 ยตs | 119 | ### Scenario tick by target (p50 of each sample window) -| Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | desktop-docker-arm64 | desktop-linux | esp32p4rev1-eth-wifi | -|---|---|---|---|---|---|---|---|---|---|---|---| -| Aurora_fps | 1,549 | 6,015 ? | 217,295 ? | 188,782 ? | 83,632 ? | 87,613 ? | - | - | 7,532 ? | - | 1,942,284 ? | -| Control_a_palette_preset_changes_only_the_palette | 7 | - | - | 2,073 | - | - | - | - | - | 18 ? | - | -| Drivers_output_and_brightness | 7 | 4 ? | - | 8,690 ? | - | - | - | - | 218 ? | 7 ? | - | -| Effects_pipeline_builds_and_renders | 7 | 4 ? | - | - | - | - | - | - | - | 8 ? | - | -| Effects_swap_while_running | 1 | 4 ? | - | 10,000 ? | - | - | - | - | 287 ? | 5 ? | - | -| Effects_teardown_under_a_running_pipeline | 7 | 17 ? | - | 9,080 | - | - | - | - | 482 ? | 10 ? | - | -| Firmware_reports_what_is_running | 29 | 1,827 ? | - | 8,659 ? | - | - | - | - | 215 ? | - | - | -| Fluid_solver | 218 | 685,902 ? | 45,974 ? | 102,058 ? | 71,150 ? | 66,874 ? | - | - | 3,371 ? | - | 761,768 ? | -| GridLayout_resize | 120 | 219 ? | 1,352 ? | 18,414 ? | 7,218 ? | 8,919 ? | 95,771 ? | 82,231 ? | 2,005 ? | - | 6,735,719 ? | -| Layers_stack_and_blend_live | 267 | - | - | - | - | - | - | - | - | 519 ? | - | -| Layouts_resize_reallocates_live | 10 | 93 ? | - | 32,399 ? | - | - | - | - | 579 ? | 22 ? | - | -| Modifiers_reshape_the_mapping | 7 | 29 ? | - | 10,350 ? | - | - | - | - | 273 ? | 11 ? | - | -| Network_hardware_reconfigures_live | 21 ? | 2,834 ? | - | 8,692 ? | - | - | - | - | 237 ? | - | - | -| Palettes_a_script_drives_the_effects | 7 | - | - | - | - | - | - | - | - | 9 ? | - | -| State_one_document_adds_an_effect_with_its_controls | 4 | - | - | 2,505 ? | - | - | - | - | - | 7 ? | - | -| perf_full | 256 | 592 ? | 5,313 | 25,340 ? | 11,711 ? | 13,373 ? | - | - | 2,438 ? | - | 5,469,660 ? | -| perf_light | 16 | 35 ? | 2,510 | 3,044 | 1,117 ? | 1,378 ? | - | - | 780 ? | - | 5,385,763 ? | -| peripheral_grid_sweep | 257 | 649 ? | 5,766 | 70,668 ? | 11,495 ? | 12,273 ? | - | - | 2,577 ? | - | 1,673,544 ? | -| peripheral_switch | 4 | 9 ? | 425 | 2,307 | 2,570 ? | 2,990 ? | - | - | 341 ? | - | 773,191 ? | +| Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | desktop-docker-arm64 | esp32p4rev1-eth-wifi | +|---|---|---|---|---|---|---|---|---|---|---| +| Aurora_fps | 1,549 | 6,015 ? | 217,295 ? | 188,782 ? | 83,632 ? | 87,613 ? | - | - | 7,532 ? | 1,942,284 ? | +| Control_a_palette_preset_changes_only_the_palette | 7 | - | - | 2,073 | - | - | - | - | - | - | +| Drivers_output_and_brightness | 7 | 4 ? | - | 8,690 ? | - | - | - | - | 218 ? | - | +| Effects_pipeline_builds_and_renders | 7 | 4 ? | - | - | - | - | - | - | - | - | +| Effects_swap_while_running | 1 | 4 ? | - | 10,000 ? | - | - | - | - | 287 ? | - | +| Effects_teardown_under_a_running_pipeline | 7 | 17 ? | - | 9,080 | - | - | - | - | 482 ? | - | +| Firmware_reports_what_is_running | 29 | 1,827 ? | - | 8,659 ? | - | - | - | - | 215 ? | - | +| Fluid_solver | 218 | 685,902 ? | 45,974 ? | 102,058 ? | 71,150 ? | 66,874 ? | - | - | 3,371 ? | 761,768 ? | +| GridLayout_resize | 120 | 219 ? | 1,352 ? | 18,414 ? | 7,218 ? | 8,919 ? | 95,771 ? | 82,231 ? | 2,005 ? | 6,735,719 ? | +| Layers_stack_and_blend_live | 267 | - | - | - | - | - | - | - | - | - | +| Layouts_resize_reallocates_live | 10 | 93 ? | - | 32,399 ? | - | - | - | - | 579 ? | - | +| Modifiers_reshape_the_mapping | 7 | 29 ? | - | 10,350 ? | - | - | - | - | 273 ? | - | +| Network_hardware_reconfigures_live | 21 ? | 2,834 ? | - | 8,692 ? | - | - | - | - | 237 ? | - | +| Palettes_a_script_drives_the_effects | 7 | - | - | - | - | - | - | - | - | - | +| State_one_document_adds_an_effect_with_its_controls | 4 | - | - | 2,505 ? | - | - | - | - | - | - | +| perf_full | 256 | 592 ? | 5,313 | 25,340 ? | 11,711 ? | 13,373 ? | - | - | 2,438 ? | 5,469,660 ? | +| perf_light | 16 | 35 ? | 2,510 | 3,044 | 1,117 ? | 1,378 ? | - | - | 780 ? | 5,385,763 ? | +| peripheral_grid_sweep | 257 | 649 ? | 5,766 | 70,668 ? | 11,495 ? | 12,273 ? | - | - | 2,577 ? | 1,673,544 ? | +| peripheral_switch | 4 | 9 ? | 425 | 2,307 | 2,570 ? | 2,990 ? | - | - | 341 ? | 773,191 ? | Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first impression rather than a percentile; several are months old and were captured during a network reconfigure, so they read as whole milliseconds. `-` means that target has never run that scenario. -**Coverage: 104/209 cells measured (49%), 26 of them with 4+ samples (12%).** The blanks are the point: a regression on a target that has never run a scenario cannot be DETECTED in it, and the target cannot be compared against the others. Filling the matrix means running the scenario suite on each bench board, which is a standing task rather than a one-off. - -### desktop-linux: isolated scenarios (p50 of the sample window) - -| Scenario | p50 | p95 | n | -|---|---:|---:|---:| -| Effects_pipeline_builds_and_renders | 8 ยตs | 8 ยตs | 1 | -| Layouts_resize_reallocates_live | 22 ยตs | 22 ยตs | 1 | - -These build a bare pipeline with no optional modules, so a change here is a change in the pipeline itself rather than in what was measured. A new module belongs in an advanced scenario, which keeps its own numbers. +**Coverage: 94/190 cells measured (49%), 26 of them with 4+ samples (13%).** The blanks are the point: a regression on a target that has never run a scenario cannot be DETECTED in it, and the target cannot be compared against the others. Filling the matrix means running the scenario suite on each bench board, which is a standing task rather than a one-off. ### desktop-macos: isolated scenarios (p50 of the sample window) @@ -119,26 +110,26 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,472 (+201) โš  | 6,190 | 24.1 % (โˆ’0.1 %) โœ“ | +| core | 28,663 (+191) โš  | 6,241 | 24.2 % (+0.1 %) โš  | | light | 31,027 | 7,439 | 26.9 % | -| platform | 18,227 (+297) โš  | 3,700 | 22.7 % (โˆ’0.1 %) โœ“ | -| ui | 12,350 (+43) โš  | 3,472 | 29.8 % | -| test | 64,597 | 6,968 | 12.5 % | +| platform | 18,760 (+533) โš  | 3,776 | 22.5 % (โˆ’0.2 %) โœ“ | +| ui | 12,350 | 3,472 | 29.8 % | +| test | 65,084 (+487) โš  | 6,997 | 12.4 % (โˆ’0.1 %) โœ“ | | moondeck | 30,600 | 4,906 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,398 | +| unit cases | 2,417 (+19) โœ“ | | scenarios | 30 | ## Complexity | Metric | Value | |---|---:| -| functions | 4,470 (+81) โœ“ | -| over threshold | 337 (+6) โš  | +| functions | 4,475 (+5) โœ“ | +| over threshold | 338 (+1) โš  | | worst CCN | 128 | ## Documentation @@ -146,7 +137,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 150 | -| markdown lines | 32,281 (+10) โš  | +| markdown lines | 32,283 (+2) โš  | | plan files | 44 | | backlog lines | 3,319 | | lessons lines | 532 | diff --git a/src/core/system/SdCardModule.cpp b/src/core/system/SdCardModule.cpp index 70f9d49cb..f5028ca4f 100644 --- a/src/core/system/SdCardModule.cpp +++ b/src/core/system/SdCardModule.cpp @@ -41,26 +41,38 @@ bool SdCardModule::affectsPrepare(const char* name) const { std::strcmp(name, "pwrPin") == 0; } +namespace { +bool samePins(const platform::SdPinConfig& a, const platform::SdPinConfig& b) { + return a.clk == b.clk && a.cmd == b.cmd && a.d0 == b.d0 && + a.d1 == b.d1 && a.d2 == b.d2 && a.d3 == b.d3 && a.pwr == b.pwr; +} +} // namespace + void SdCardModule::prepare() { + platform::SdPinConfig pins{}; + pins.clk = clkPin_; + pins.cmd = cmdPin_; + pins.d0 = d0Pin_; + pins.d1 = d1Pin_; + pins.d2 = d2Pin_; + pins.d3 = d3Pin_; + pins.pwr = pwrPin_; + // prepare() runs on any affectsPrepare change anywhere in the tree, so most calls are about some other module entirely; skip the unmount/remount round trip when this instance's own wiring has not actually changed, rather than tearing down a mount (and any stream open on it) for nothing. + if (mounted_ && samePins(pins, mountedPins_)) return; + const bool wasMounted = mounted_; - // A pin edit lands here too: unmount what an earlier wiring opened before trying the new one. - if (mounted_) { platform::sdUnmount(); mounted_ = false; } + if (mounted_) { + if (!platform::sdUnmount()) { setStatus("busy: transfer in progress", Severity::Warning); return; } + mounted_ = false; + } if constexpr (!platform::hasSdCard) { setStatus("no SD hardware on this build", Severity::Status); } else if (clkPin_ < 0 || cmdPin_ < 0 || d0Pin_ < 0) { setStatus("set the SD card pins first", Severity::Warning); } else { - platform::SdPinConfig pins{}; - pins.clk = clkPin_; - pins.cmd = cmdPin_; - pins.d0 = d0Pin_; - pins.d1 = d1Pin_; - pins.d2 = d2Pin_; - pins.d3 = d3Pin_; - pins.pwr = pwrPin_; mounted_ = platform::sdMount(pins); - if (mounted_) { refreshGauge(); clearStatus(); } + if (mounted_) { mountedPins_ = pins; refreshGauge(); clearStatus(); } else setStatus("mount failed: check the card is inserted and formatted FAT32", Severity::Error); } // defineControls() ran with the OLD mounted_, so its "mounted_ && totalKiB_ > 0" gate above saw a stale value either way the state just moved; one rebuild picks up or drops the gauge. @@ -68,13 +80,14 @@ void SdCardModule::prepare() { } void SdCardModule::release() { - if (mounted_) { platform::sdUnmount(); mounted_ = false; } + if (mounted_ && platform::sdUnmount()) mounted_ = false; MoonModule::release(); } void SdCardModule::onControlChanged(const char* controlName) { if (std::strcmp(controlName, "format") != 0) return; if (!mounted_) { setStatus("mount a card before formatting", Severity::Warning); markDirty(); return; } + if (platform::sdCardBusy()) { setStatus("busy: transfer in progress", Severity::Warning); markDirty(); return; } if (!platform::sdFormat()) { setStatus("format failed", Severity::Error); markDirty(); return; } refreshGauge(); clearStatus(); diff --git a/src/core/system/SdCardModule.h b/src/core/system/SdCardModule.h index 4530a554a..c206e8f6e 100644 --- a/src/core/system/SdCardModule.h +++ b/src/core/system/SdCardModule.h @@ -1,6 +1,7 @@ #pragma once #include "core/module/MoonModule.h" +#include "platform/platform.h" // SdPinConfig #include @@ -22,7 +23,9 @@ namespace mm { /// ## The mount re-attempts on every pin change, like the bus it rides /// /// `prepare()`, not `setup()`: a pin edit unmounts and remounts on the new wiring, the same live-reconfiguration contract I2cBusModule's sda/scl have. +/// `prepare()` runs tree-wide on any `affectsPrepare` change, so a stored copy of the mounted pins lets an unrelated one return without touching the card at all. /// A missing or unreadable card shows as its own status rather than retried every tick; reinsert and touch a pin field (or reboot) to retry. +/// An unmount or a format while a stream is open is refused rather than run underneath it: the status reads "busy: transfer in progress" instead. /// /// ## No panel, no view toggle /// @@ -53,6 +56,7 @@ class SdCardModule : public MoonModule { private: bool mounted_ = false; ///< true once platform::sdMount() has succeeded + platform::SdPinConfig mountedPins_{}; ///< the pins mounted_ was last mounted with, so an unrelated prepare() is a no-op uint32_t usedKiB_ = 0; ///< card usage, in KiB: @xref{why-kib-not-the-shared-progress-controls-bytes} uint32_t totalKiB_ = 0; ///< the card's capacity, in KiB, read once on mount uint8_t secondsSinceScan_ = 0; ///< paces the usage scan diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 967d28d18..87fee16f4 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -869,12 +869,20 @@ std::filesystem::path fsRoot_{defaultRoot()}; // No real card on a host, so "/sd" is a subfolder of the same root, gated by this flag the way a missing card gates the real mount: @xref{why-sd-is-a-subfolder-not-a-second-root}. bool sdMounted_ = false; +// Every open /sd read/write stream, counted so sdUnmount/sdFormat can refuse under one; a flash stream through the same fsReadStreamOpen/fsWriteStreamOpen does not count, since unmounting the card has nothing to do with it. +int sdStreamsOpen_ = 0; /// Whether `path` (leading slashes already stripped) names the SD mount: "sd" or "sd/...". bool isSdApiPath(const char* path) { return path[0] == 's' && path[1] == 'd' && (path[2] == '\0' || path[2] == '/'); } +/// isSdApiPath for a raw API path, leading slashes and all. +bool pathIsSd(const char* path) { + while (*path == '/') path++; + return isSdApiPath(path); +} + // Map an API path onto the root, normalize it, and reject one that escapes; an empty result reads as failure, which callers already handle. std::filesystem::path toFsPath(const char* path) { if (!path) return {}; @@ -1054,29 +1062,43 @@ bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { return true; } +namespace { +// Tags whether this handle counts toward sdStreamsOpen_, so close() decrements exactly what open() counted. +struct DesktopReadHandle { FILE* f = nullptr; bool isSd = false; }; +} + bool fsReadStreamOpen(FsReadStream& h, const char* path) { FILE* f = std::fopen(toFsPath(path).string().c_str(), "rb"); if (!f) return false; - h.impl = f; + auto* rh = new DesktopReadHandle(); + rh->f = f; + rh->isSd = pathIsSd(path); + if (rh->isSd) ++sdStreamsOpen_; + h.impl = rh; return true; } int fsReadStreamRead(FsReadStream& h, char* buf, size_t len) { - auto* f = static_cast(h.impl); - if (!f) return -1; - const size_t n = std::fread(buf, 1, len, f); - if (n == 0 && std::ferror(f)) return -1; + auto* rh = static_cast(h.impl); + if (!rh) return -1; + const size_t n = std::fread(buf, 1, len, rh->f); + if (n == 0 && std::ferror(rh->f)) return -1; return static_cast(n); } void fsReadStreamClose(FsReadStream& h) { - if (h.impl) std::fclose(static_cast(h.impl)); + auto* rh = static_cast(h.impl); + if (rh) { + std::fclose(rh->f); + if (rh->isSd) --sdStreamsOpen_; + delete rh; + } h.impl = nullptr; } namespace { -// Both paths live alongside the FILE*, so close() can rename without re-deriving them. -struct DesktopWriteHandle { FILE* f = nullptr; std::filesystem::path target, tmp; }; +// Both paths live alongside the FILE*, so close() can rename without re-deriving them; isSd mirrors DesktopReadHandle's. +struct DesktopWriteHandle { FILE* f = nullptr; std::filesystem::path target, tmp; bool isSd = false; }; } bool fsWriteStreamOpen(FsWriteStream& h, const char* path) { @@ -1089,6 +1111,8 @@ bool fsWriteStreamOpen(FsWriteStream& h, const char* path) { wh->tmp += ".tmp"; wh->f = openOwnerOnly(wh->tmp.string().c_str()); if (!wh->f) { delete wh; return false; } + wh->isSd = pathIsSd(path); + if (wh->isSd) ++sdStreamsOpen_; h.impl = wh; return true; } @@ -1115,6 +1139,7 @@ bool fsWriteStreamClose(FsWriteStream& h, bool commit) { bool ok = true; if (commit) { std::filesystem::rename(wh->tmp, wh->target, ec); ok = !ec; } if (!commit || !ok) std::filesystem::remove(wh->tmp, ec); + if (wh->isSd) --sdStreamsOpen_; delete wh; h.impl = nullptr; return ok; @@ -1175,10 +1200,18 @@ bool sdMount(const SdPinConfig& pins) { return true; } -void sdUnmount() { sdMounted_ = false; } +bool sdCardBusy() { return sdStreamsOpen_ > 0; } + +bool sdUnmount() { + if (!sdMounted_) return true; + if (sdStreamsOpen_ > 0) return false; // the ESP32 conflict this fiction still models: a stream mid-transfer + sdMounted_ = false; + return true; +} bool sdFormat() { if (!sdMounted_) return false; + if (sdStreamsOpen_ > 0) return false; std::error_code ec; std::filesystem::remove_all(fsRoot_ / "sd", ec); std::filesystem::create_directories(fsRoot_ / "sd", ec); diff --git a/src/platform/esp32/platform_esp32_sdmmc.cpp b/src/platform/esp32/platform_esp32_sdmmc.cpp index 8b69b6003..e89a456c1 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.cpp +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -71,6 +71,7 @@ static constexpr const char* SD_DRIVE = "0:"; static sdmmc_card_t* g_card = nullptr; static bool g_mounted = false; +static int g_streamsOpen = 0; // every open read/write stream, counted so unmount/format can refuse under one static int8_t g_pwrGpio = -1; // so unmount powers back down what mount powered up #if SOC_SDMMC_IO_POWER_EXTERNAL static sd_pwr_ctrl_handle_t g_pwrCtrl = nullptr; @@ -90,6 +91,8 @@ static bool sdTranslate(const char* apiPath, char* out, size_t outLen) { bool sdCardMounted() { return g_mounted; } +bool sdCardBusy() { return g_streamsOpen > 0; } + /// A pin this build never wires reads as -1; one it does but the chip refuses is a mount-time mistake, not UB. static bool validOutputPin(int8_t pin) { return pin >= 0 && GPIO_IS_VALID_OUTPUT_GPIO(pin); @@ -163,8 +166,9 @@ bool sdMount(const SdPinConfig& pins) { return true; } -void sdUnmount() { - if (!g_mounted) return; +bool sdUnmount() { + if (!g_mounted) return true; + if (g_streamsOpen > 0) return false; // a transfer holds a FIL into the context this would free esp_vfs_fat_sdcard_unmount(SD_MOUNT_POINT, g_card); #if SOC_SDMMC_IO_POWER_EXTERNAL if (g_pwrCtrl) { sd_pwr_ctrl_del_on_chip_ldo(g_pwrCtrl); g_pwrCtrl = nullptr; } @@ -172,10 +176,12 @@ void sdUnmount() { if (g_pwrGpio >= 0) gpio_set_level(static_cast(g_pwrGpio), 1); g_card = nullptr; g_mounted = false; + return true; } bool sdFormat() { if (!g_mounted || !g_card) return false; + if (g_streamsOpen > 0) return false; // same conflict as an unmount under an open stream // Formats and remounts in one call; the mount stays live either way. return esp_vfs_fat_sdcard_format(SD_MOUNT_POINT, g_card) == ESP_OK; } @@ -357,6 +363,7 @@ bool sdCardReadStreamOpen(void*& handle, const char* apiPath) { auto* f = new FIL(); if (f_open(f, full, FA_READ) != FR_OK) { delete f; return false; } handle = f; + ++g_streamsOpen; return true; } @@ -373,6 +380,7 @@ void sdCardReadStreamClose(void* handle) { if (!f) return; f_close(f); delete f; + --g_streamsOpen; } // Carries both paths, not just the FIL, so close() can rename or discard without re-deriving them. @@ -391,6 +399,7 @@ bool sdCardWriteStreamOpen(void*& handle, const char* apiPath) { if (n < 0 || static_cast(n) >= sizeof(h->tmp)) { delete h; return false; } if (f_open(&h->f, h->tmp, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) { delete h; return false; } handle = h; + ++g_streamsOpen; return true; } @@ -408,6 +417,7 @@ bool sdCardWriteStreamClose(void* handle, bool commit) { const bool ok = commit ? sdCommitTmp(h->tmp, h->full) : true; if (!commit) f_unlink(h->tmp); delete h; + --g_streamsOpen; return ok; } @@ -415,7 +425,8 @@ bool sdCardWriteStreamClose(void* handle, bool commit) { bool sdCardMounted() { return false; } bool sdMount(const SdPinConfig&) { return false; } -void sdUnmount() {} +bool sdCardBusy() { return false; } +bool sdUnmount() { return true; } bool sdFormat() { return false; } uint64_t sdCardUsed() { return 0; } uint64_t sdCardTotal() { return 0; } diff --git a/src/platform/platform.h b/src/platform/platform.h index 828ac5470..ac5ae6310 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -328,9 +328,11 @@ struct SdPinConfig { }; /// Mount the card under `/sd`, idempotent; false if no card, no slot, or the pins are unset. bool sdMount(const SdPinConfig& pins); -/// Unmount it; safe when nothing is mounted. -void sdUnmount(); -/// Format and remount; false while unmounted or on a format failure. +/// True while a read or write stream is open on the card; sdUnmount/sdFormat refuse while this holds, so a caller checks it first to give the specific reason. +bool sdCardBusy(); +/// Unmount it; false (refused) while a stream is open, true otherwise, including when nothing was mounted. +bool sdUnmount(); +/// Format and remount; false while unmounted, while a stream is open, or on a format failure. bool sdFormat(); /// Card bytes used, 0 when unmounted; wide because a real card routinely exceeds 4 GiB. uint64_t sdCardUsed(); diff --git a/test/unit/core/unit_HttpServerModule_sdcard.cpp b/test/unit/core/unit_HttpServerModule_sdcard.cpp index 87ef3b91f..1bb0df34a 100644 --- a/test/unit/core/unit_HttpServerModule_sdcard.cpp +++ b/test/unit/core/unit_HttpServerModule_sdcard.cpp @@ -161,6 +161,8 @@ TEST_CASE("an SD download larger than one tick's chunk streams across several ti CHECK(body.size() == payload.size()); CHECK(body == payload); + // The last byte lands this tick, but driveSdDownload only notices bufSent caught up to bufLen and closes the read stream on the NEXT one; give it a few before the fixture unmounts underneath it. + for (int i = 0; i < 5; i++) http.tick20ms(); client.close(); http.release(); } diff --git a/test/unit/core/unit_SdCardModule.cpp b/test/unit/core/unit_SdCardModule.cpp index f26239437..381b84be0 100644 --- a/test/unit/core/unit_SdCardModule.cpp +++ b/test/unit/core/unit_SdCardModule.cpp @@ -57,6 +57,13 @@ struct Rig { } }; +// Wires the required pins and mounts, for a test whose point starts after that. +void mountRig(SdCardModule& sd) { + wireRequiredPins(sd); + sd.prepare(); + REQUIRE(find(sd, "card (KiB)") != nullptr); +} + } // namespace TEST_CASE("the SD pins are visible, since desktop always reports hasSdCard") { @@ -142,6 +149,45 @@ TEST_CASE("format refuses while unmounted, and wipes the card while mounted") { CHECK(std::filesystem::exists(std::string(r.root) + "/sd")); // recreated, still mounted } +TEST_CASE("an unrelated prepare does not remount when the pins have not changed") { + Rig r; + mountRig(r.sd); + + platform::FsWriteStream w{}; + REQUIRE(platform::fsWriteStreamOpen(w, "/sd/clip.bin")); + + r.sd.prepare(); // same pins: prepare() runs tree-wide on any affectsPrepare change, so this must be a no-op + CHECK(r.sd.status() == nullptr); // never tried to unmount, so no "busy" status either + CHECK(find(r.sd, "card (KiB)") != nullptr); // mount state undisturbed + + REQUIRE(platform::fsWriteStreamWrite(w, "hello", 5)); // the stream the no-op prepare() did not touch + REQUIRE(platform::fsWriteStreamClose(w, true)); + CHECK(std::filesystem::exists(std::string(r.root) + "/sd/clip.bin")); +} + +TEST_CASE("format refuses while a download stream is open, and succeeds once it closes") { + Rig r; + mountRig(r.sd); + + platform::FsWriteStream w{}; + REQUIRE(platform::fsWriteStreamOpen(w, "/sd/clip.bin")); + REQUIRE(platform::fsWriteStreamWrite(w, "hello", 5)); + REQUIRE(platform::fsWriteStreamClose(w, true)); + + platform::FsReadStream rs{}; + REQUIRE(platform::fsReadStreamOpen(rs, "/sd/clip.bin")); + + r.sd.onControlChanged("format"); // a download is in flight + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()) == "busy: transfer in progress"); + CHECK(std::filesystem::exists(std::string(r.root) + "/sd/clip.bin")); // not wiped + + platform::fsReadStreamClose(rs); + r.sd.onControlChanged("format"); // the stream is gone: format now runs + CHECK(r.sd.status() == nullptr); + CHECK_FALSE(std::filesystem::exists(std::string(r.root) + "/sd/clip.bin")); +} + TEST_CASE("tick1s is a no-op while unmounted, and refreshes the gauge once mounted") { Rig r; for (int i = 0; i < 120; i++) r.sd.tick1s(); // two minutes' worth, nothing to show From 73bc49a6276c98c82f68057a5dfd674fab6b685f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Sun, 11 Oct 2026 17:52:18 +0200 Subject: [PATCH 03/20] Move the SD card's one-shot FIL off the stack, onto one static PR #136 review item 2 (blocking): sdCardRead, sdCardReadAt, sdCardWriteAtomic and sdCardWriteStreamCb each declared `FIL f;` as a local, putting a multi-KB FatFs file object on the calling task's stack - the 12 KB main task for anything reached from the render tick. That is the same crash class this branch already hit once in driveSdUpload, just one level lower: not a buffer this time, but FatFs's own file-handle struct. Flash: esp32p4rev1-eth +48 B (2328752 -> 2328800). Account: src +17/-19 lines | test +1/-1 lines | code findings 1642 -> 1640 (-2, no rule rose) | duplicated lines 3773 -> 3773 (+0) Core: - platform_esp32_sdmmc.cpp: one file-scope `static FIL g_fil` now serves all four routines. Safe because every fsXxx call this task makes runs to completion before the next starts - there is no reentrancy between them to guard against - and the kept-open streams (the fifth FIL, inside SdWriteHandle, and the heap-allocated one in sdCardReadStreamOpen) already live off the stack on their own, untouched here. - Reworded two comments ewowi flagged as describing the wrong mechanism: the one near g_fil's declaration and the one on driveSdUpload's own stack-to-member fix from the main commit, which read as if the stack frame accumulated across calls. It is not cumulative - one recurring large frame, often enough to eventually coincide with other deep stack use and panic the board - and the wording now says that. - Shortened two docgen-flagged comment lines (platform_esp32_sdmmc.cpp's sdCommitTmp comment, and the driveSdUpload one above) while in the area; both were over the 30-word line cap. Verified live on esp32p4rev1-eth over serial: a small file and a 6 MB upload both round-tripped through /sd correctly after the rebuild+flash; the card's contents were unaffected. Checks (scoped to this diff's own trigger-table buckets - src/ only, no .md/src/ui/moondeck touched): - check_specs: PASS - 136/136 modules - check_prose: PASS - 0 new findings - check_docgen: PASS - 0 errors, total warnings fell (4320 -> 4317) - check_code: PASS - no rule rose; total findings fell by 2 - build_desktop: PASS (this file is ESP32-only; desktop compiles the one changed comment line in HttpServerModule.cpp) - test_desktop: PASS - 2429/2429 (unaffected by an ESP32-only change; real verification is the live hardware test above) - run_scenario: PASS - 30 scenarios, 17 passed / 13 skipped, 0 failed - check_esp32_built: PASS - fresh after the real rebuild+flash - collect_kpi: PASS - recorded above - Real hardware (esp32p4rev1-eth, over serial): verified as described above --- docs/reference/metrics/code.md | 8 +- docs/reference/metrics/docgen.md | 16 ++-- docs/reference/metrics/repo-health.json | 86 +++++++++++++++++++-- docs/reference/metrics/repo-health.md | 77 ++++++++++-------- src/core/system/HttpServerModule.cpp | 2 +- src/platform/esp32/platform_esp32_sdmmc.cpp | 34 ++++---- 6 files changed, 152 insertions(+), 71 deletions(-) diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index 7846c336e..b6b25dce4 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,13 +4,13 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows. -**1642 finding(s)** across 322 file(s). 2.88% of all lines are duplicated, 3773 lines. +**1640 finding(s)** across 322 file(s). 2.88% of all lines are duplicated, 3767 lines. ## By rule | Rule | Findings | |---|---:| -| duplicated block | 780 | +| duplicated block | 778 | | complex function | 290 | | blocking call on the render path | 195 | | deeply nested | 113 | @@ -47,7 +47,7 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta | `test` | 576 | | `light` | 421 | | `core` | 353 | -| `platform` | 188 | +| `platform` | 186 | | `ui` | 74 | | `moondeck` | 24 | | `src` | 4 | @@ -124,7 +124,6 @@ The next function worth simplifying is at the top of each file's list. | `src/light/effects/FishTankEffect.h` | 7 | `lines 244-258, also in src/light/effects/PacmanEffect.h:243`, duplicated block 15 | | `src/platform/esp32/platform_esp32_hub75.cpp` | 7 | `platform::hub75Init`, complex function 32 | | `src/platform/esp32/platform_esp32_parlio.cpp` | 7 | `lines 277-286, also in src/platform/esp32/platform_esp32_i80.cpp:425`, duplicated block 10 | -| `src/platform/esp32/platform_esp32_sdmmc.cpp` | 7 | `platform::sdMount`, complex function 22 | | `test/js/ui-ws-scheme.test.mjs` | 7 | `lines 10-22, also in test/js/ui-render-guard.test.mjs:13`, duplicated block 13 | | `test/unit/core/unit_NetworkModule.cpp` | 7 | `lines 556-564, also in test/unit/core/unit_NetworkModule.cpp:190`, duplicated block 9 | | `test/unit/light/unit_NdiDriver.cpp` | 7 | `lines 29-46, also in test/unit/light/unit_PanelCardDriver.cpp:22`, duplicated block 18 | @@ -159,6 +158,7 @@ The next function worth simplifying is at the top of each file's list. | `src/light/layouts/TorontoBarGourdsLayout.h` | 5 | `lines 52-68, also in src/light/layouts/CarLightsLayout.h:49`, duplicated block 17 | | `src/platform/desktop/main_desktop.cpp` | 5 | `main`, deeply nested 5 | | `src/platform/esp32/platform_esp32_h264.cpp` | 5 | `platform::hlsSegment`, complex function 26 | +| `src/platform/esp32/platform_esp32_sdmmc.cpp` | 5 | `platform::sdMount`, complex function 22 | | `test/unit/core/unit_HttpServerModule_sdcard.cpp` | 5 | `lines 23-32, also in test/unit/core/unit_HttpServerModule_connections.cpp:18`, duplicated block 10 | | `test/unit/light/unit_Drivers_rendersplit.cpp` | 5 | `(static local variable) in tick`, blocking call on the render path 1 | | `test/unit/light/unit_FireEffect.cpp` | 5 | `lines 29-42, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 14 | diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index 088624257..e0ff0b1e5 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,7 +4,7 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks. -**0 error(s)** and **4318 warning(s)** across 422 page(s). +**0 error(s)** and **4317 warning(s)** across 421 page(s). **5534 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. @@ -16,8 +16,8 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| -| split paragraphs | 0 | 1799 | -| over-wide comment lines | 0 | 1598 | +| split paragraphs | 0 | 1800 | +| over-wide comment lines | 0 | 1596 | | appendix sections nothing refers to | 0 | 372 | | multi-line comment blocks | 0 | 321 | | over-long sentences | 0 | 82 | @@ -31,7 +31,7 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier | Summary page | Errors | Warnings | |---|---:|---:| | `(tests, no card)` | 0 | 2072 | -| `platform/index.md` | 0 | 747 | +| `platform/index.md` | 0 | 746 | | `light/effects.md` | 0 | 403 | | `core/system.md` | 0 | 223 | | `core/supporting.md` | 0 | 181 | @@ -71,7 +71,7 @@ By rule, warnings: 1364 over-wide comment lines, 273 split paragraphs, 198 multi ### platform/index.md -**0 error(s)** and **747 warning(s)** across 34 file(s). +**0 error(s)** and **746 warning(s)** across 33 file(s). | Findings | File | | |---:|---|---| @@ -85,9 +85,9 @@ By rule, warnings: 1364 over-wide comment lines, 273 split paragraphs, 198 multi | 32 | `src/platform/esp32/moonlive_asm_riscv.cpp` | warning | | 23 | `src/platform/esp32/platform_esp32_i2s.cpp` | warning | | 20 | `src/platform/esp32/platform_esp32_improv.cpp` | warning | -| 1-19 each | *24 more warning files, 150 findings* | | +| 1-19 each | *23 more warning files, 149 findings* | | -By rule, warnings: 399 split paragraphs, 220 over-wide comment lines, 114 multi-line comment blocks, 14 appendix sections nothing refers to. +By rule, warnings: 399 split paragraphs, 219 over-wide comment lines, 114 multi-line comment blocks, 14 appendix sections nothing refers to. ### light/effects.md @@ -127,7 +127,7 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. | 11 | `src/core/system/MqttPacket.h` | warning | | 1-11 each | *16 more warning files, 78 findings* | | -By rule, warnings: 164 split paragraphs, 37 appendix sections nothing refers to, 13 over-wide comment lines, 9 multi-line comment blocks. +By rule, warnings: 165 split paragraphs, 37 appendix sections nothing refers to, 12 over-wide comment lines, 9 multi-line comment blocks. ### core/supporting.md diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 12c6f178e..40fdb6420 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "745339aa", + "commit": "b9c3f6b3", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, - "esp32p4rev1-eth": 2328752, + "esp32p4rev1-eth": 2328800, "esp32p4rev1-eth-wifi": 2358896, "esp32s3-n8r8": 2336304, "esp32s31": 2595200, @@ -54,6 +54,12 @@ "p95": 2744, "n": 4, "last": "2026-10-04" + }, + "desktop-linux": { + "p50": 18, + "p95": 18, + "n": 1, + "last": "2026-10-11" } }, "Firmware_reports_what_is_running": { @@ -120,6 +126,12 @@ "p95": 2800, "n": 3, "last": "2026-10-04" + }, + "desktop-linux": { + "p50": 6, + "p95": 6, + "n": 1, + "last": "2026-10-11" } }, "Aurora_fps": { @@ -508,6 +520,12 @@ "p95": 218, "n": 1, "last": "2026-09-30" + }, + "desktop-linux": { + "p50": 6, + "p95": 6, + "n": 1, + "last": "2026-10-11" } }, "Effects_pipeline_builds_and_renders": { @@ -517,6 +535,12 @@ "n": 32, "last": "2026-10-10" }, + "desktop-linux": { + "p50": 7, + "p95": 7, + "n": 1, + "last": "2026-10-11" + }, "desktop-windows": { "p50": 4, "p95": 6, @@ -548,6 +572,12 @@ "p95": 287, "n": 1, "last": "2026-09-30" + }, + "desktop-linux": { + "p50": 4, + "p95": 4, + "n": 1, + "last": "2026-10-11" } }, "Effects_teardown_under_a_running_pipeline": { @@ -574,6 +604,12 @@ "p95": 482, "n": 1, "last": "2026-09-30" + }, + "desktop-linux": { + "p50": 12, + "p95": 12, + "n": 1, + "last": "2026-10-11" } }, "Layers_stack_and_blend_live": { @@ -582,6 +618,12 @@ "p95": 355, "n": 32, "last": "2026-10-10" + }, + "desktop-linux": { + "p50": 623, + "p95": 623, + "n": 1, + "last": "2026-10-11" } }, "Layouts_resize_reallocates_live": { @@ -608,6 +650,12 @@ "p95": 579, "n": 1, "last": "2026-09-30" + }, + "desktop-linux": { + "p50": 19, + "p95": 19, + "n": 1, + "last": "2026-10-11" } }, "Modifiers_reshape_the_mapping": { @@ -634,6 +682,12 @@ "p95": 273, "n": 1, "last": "2026-09-30" + }, + "desktop-linux": { + "p50": 10, + "p95": 10, + "n": 1, + "last": "2026-10-11" } }, "Palettes_a_script_drives_the_effects": { @@ -642,6 +696,12 @@ "p95": 74, "n": 32, "last": "2026-10-10" + }, + "desktop-linux": { + "p50": 8, + "p95": 8, + "n": 1, + "last": "2026-10-11" } } }, @@ -682,14 +742,28 @@ } }, "desktop-linux": { - "tick_us": 7, - "fps": 142857 + "tick_us": 9, + "fps": 111111, + "scenario_p50": { + "Effects_pipeline_builds_and_renders": { + "p50": 7, + "p95": 7, + "n": 1, + "last": "2026-10-11" + }, + "Layouts_resize_reallocates_live": { + "p50": 19, + "p95": 19, + "n": 1, + "last": "2026-10-11" + } + } } }, "loc": { "core": 28663, "light": 31027, - "platform": 18760, + "platform": 18758, "ui": 12350, "test": 65084, "moondeck": 30600 @@ -704,7 +778,7 @@ "ratio": 0.269 }, "platform": { - "lines": 3776, + "lines": 3777, "ratio": 0.225 }, "ui": { diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 6d048cada..d080b6adb 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `745339aa`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `b9c3f6b3`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -18,7 +18,7 @@ Current state only; the trend is this file's git history (`git log -p docs/refer | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | | lines core | 22,299 | 28,663 | +6,364 (+28.5%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | -| lines platform | 16,798 | 18,760 | +1,962 (+11.7%) | +| lines platform | 16,798 | 18,758 | +1,960 (+11.7%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | | lines test | 57,759 | 65,084 | +7,325 (+12.7%) | | lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | @@ -31,7 +31,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop-linux | 2,488 KB (+128 B) โš  | - | - | yes | +| desktop-linux | 2,488 KB | - | - | yes | | desktop-macos | 2,217 KB | - | - | carried 1d | | desktop-windows | 1,880 KB | - | - | **STALE 11d** | | esp32 | 2,174 KB | 2,496 KB | 87% | carried 2d | @@ -40,7 +40,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 32d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | | esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 1d | -| esp32p4rev1-eth | 2,274 KB (+336 B) โš  | 4,096 KB | 56% | yes | +| esp32p4rev1-eth | 2,274 KB (+48 B) โš  | 4,096 KB | 56% | yes | | esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 11d** | | esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 3d | | esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 1d | @@ -55,38 +55,47 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 7 ยตs (โˆ’1 ยตs) โœ“ | 142,857 (+17,857) โœ“ | +| desktop-linux | 9 ยตs (+2 ยตs) โš  | 111,111 (โˆ’31,746) โš  | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | | esp32 | 8,354 ยตs | 119 | ### Scenario tick by target (p50 of each sample window) -| Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | desktop-docker-arm64 | esp32p4rev1-eth-wifi | -|---|---|---|---|---|---|---|---|---|---|---| -| Aurora_fps | 1,549 | 6,015 ? | 217,295 ? | 188,782 ? | 83,632 ? | 87,613 ? | - | - | 7,532 ? | 1,942,284 ? | -| Control_a_palette_preset_changes_only_the_palette | 7 | - | - | 2,073 | - | - | - | - | - | - | -| Drivers_output_and_brightness | 7 | 4 ? | - | 8,690 ? | - | - | - | - | 218 ? | - | -| Effects_pipeline_builds_and_renders | 7 | 4 ? | - | - | - | - | - | - | - | - | -| Effects_swap_while_running | 1 | 4 ? | - | 10,000 ? | - | - | - | - | 287 ? | - | -| Effects_teardown_under_a_running_pipeline | 7 | 17 ? | - | 9,080 | - | - | - | - | 482 ? | - | -| Firmware_reports_what_is_running | 29 | 1,827 ? | - | 8,659 ? | - | - | - | - | 215 ? | - | -| Fluid_solver | 218 | 685,902 ? | 45,974 ? | 102,058 ? | 71,150 ? | 66,874 ? | - | - | 3,371 ? | 761,768 ? | -| GridLayout_resize | 120 | 219 ? | 1,352 ? | 18,414 ? | 7,218 ? | 8,919 ? | 95,771 ? | 82,231 ? | 2,005 ? | 6,735,719 ? | -| Layers_stack_and_blend_live | 267 | - | - | - | - | - | - | - | - | - | -| Layouts_resize_reallocates_live | 10 | 93 ? | - | 32,399 ? | - | - | - | - | 579 ? | - | -| Modifiers_reshape_the_mapping | 7 | 29 ? | - | 10,350 ? | - | - | - | - | 273 ? | - | -| Network_hardware_reconfigures_live | 21 ? | 2,834 ? | - | 8,692 ? | - | - | - | - | 237 ? | - | -| Palettes_a_script_drives_the_effects | 7 | - | - | - | - | - | - | - | - | - | -| State_one_document_adds_an_effect_with_its_controls | 4 | - | - | 2,505 ? | - | - | - | - | - | - | -| perf_full | 256 | 592 ? | 5,313 | 25,340 ? | 11,711 ? | 13,373 ? | - | - | 2,438 ? | 5,469,660 ? | -| perf_light | 16 | 35 ? | 2,510 | 3,044 | 1,117 ? | 1,378 ? | - | - | 780 ? | 5,385,763 ? | -| peripheral_grid_sweep | 257 | 649 ? | 5,766 | 70,668 ? | 11,495 ? | 12,273 ? | - | - | 2,577 ? | 1,673,544 ? | -| peripheral_switch | 4 | 9 ? | 425 | 2,307 | 2,570 ? | 2,990 ? | - | - | 341 ? | 773,191 ? | +| Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | desktop-docker-arm64 | desktop-linux | esp32p4rev1-eth-wifi | +|---|---|---|---|---|---|---|---|---|---|---|---| +| Aurora_fps | 1,549 | 6,015 ? | 217,295 ? | 188,782 ? | 83,632 ? | 87,613 ? | - | - | 7,532 ? | - | 1,942,284 ? | +| Control_a_palette_preset_changes_only_the_palette | 7 | - | - | 2,073 | - | - | - | - | - | 18 ? | - | +| Drivers_output_and_brightness | 7 | 4 ? | - | 8,690 ? | - | - | - | - | 218 ? | 6 ? | - | +| Effects_pipeline_builds_and_renders | 7 | 4 ? | - | - | - | - | - | - | - | 7 ? | - | +| Effects_swap_while_running | 1 | 4 ? | - | 10,000 ? | - | - | - | - | 287 ? | 4 ? | - | +| Effects_teardown_under_a_running_pipeline | 7 | 17 ? | - | 9,080 | - | - | - | - | 482 ? | 12 ? | - | +| Firmware_reports_what_is_running | 29 | 1,827 ? | - | 8,659 ? | - | - | - | - | 215 ? | - | - | +| Fluid_solver | 218 | 685,902 ? | 45,974 ? | 102,058 ? | 71,150 ? | 66,874 ? | - | - | 3,371 ? | - | 761,768 ? | +| GridLayout_resize | 120 | 219 ? | 1,352 ? | 18,414 ? | 7,218 ? | 8,919 ? | 95,771 ? | 82,231 ? | 2,005 ? | - | 6,735,719 ? | +| Layers_stack_and_blend_live | 267 | - | - | - | - | - | - | - | - | 623 ? | - | +| Layouts_resize_reallocates_live | 10 | 93 ? | - | 32,399 ? | - | - | - | - | 579 ? | 19 ? | - | +| Modifiers_reshape_the_mapping | 7 | 29 ? | - | 10,350 ? | - | - | - | - | 273 ? | 10 ? | - | +| Network_hardware_reconfigures_live | 21 ? | 2,834 ? | - | 8,692 ? | - | - | - | - | 237 ? | - | - | +| Palettes_a_script_drives_the_effects | 7 | - | - | - | - | - | - | - | - | 8 ? | - | +| State_one_document_adds_an_effect_with_its_controls | 4 | - | - | 2,505 ? | - | - | - | - | - | 6 ? | - | +| perf_full | 256 | 592 ? | 5,313 | 25,340 ? | 11,711 ? | 13,373 ? | - | - | 2,438 ? | - | 5,469,660 ? | +| perf_light | 16 | 35 ? | 2,510 | 3,044 | 1,117 ? | 1,378 ? | - | - | 780 ? | - | 5,385,763 ? | +| peripheral_grid_sweep | 257 | 649 ? | 5,766 | 70,668 ? | 11,495 ? | 12,273 ? | - | - | 2,577 ? | - | 1,673,544 ? | +| peripheral_switch | 4 | 9 ? | 425 | 2,307 | 2,570 ? | 2,990 ? | - | - | 341 ? | - | 773,191 ? | Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first impression rather than a percentile; several are months old and were captured during a network reconfigure, so they read as whole milliseconds. `-` means that target has never run that scenario. -**Coverage: 94/190 cells measured (49%), 26 of them with 4+ samples (13%).** The blanks are the point: a regression on a target that has never run a scenario cannot be DETECTED in it, and the target cannot be compared against the others. Filling the matrix means running the scenario suite on each bench board, which is a standing task rather than a one-off. +**Coverage: 104/209 cells measured (49%), 26 of them with 4+ samples (12%).** The blanks are the point: a regression on a target that has never run a scenario cannot be DETECTED in it, and the target cannot be compared against the others. Filling the matrix means running the scenario suite on each bench board, which is a standing task rather than a one-off. + +### desktop-linux: isolated scenarios (p50 of the sample window) + +| Scenario | p50 | p95 | n | +|---|---:|---:|---:| +| Effects_pipeline_builds_and_renders | 7 ยตs | 7 ยตs | 1 | +| Layouts_resize_reallocates_live | 19 ยตs | 19 ยตs | 1 | + +These build a bare pipeline with no optional modules, so a change here is a change in the pipeline itself rather than in what was measured. A new module belongs in an advanced scenario, which keeps its own numbers. ### desktop-macos: isolated scenarios (p50 of the sample window) @@ -110,26 +119,26 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,663 (+191) โš  | 6,241 | 24.2 % (+0.1 %) โš  | +| core | 28,663 | 6,241 | 24.2 % | | light | 31,027 | 7,439 | 26.9 % | -| platform | 18,760 (+533) โš  | 3,776 | 22.5 % (โˆ’0.2 %) โœ“ | +| platform | 18,758 (โˆ’2) โœ“ | 3,777 | 22.5 % | | ui | 12,350 | 3,472 | 29.8 % | -| test | 65,084 (+487) โš  | 6,997 | 12.4 % (โˆ’0.1 %) โœ“ | +| test | 65,084 | 6,997 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,417 (+19) โœ“ | +| unit cases | 2,417 | | scenarios | 30 | ## Complexity | Metric | Value | |---|---:| -| functions | 4,475 (+5) โœ“ | -| over threshold | 338 (+1) โš  | +| functions | 4,475 | +| over threshold | 338 | | worst CCN | 128 | ## Documentation @@ -137,7 +146,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 150 | -| markdown lines | 32,283 (+2) โš  | +| markdown lines | 32,283 | | plan files | 44 | | backlog lines | 3,319 | | lessons lines | 532 | diff --git a/src/core/system/HttpServerModule.cpp b/src/core/system/HttpServerModule.cpp index 31061d5f6..6f2e97fec 100644 --- a/src/core/system/HttpServerModule.cpp +++ b/src/core/system/HttpServerModule.cpp @@ -197,7 +197,7 @@ void HttpServerModule::finishSdTransfer(SdTransfer& t, bool closeConn) { } // An upload slot's bounded chunk: socket to card, then the stall guard. -// Reads into t.buf, the struct's own scratch buffer: a stack-local here ran every 20ms for the whole transfer, and on a multi-minute upload that stacked deeply enough to panic the board. +// Reads into t.buf, not a stack-local: that put a 4 KB frame on this task's stack every tick, until one coincided with other deep stack use and panicked the board. void HttpServerModule::driveSdUpload(SdTransfer& t, uint32_t now) { if (t.remaining > 0) { const size_t want = t.remaining < sizeof(t.buf) ? t.remaining : sizeof(t.buf); diff --git a/src/platform/esp32/platform_esp32_sdmmc.cpp b/src/platform/esp32/platform_esp32_sdmmc.cpp index e89a456c1..d114c2f94 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.cpp +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -73,6 +73,8 @@ static sdmmc_card_t* g_card = nullptr; static bool g_mounted = false; static int g_streamsOpen = 0; // every open read/write stream, counted so unmount/format can refuse under one static int8_t g_pwrGpio = -1; // so unmount powers back down what mount powered up +// Every call below runs to completion before the next starts, so one shared FIL off the stack serves them all; the kept-open streams further down heap-allocate their own. +static FIL g_fil; #if SOC_SDMMC_IO_POWER_EXTERNAL static sd_pwr_ctrl_handle_t g_pwrCtrl = nullptr; #endif @@ -241,11 +243,10 @@ int sdCardRead(const char* apiPath, char* buf, size_t maxLen) { if (!g_mounted || !buf || maxLen == 0) return -1; char full[128]; if (!sdTranslate(apiPath, full, sizeof(full))) return -1; - FIL f; - if (f_open(&f, full, FA_READ) != FR_OK) return -1; + if (f_open(&g_fil, full, FA_READ) != FR_OK) return -1; UINT br = 0; - const FRESULT r = f_read(&f, buf, static_cast(maxLen - 1), &br); - f_close(&f); + const FRESULT r = f_read(&g_fil, buf, static_cast(maxLen - 1), &br); + f_close(&g_fil); if (r != FR_OK) return -1; buf[br] = 0; return static_cast(br); @@ -264,12 +265,11 @@ int sdCardReadAt(const char* apiPath, long offset, char* buf, size_t len) { if (!g_mounted || !buf) return -1; char full[128]; if (!sdTranslate(apiPath, full, sizeof(full))) return -1; - FIL f; - if (f_open(&f, full, FA_READ) != FR_OK) return -1; - if (f_lseek(&f, static_cast(offset)) != FR_OK) { f_close(&f); return -1; } + if (f_open(&g_fil, full, FA_READ) != FR_OK) return -1; + if (f_lseek(&g_fil, static_cast(offset)) != FR_OK) { f_close(&g_fil); return -1; } UINT br = 0; - const FRESULT r = f_read(&f, buf, static_cast(len), &br); - f_close(&f); + const FRESULT r = f_read(&g_fil, buf, static_cast(len), &br); + f_close(&g_fil); if (r != FR_OK) return -1; return static_cast(br); } @@ -287,7 +287,7 @@ static void sdMakeParent(const char* apiPath) { sdCardMkdir(dir); } -// f_rename refuses when the destination already exists (no FatFs equivalent of POSIX's atomic replace), so the old file is unlinked first; the brief gap between that and the rename is the cost of FAT having no better primitive. +// f_rename refuses an existing destination, unlike POSIX rename; the old file is unlinked first, with the brief gap as FAT's cost for having no atomic replace. static bool sdCommitTmp(const char* tmp, const char* full) { f_unlink(full); if (f_rename(tmp, full) == FR_OK) return true; @@ -305,11 +305,10 @@ bool sdCardWriteAtomic(const char* apiPath, const char* data, size_t len) { int n = std::snprintf(tmp, sizeof(tmp), "%s.tmp", full); if (n < 0 || static_cast(n) >= sizeof(tmp)) return false; - FIL f; - if (f_open(&f, tmp, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) return false; + if (f_open(&g_fil, tmp, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) return false; UINT bw = 0; - const FRESULT wr = f_write(&f, data, static_cast(len), &bw); - f_close(&f); + const FRESULT wr = f_write(&g_fil, data, static_cast(len), &bw); + f_close(&g_fil); if (wr != FR_OK || bw != len) { f_unlink(tmp); return false; } return sdCommitTmp(tmp, full); } @@ -323,8 +322,7 @@ bool sdCardWriteStreamCb(const char* apiPath, FsWriteSrc src, void* user) { int n = std::snprintf(tmp, sizeof(tmp), "%s.tmp", full); if (n < 0 || static_cast(n) >= sizeof(tmp)) return false; - FIL f; - if (f_open(&f, tmp, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) return false; + if (f_open(&g_fil, tmp, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) return false; char chunk[1024]; bool ok = true, abort = false; for (;;) { @@ -332,9 +330,9 @@ bool sdCardWriteStreamCb(const char* apiPath, FsWriteSrc src, void* user) { if (abort) { ok = false; break; } if (got == 0) break; UINT bw = 0; - if (f_write(&f, chunk, static_cast(got), &bw) != FR_OK || bw != got) { ok = false; break; } + if (f_write(&g_fil, chunk, static_cast(got), &bw) != FR_OK || bw != got) { ok = false; break; } } - f_close(&f); + f_close(&g_fil); if (!ok) { f_unlink(tmp); return false; } return sdCommitTmp(tmp, full); } From dc236f190eba4247752f141ac4ddbd30c2d06dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Sun, 11 Oct 2026 19:04:02 +0200 Subject: [PATCH 04/20] Make SdCardModule and its transfer slots cost nothing without SD PR #136 review item 3 (yours): SdCardModule booted on every board regardless of hasSdCard, each one carrying 7 pin controls and a format button for hardware it provably does not have, and HttpServerModule's two-slot SdTransfer array (~8 KB, each slot holding a 4 KB download buffer plus a TCP connection and both stream handles) sat in every board's memory and ran its drive call every 20 ms, whether or not the board could ever have a card. Flash: esp32s3-zero -656 B (2232272 -> 2231616, a board with no SD support at all). Account: src +10/-16 lines | test +3/-3 lines | code findings 1640 -> 1639 (-1, no rule rose) | duplicated lines 3767 -> 3767 (+0) Core: - module_types.cpp: kBootModules' SdCardModule row gates on platform::hasSdCard now, the same way WiFiModule's row already gates on hasWiFi, rather than an unconditional true. A board without the capability never boots the module at all. - HttpServerModule.h/.cpp: kSdTransferSlots is platform::hasSdCard ? 2 : 0, and sdTransfers_ is std::array - zero elements, not two, on a board without SD. std::array is standard C++, not an extension: it constructs no SdTransfer at all, and claimSdTransfer/driveSdTransfers's loops over it already run zero times with no guard needed. The one call site that still runs unconditionally (tick20ms's call to driveSdTransfers) is wrapped in if constexpr(platform::hasSdCard) too, so the call itself compiles out rather than relying on the empty loop to make it a no-op. - SdCardModule.cpp/.h: dropped the per-pin setHidden(..., !hasSdCard) calls and the "no SD hardware on this build" branch in prepare() - both existed to cover a board without the capability, which now simply never boots this module. "set the SD card pins first" changed from Severity::Warning to Severity::Status: unset pins are this module's idle state, nothing has gone wrong yet. Tests: - unit_SdCardModule.cpp: renamed and refocused the pin-visibility test - it asserted hidden == !hasSdCard, which only passed because desktop's hasSdCard happens to be true; it now asserts the pins are never hidden, which is the actual invariant the removed setHidden calls left behind. Not testable on desktop: platform::hasSdCard is unconditionally true there (by design, for real test coverage of the mount/stream logic), so the kSdTransferSlots == 0 path can only be exercised by compiling for a board that does not set it. Verified instead by building esp32s3-zero (no MM_SD_CARD) clean, and by the flash delta above, which is exactly what dropping the dead SdTransfer machinery from that binary should produce. Verified live on esp32p4rev1-eth over serial (a board WHERE hasSdCard is true, so this change's own behavior there is deliberately almost a no-op): the pins are visible without a card inserted, the idle status reads with severity "status" (confirmed in the raw /api/state JSON), and the real, pinned "SdCard" instance mounted and read its gauge exactly as before. Checks (scoped to this diff's own trigger-table buckets - src/ and test/ only): - check_specs: PASS - 136/136 modules - check_prose: PASS - 0 new findings - check_docgen: PASS - 0 errors, 0 new warnings - check_code: PASS - no rule rose; total findings fell by 1 - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2429/2429 - run_scenario: PASS - 30 scenarios, 17 passed / 13 skipped, 0 failed - check_esp32_built: PASS - fresh after the real rebuild+flash - collect_kpi: PASS - recorded above (the perf.* timing deltas in this run are host noise, not from this change - it is a no-op on desktop's own hasSdCard=true path) - Real hardware (esp32p4rev1-eth, over serial; esp32s3-zero build-only, no board on hand): verified as described above --- docs/reference/metrics/code.md | 10 ++-- docs/reference/metrics/repo-health.json | 64 ++++++++++++------------- docs/reference/metrics/repo-health.md | 44 ++++++++--------- src/core/system/HttpServerModule.cpp | 2 +- src/core/system/HttpServerModule.h | 6 ++- src/core/system/SdCardModule.cpp | 16 ++----- src/module_types.cpp | 2 +- test/unit/core/unit_SdCardModule.cpp | 4 +- 8 files changed, 71 insertions(+), 77 deletions(-) diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index b6b25dce4..3a5fe2c6a 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,14 +4,14 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows. -**1640 finding(s)** across 322 file(s). 2.88% of all lines are duplicated, 3767 lines. +**1639 finding(s)** across 322 file(s). 2.88% of all lines are duplicated, 3767 lines. ## By rule | Rule | Findings | |---|---:| | duplicated block | 778 | -| complex function | 290 | +| complex function | 289 | | blocking call on the render path | 195 | | deeply nested | 113 | | module type in the UI | 71 | @@ -46,7 +46,7 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta |---|---:| | `test` | 576 | | `light` | 421 | -| `core` | 353 | +| `core` | 352 | | `platform` | 186 | | `ui` | 74 | | `moondeck` | 24 | @@ -80,7 +80,7 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/core/unit_HttpServerModule_apply.cpp` | 19 | `lines 536-547, also in test/unit/core/unit_HttpServerModule_apply.cpp:122`, duplicated block 12 | | `src/platform/esp32/platform_esp32_moon_i80.cpp` | 18 | `platform::createRingState`, complex function 32 | | `test/unit/core/unit_PinsModule.cpp` | 18 | `lines 46-57, also in test/unit/light/unit_Hub75Driver_pins.cpp:66`, duplicated block 12 | -| `src/core/services/AudioService.h` | 17 | `AudioService in 52 files`, name spread 52 | +| `src/core/services/AudioService.h` | 17 | `AudioService in 51 files`, name spread 51 | | `src/light/effects/PacmanEffect.h` | 16 | `lines 243-257, also in src/light/effects/FishTankEffect.h:244`, duplicated block 15 | | `src/platform/esp32/platform_esp32_ota.cpp` | 15 | `platform::otaWriteMoonBase`, complex function 33 | | `test/unit/light/unit_PaintBrushEffect.cpp` | 15 | `lines 44-61, also in test/unit/light/unit_GEQ3DEffect.cpp:58`, duplicated block 18 | @@ -232,7 +232,6 @@ The next function worth simplifying is at the top of each file's list. | `src/core/system/DevicePlugin.h` | 2 | `lines 94-100, also in src/core/system/DevicePlugin.h:75`, duplicated block 7 | | `src/core/system/HttpServerModule.h` | 2 | `HttpServerModule in 17 files`, name spread 17 | | `src/core/system/MqttPacket.h` | 2 | `buildMqttConnect`, complex function 31 | -| `src/core/system/SdCardModule.cpp` | 2 | `SdCardModule::prepare`, complex function 11 | | `src/core/system/Sha1.h` | 2 | `sha1, line 21`, large stack frame 920 | | `src/core/util/ModuleFactory.h` | 2 | `ModuleFactory::create`, deeply nested 4 | | `src/core/util/PinList.h` | 2 | `parsePinList`, complex function 15 | @@ -307,6 +306,7 @@ The next function worth simplifying is at the top of each file's list. | `src/core/system/FileManagerModule.cpp` | 1 | `platform::filesystemUsed in tick1s`, blocking call on the render path 1 | | `src/core/system/FilesystemModule.h` | 1 | `FilesystemModule in 25 files`, name spread 25 | | `src/core/system/I2cBusModule.h` | 1 | `I2cBusModule::scan`, complex function 18 | +| `src/core/system/SdCardModule.cpp` | 1 | `SdCardModule::refreshGauge in tick1s`, blocking call on the render path 1 | | `src/core/util/Addressing.h` | 1 | `sendAddressed`, complex function 11 | | `src/core/util/CaptivePortal.h` | 1 | `captive::dnsReply`, complex function 21 | | `src/core/util/ImprovFrame.h` | 1 | `ImprovFrameParser::feed`, complex function 22 | diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 40fdb6420..27e868835 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,5 +1,5 @@ { - "commit": "b9c3f6b3", + "commit": "73bc49a6", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, @@ -12,7 +12,7 @@ "esp32-wrover": 1843760, "qemu": 1383648, "esp32p4rev3-eth": 2203712, - "esp32s3-zero": 2232272, + "esp32s3-zero": 2231616, "esp32-pico": 2107168, "desktop-macos": 2270440, "desktop-windows": 1925120, @@ -56,8 +56,8 @@ "last": "2026-10-04" }, "desktop-linux": { - "p50": 18, - "p95": 18, + "p50": 22, + "p95": 22, "n": 1, "last": "2026-10-11" } @@ -128,8 +128,8 @@ "last": "2026-10-04" }, "desktop-linux": { - "p50": 6, - "p95": 6, + "p50": 8, + "p95": 8, "n": 1, "last": "2026-10-11" } @@ -522,8 +522,8 @@ "last": "2026-09-30" }, "desktop-linux": { - "p50": 6, - "p95": 6, + "p50": 8, + "p95": 8, "n": 1, "last": "2026-10-11" } @@ -536,8 +536,8 @@ "last": "2026-10-10" }, "desktop-linux": { - "p50": 7, - "p95": 7, + "p50": 8, + "p95": 8, "n": 1, "last": "2026-10-11" }, @@ -574,8 +574,8 @@ "last": "2026-09-30" }, "desktop-linux": { - "p50": 4, - "p95": 4, + "p50": 5, + "p95": 5, "n": 1, "last": "2026-10-11" } @@ -606,8 +606,8 @@ "last": "2026-09-30" }, "desktop-linux": { - "p50": 12, - "p95": 12, + "p50": 17, + "p95": 17, "n": 1, "last": "2026-10-11" } @@ -620,8 +620,8 @@ "last": "2026-10-10" }, "desktop-linux": { - "p50": 623, - "p95": 623, + "p50": 967, + "p95": 967, "n": 1, "last": "2026-10-11" } @@ -652,8 +652,8 @@ "last": "2026-09-30" }, "desktop-linux": { - "p50": 19, - "p95": 19, + "p50": 25, + "p95": 25, "n": 1, "last": "2026-10-11" } @@ -684,8 +684,8 @@ "last": "2026-09-30" }, "desktop-linux": { - "p50": 10, - "p95": 10, + "p50": 14, + "p95": 14, "n": 1, "last": "2026-10-11" } @@ -698,8 +698,8 @@ "last": "2026-10-10" }, "desktop-linux": { - "p50": 8, - "p95": 8, + "p50": 12, + "p95": 12, "n": 1, "last": "2026-10-11" } @@ -742,18 +742,18 @@ } }, "desktop-linux": { - "tick_us": 9, - "fps": 111111, + "tick_us": 12, + "fps": 83333, "scenario_p50": { "Effects_pipeline_builds_and_renders": { - "p50": 7, - "p95": 7, + "p50": 8, + "p95": 8, "n": 1, "last": "2026-10-11" }, "Layouts_resize_reallocates_live": { - "p50": 19, - "p95": 19, + "p50": 25, + "p95": 25, "n": 1, "last": "2026-10-11" } @@ -761,7 +761,7 @@ } }, "loc": { - "core": 28663, + "core": 28657, "light": 31027, "platform": 18758, "ui": 12350, @@ -770,7 +770,7 @@ }, "comments": { "core": { - "lines": 6241, + "lines": 6243, "ratio": 0.242 }, "light": { @@ -800,7 +800,7 @@ }, "docs": { "md_files": 150, - "md_lines": 32283, + "md_lines": 32292, "plans_files": 44, "backlog_lines": 3319, "lessons_lines": 532, @@ -808,7 +808,7 @@ }, "complexity": { "functions": 4475, - "over_threshold": 338, + "over_threshold": 337, "worst_ccn": 128 } } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index d080b6adb..41b0b8bf1 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `b9c3f6b3`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `73bc49a6`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -14,9 +14,9 @@ Current state only; the trend is this file's git history (`git log -p docs/refer | flash esp32p4rev3-eth | 2,025 KB | 2,152 KB | +126.8 KB (+6.3%) | | flash esp32s3-n16r8 | 2,111 KB | 2,282 KB | +170.1 KB (+8.1%) | | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | -| flash esp32s3-zero | 1,977 KB | 2,180 KB | +203.2 KB (+10.3%) | +| flash esp32s3-zero | 1,977 KB | 2,179 KB | +202.6 KB (+10.2%) | | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | -| lines core | 22,299 | 28,663 | +6,364 (+28.5%) | +| lines core | 22,299 | 28,657 | +6,358 (+28.5%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | | lines platform | 16,798 | 18,758 | +1,960 (+11.7%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | @@ -40,12 +40,12 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 32d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | | esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 1d | -| esp32p4rev1-eth | 2,274 KB (+48 B) โš  | 4,096 KB | 56% | yes | +| esp32p4rev1-eth | 2,274 KB | 4,096 KB | 56% | yes | | esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 11d** | | esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 3d | | esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 1d | | esp32s3-n8r8 | 2,282 KB | 3,072 KB | 74% | carried 1d | -| esp32s3-zero | 2,180 KB | 2,496 KB | 87% | carried 0d | +| esp32s3-zero | 2,179 KB (โˆ’656 B) โœ“ | 2,496 KB | 87% | yes | | esp32s31 | 2,534 KB | 4,096 KB | 62% | carried 1d | | qemu | 1,351 KB | - | - | carried (age?) | @@ -55,7 +55,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 9 ยตs (+2 ยตs) โš  | 111,111 (โˆ’31,746) โš  | +| desktop-linux | 12 ยตs (+3 ยตs) โš  | 83,333 (โˆ’27,778) โš  | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | | esp32 | 8,354 ยตs | 119 | @@ -65,20 +65,20 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | desktop-docker-arm64 | desktop-linux | esp32p4rev1-eth-wifi | |---|---|---|---|---|---|---|---|---|---|---|---| | Aurora_fps | 1,549 | 6,015 ? | 217,295 ? | 188,782 ? | 83,632 ? | 87,613 ? | - | - | 7,532 ? | - | 1,942,284 ? | -| Control_a_palette_preset_changes_only_the_palette | 7 | - | - | 2,073 | - | - | - | - | - | 18 ? | - | -| Drivers_output_and_brightness | 7 | 4 ? | - | 8,690 ? | - | - | - | - | 218 ? | 6 ? | - | -| Effects_pipeline_builds_and_renders | 7 | 4 ? | - | - | - | - | - | - | - | 7 ? | - | -| Effects_swap_while_running | 1 | 4 ? | - | 10,000 ? | - | - | - | - | 287 ? | 4 ? | - | -| Effects_teardown_under_a_running_pipeline | 7 | 17 ? | - | 9,080 | - | - | - | - | 482 ? | 12 ? | - | +| Control_a_palette_preset_changes_only_the_palette | 7 | - | - | 2,073 | - | - | - | - | - | 22 (+4) โš  ? | - | +| Drivers_output_and_brightness | 7 | 4 ? | - | 8,690 ? | - | - | - | - | 218 ? | 8 (+2) โš  ? | - | +| Effects_pipeline_builds_and_renders | 7 | 4 ? | - | - | - | - | - | - | - | 8 (+1) โš  ? | - | +| Effects_swap_while_running | 1 | 4 ? | - | 10,000 ? | - | - | - | - | 287 ? | 5 (+1) โš  ? | - | +| Effects_teardown_under_a_running_pipeline | 7 | 17 ? | - | 9,080 | - | - | - | - | 482 ? | 17 (+5) โš  ? | - | | Firmware_reports_what_is_running | 29 | 1,827 ? | - | 8,659 ? | - | - | - | - | 215 ? | - | - | | Fluid_solver | 218 | 685,902 ? | 45,974 ? | 102,058 ? | 71,150 ? | 66,874 ? | - | - | 3,371 ? | - | 761,768 ? | | GridLayout_resize | 120 | 219 ? | 1,352 ? | 18,414 ? | 7,218 ? | 8,919 ? | 95,771 ? | 82,231 ? | 2,005 ? | - | 6,735,719 ? | -| Layers_stack_and_blend_live | 267 | - | - | - | - | - | - | - | - | 623 ? | - | -| Layouts_resize_reallocates_live | 10 | 93 ? | - | 32,399 ? | - | - | - | - | 579 ? | 19 ? | - | -| Modifiers_reshape_the_mapping | 7 | 29 ? | - | 10,350 ? | - | - | - | - | 273 ? | 10 ? | - | +| Layers_stack_and_blend_live | 267 | - | - | - | - | - | - | - | - | 967 (+344) โš  ? | - | +| Layouts_resize_reallocates_live | 10 | 93 ? | - | 32,399 ? | - | - | - | - | 579 ? | 25 (+6) โš  ? | - | +| Modifiers_reshape_the_mapping | 7 | 29 ? | - | 10,350 ? | - | - | - | - | 273 ? | 14 (+4) โš  ? | - | | Network_hardware_reconfigures_live | 21 ? | 2,834 ? | - | 8,692 ? | - | - | - | - | 237 ? | - | - | -| Palettes_a_script_drives_the_effects | 7 | - | - | - | - | - | - | - | - | 8 ? | - | -| State_one_document_adds_an_effect_with_its_controls | 4 | - | - | 2,505 ? | - | - | - | - | - | 6 ? | - | +| Palettes_a_script_drives_the_effects | 7 | - | - | - | - | - | - | - | - | 12 (+4) โš  ? | - | +| State_one_document_adds_an_effect_with_its_controls | 4 | - | - | 2,505 ? | - | - | - | - | - | 8 (+2) โš  ? | - | | perf_full | 256 | 592 ? | 5,313 | 25,340 ? | 11,711 ? | 13,373 ? | - | - | 2,438 ? | - | 5,469,660 ? | | perf_light | 16 | 35 ? | 2,510 | 3,044 | 1,117 ? | 1,378 ? | - | - | 780 ? | - | 5,385,763 ? | | peripheral_grid_sweep | 257 | 649 ? | 5,766 | 70,668 ? | 11,495 ? | 12,273 ? | - | - | 2,577 ? | - | 1,673,544 ? | @@ -92,8 +92,8 @@ Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first | Scenario | p50 | p95 | n | |---|---:|---:|---:| -| Effects_pipeline_builds_and_renders | 7 ยตs | 7 ยตs | 1 | -| Layouts_resize_reallocates_live | 19 ยตs | 19 ยตs | 1 | +| Effects_pipeline_builds_and_renders | 8 ยตs (+1 ยตs) โš  | 8 ยตs | 1 | +| Layouts_resize_reallocates_live | 25 ยตs (+6 ยตs) โš  | 25 ยตs | 1 | These build a bare pipeline with no optional modules, so a change here is a change in the pipeline itself rather than in what was measured. A new module belongs in an advanced scenario, which keeps its own numbers. @@ -119,9 +119,9 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,663 | 6,241 | 24.2 % | +| core | 28,657 (โˆ’6) โœ“ | 6,243 | 24.2 % | | light | 31,027 | 7,439 | 26.9 % | -| platform | 18,758 (โˆ’2) โœ“ | 3,777 | 22.5 % | +| platform | 18,758 | 3,777 | 22.5 % | | ui | 12,350 | 3,472 | 29.8 % | | test | 65,084 | 6,997 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | @@ -138,7 +138,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | functions | 4,475 | -| over threshold | 338 | +| over threshold | 337 (โˆ’1) โœ“ | | worst CCN | 128 | ## Documentation @@ -146,7 +146,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 150 | -| markdown lines | 32,283 | +| markdown lines | 32,292 (+9) โš  | | plan files | 44 | | backlog lines | 3,319 | | lessons lines | 532 | diff --git a/src/core/system/HttpServerModule.cpp b/src/core/system/HttpServerModule.cpp index 6f2e97fec..6d2256e91 100644 --- a/src/core/system/HttpServerModule.cpp +++ b/src/core/system/HttpServerModule.cpp @@ -144,7 +144,7 @@ void HttpServerModule::tick20ms() MM_NONBLOCKING { // Inbound WS frames: the WLED app sets on/off and brightness with an {on,bri} text frame over /ws. pollWledStateFromWebSockets(); serveConnections(); - driveSdTransfers(); + if constexpr (platform::hasSdCard) driveSdTransfers(); // sdTransfers_ has zero slots otherwise, so there is nothing to drive } // One entry from the tick, so the connection work, which blocks by nature, is one call on the render path. diff --git a/src/core/system/HttpServerModule.h b/src/core/system/HttpServerModule.h index 45d9cafda..c3b1b7fe0 100644 --- a/src/core/system/HttpServerModule.h +++ b/src/core/system/HttpServerModule.h @@ -6,6 +6,7 @@ #include "core/util/BinaryBroadcaster.h" #include "platform/platform.h" +#include #include namespace mm { @@ -395,9 +396,10 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { size_t bufSent = 0; uint32_t lastActivityMs = 0; // a stall guard, not a size or time cap on the transfer itself }; - static constexpr int kSdTransferSlots = 2; + // Zero slots, not two, on a board without SD: std::array is standard, constructs no SdTransfer at all, and every loop over it already runs zero times. + static constexpr int kSdTransferSlots = platform::hasSdCard ? 2 : 0; static constexpr uint32_t kSdTransferStallMs = 10000; // no progress this long: abort, free the slot - SdTransfer sdTransfers_[kSdTransferSlots]; + std::array sdTransfers_{}; /// A free slot for a new transfer, or null when every slot is busy. SdTransfer* claimSdTransfer(); /// Advance every active slot by one bounded chunk; called once per tick, the drainXxxSend's sibling. diff --git a/src/core/system/SdCardModule.cpp b/src/core/system/SdCardModule.cpp index f5028ca4f..710f363ca 100644 --- a/src/core/system/SdCardModule.cpp +++ b/src/core/system/SdCardModule.cpp @@ -11,21 +11,14 @@ namespace mm { void SdCardModule::defineControls() { - // Hidden on a chip with no SDMMC hardware, the same gate AudioService uses for its own mic pins; hidden stays bound, so a value survives a board change. + // Unhidden: a board without hasSdCard simply never boots this module (module_types.cpp's kBootModules gates on it), so there is no "wrong board" case left for a hidden pin to guard against. controls_.addPin("clkPin", clkPin_); - controls_.setHidden(controls_.count() - 1, !platform::hasSdCard); controls_.addPin("cmdPin", cmdPin_); - controls_.setHidden(controls_.count() - 1, !platform::hasSdCard); controls_.addPin("d0Pin", d0Pin_); - controls_.setHidden(controls_.count() - 1, !platform::hasSdCard); controls_.addPin("d1Pin", d1Pin_); - controls_.setHidden(controls_.count() - 1, !platform::hasSdCard); controls_.addPin("d2Pin", d2Pin_); - controls_.setHidden(controls_.count() - 1, !platform::hasSdCard); controls_.addPin("d3Pin", d3Pin_); - controls_.setHidden(controls_.count() - 1, !platform::hasSdCard); controls_.addPin("pwrPin", pwrPin_); - controls_.setHidden(controls_.count() - 1, !platform::hasSdCard); // Neither is hidden: there is no panel of this module's own, so the generic card is the only place either shows. Press-twice confirmed generically, the same contract every other confirm-gated button already has. controls_.addButton("format", /*confirm=*/true); @@ -66,10 +59,9 @@ void SdCardModule::prepare() { mounted_ = false; } - if constexpr (!platform::hasSdCard) { - setStatus("no SD hardware on this build", Severity::Status); - } else if (clkPin_ < 0 || cmdPin_ < 0 || d0Pin_ < 0) { - setStatus("set the SD card pins first", Severity::Warning); + if (clkPin_ < 0 || cmdPin_ < 0 || d0Pin_ < 0) { + // Unset is this module's idle state (nothing is wrong yet, nothing was attempted), not a warning. + setStatus("set the SD card pins first", Severity::Status); } else { mounted_ = platform::sdMount(pins); if (mounted_) { mountedPins_ = pins; refreshGauge(); clearStatus(); } diff --git a/src/module_types.cpp b/src/module_types.cpp index 79419b07d..5f74c2fa8 100644 --- a/src/module_types.cpp +++ b/src/module_types.cpp @@ -402,7 +402,7 @@ constexpr BootModule kBootModules[] = { {"SystemModule", "I2cBusModule", nullptr, true}, {"SystemModule", "PinsModule", nullptr, true}, {"Services", "AudioService", nullptr, true}, - {"Services", "SdCardModule", nullptr, true}, + {"Services", "SdCardModule", nullptr, mm::platform::hasSdCard}, {"NetworkModule", "EthernetModule", nullptr, kEthernetCard}, {"NetworkModule", "WiFiModule", nullptr, mm::platform::hasWiFi}, {"NetworkModule", "AccessPointModule", nullptr, mm::platform::hasWiFi}, diff --git a/test/unit/core/unit_SdCardModule.cpp b/test/unit/core/unit_SdCardModule.cpp index 381b84be0..6a4d340af 100644 --- a/test/unit/core/unit_SdCardModule.cpp +++ b/test/unit/core/unit_SdCardModule.cpp @@ -66,13 +66,13 @@ void mountRig(SdCardModule& sd) { } // namespace -TEST_CASE("the SD pins are visible, since desktop always reports hasSdCard") { +TEST_CASE("the SD pins are never hidden: a board without hasSdCard simply never boots this module") { SdCardModule sd; sd.defineControls(); for (const char* name : {"clkPin", "cmdPin", "d0Pin", "d1Pin", "d2Pin", "d3Pin", "pwrPin"}) { const auto* c = find(sd, name); REQUIRE(c != nullptr); - CHECK(c->hidden == !platform::hasSdCard); + CHECK_FALSE(c->hidden); } } From e863bd7126d02f00cfcbe6a7b49b4c02bcb65684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Sun, 11 Oct 2026 20:59:41 +0200 Subject: [PATCH 05/20] Drop every SD symbol from a binary that cannot use the card Follow-up to item 3: the previous commit made the SdTransfer slots and the boot default cost nothing without hasSdCard, but SdCardModule's own class (~3.3 KB: defineControls, prepare, the vtable) still registered on every board, since registerType stays unconditional for every other hasXxx-gated type in this codebase (WiFiModule, EthernetModule) to keep them addable via the generic module picker even where unwired. SD is a stronger case than either of those: hasSdCard requires a deliberate MM_SD_CARD opt-in specifically because FatFs costs real flash, so a board without it can never gain the capability short of a rebuild - there is nothing a user could configure to make an added SdCardModule do anything. Registering it anyway offered a button that provably cannot work. Flash: esp32s3-zero -6256 B (2231616 -> 2225360, cumulative with item 3: -6912 B against the original un-gated state). esp32p4rev1-eth +16 B (a board where hasSdCard is true, so this change is close to a no-op there). Account: src +68/-54 lines | test +1/-1 lines | code findings 1639 -> 1640 (complex function +1, explained below) | duplicated lines 3767 -> 3767 (+0) Core: - module_types.cpp: registerType itself is now if constexpr(platform::hasSdCard)-gated, not just its kBootModules boot-default row. A board without the capability no longer offers the type in the generic "add module" picker at all, and the linker drops the class entirely. - platform_esp32_fs.cpp: every fsXxx function's /sd dispatch (fsMkdir/fsExists/fsRemove/fsRead/fsWriteAtomic/fsReadAt/fsSize/fsWriteStream/fsList and the four stream functions) is now if constexpr(hasSdCard)-gated too, restructured from if/else to an early return inside the gated block falling through to the flash path. Without this, these always-reachable dispatchers (any path starting with "/sd" hits them regardless of hasSdCard) kept 8 small sdCardXxx stub symbols linked in even after SdCardModule itself was gone. - HttpServerModule.cpp: handleWriteFileSd's free-space pre-check (platform::sdCardTotal/sdCardUsed) is now if constexpr(hasSdCard)-gated for the same reason - it is reachable via the same always-on /sd path check, and was the last thing still calling those two symbols on a board that could never have a card. claimSdTransfer below it already answers 503 with zero slots, so dropping the check costs nothing behaviorally. Verified by checking the actual linked ELF, not just the compiled object files (ESP-IDF's build system compiles the sdmmc/esp_driver_sdmmc/FatFs-SD component objects for any chip with SDMMC host hardware, Kconfig flag or not - the question is only whether the linker keeps them). `nm` against esp32s3-zero's MoonLight.elf before this change still showed sdCardUsed/sdCardTotal; after it, a search for "sdCard" or "SdCardModule" across the whole symbol table returns nothing, and neither does a broader sweep for sdmmc_/esp_vfs_fat_sd/ff_diskio.*sd/sd_host_/sd_trans_ - none of FatFs's or the SDMMC driver's own code is linked in either. complex function +1: registerModuleTypes (already a 150-line flat list of registerType calls, already flagged as a long function) crossed from CCN 10 to 11 because of the one added if constexpr. Per coding-standards.md's own escape valve, splitting a single conditional out of a flat registration list would not simplify it; accepted as-is. Verified live on esp32p4rev1-eth over serial (hasSdCard is true there, so this change is nearly a no-op on that board by design): the real "SdCard" instance still mounts, lists its existing files unchanged, and a fresh upload/download/delete round-trips correctly. Checks (scoped to this diff's own trigger-table buckets - src/ only): - check_specs: PASS - 136/136 modules - check_prose: PASS - 0 new findings (fixed two pre-existing ones noticed in SdCardModule.cpp while re-checking: a 31-word sentence and a weasel word, both from item 3's "Unhidden" comment, which this touches again) - check_docgen: PASS - 0 errors, total warnings fell (4317 -> 4316) - check_code: FAIL - the one explained complex-function delta above; nothing else rose - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2429/2429 - run_scenario: PASS - 30 scenarios, 17 passed / 13 skipped, 0 failed - check_esp32_built: PASS - fresh after the real rebuild+flash - collect_kpi: PASS - recorded above (the perf.* swings in this run are host noise; this change does not touch the tick path on desktop, where hasSdCard is unconditionally true) - Real hardware (esp32p4rev1-eth over serial; esp32s3-zero build+ELF-inspection only, no board on hand): verified as described above --- docs/reference/metrics/code.md | 8 +- docs/reference/metrics/docgen.md | 12 +-- docs/reference/metrics/repo-health.json | 88 ++-------------------- docs/reference/metrics/repo-health.md | 75 ++++++++----------- src/core/system/HttpServerModule.cpp | 22 +++--- src/core/system/SdCardModule.cpp | 2 +- src/module_types.cpp | 3 +- src/platform/esp32/platform_esp32_fs.cpp | 95 +++++++++++++----------- 8 files changed, 118 insertions(+), 187 deletions(-) diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index 3a5fe2c6a..d1924ee58 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,14 +4,14 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows. -**1639 finding(s)** across 322 file(s). 2.88% of all lines are duplicated, 3767 lines. +**1640 finding(s)** across 322 file(s). 2.88% of all lines are duplicated, 3767 lines. ## By rule | Rule | Findings | |---|---:| | duplicated block | 778 | -| complex function | 289 | +| complex function | 290 | | blocking call on the render path | 195 | | deeply nested | 113 | | module type in the UI | 71 | @@ -50,7 +50,7 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta | `platform` | 186 | | `ui` | 74 | | `moondeck` | 24 | -| `src` | 4 | +| `src` | 5 | | `mooninstaller` | 2 | ## By file @@ -257,6 +257,7 @@ The next function worth simplifying is at the top of each file's list. | `src/light/util/MpegTs.h` | 2 | `ts::Writer::writeAccessUnit`, complex function 23 | | `src/light/util/Palette.h` | 2 | `paletteOptions`, complex function 12 | | `src/light/util/RtpH264.h` | 2 | `rtp::Packetiser::writeNal`, long parameter list 8 | +| `src/module_types.cpp` | 2 | `registerModuleTypes`, long function 150 | | `src/platform/esp32/moonlive_asm_riscv.h` | 2 | `lines 74-91, also in src/platform/esp32/moonlive_asm_xtensa.h:79`, duplicated block 18 | | `src/platform/esp32/moonlive_asm_xtensa.cpp` | 2 | `lines 481-487, also in src/platform/esp32/moonlive_asm_riscv.cpp:297`, duplicated block 7 | | `src/platform/esp32/moonlive_emit_riscv.cpp` | 2 | `lines 52-57, also in src/platform/esp32/moonlive_emit_riscv.cpp:25`, duplicated block 6 | @@ -342,7 +343,6 @@ The next function worth simplifying is at the top of each file's list. | `src/light/moonlive/MoonLivePalette.h` | 1 | `moonlive::MoonLive::runValue in tick`, blocking call on the render path 1 | | `src/light/powerfunctions/fluid.h` | 1 | `Fluid::resize`, complex function 14 | | `src/light/util/E131Packet.h` | 1 | `parseE131Packet`, complex function 17 | -| `src/module_types.cpp` | 1 | `registerModuleTypes`, long function 150 | | `src/platform/desktop/moonlive_asm_host.h` | 1 | `lines 36-43, also in src/platform/esp32/moonlive_asm_riscv.h:36`, duplicated block 8 | | `src/platform/desktop/platform_config.h` | 1 | `lines 71-85, also in src/platform/esp32/platform_config.h:241`, duplicated block 15 | | `src/platform/esp32/moonlive_asm_xtensa.h` | 1 | `lines 79-96, also in src/platform/esp32/moonlive_asm_riscv.h:74`, duplicated block 18 | diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index e0ff0b1e5..eaef732f5 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,7 +4,7 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks. -**0 error(s)** and **4317 warning(s)** across 421 page(s). +**0 error(s)** and **4316 warning(s)** across 421 page(s). **5534 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. @@ -17,7 +17,7 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| | split paragraphs | 0 | 1800 | -| over-wide comment lines | 0 | 1596 | +| over-wide comment lines | 0 | 1595 | | appendix sections nothing refers to | 0 | 372 | | multi-line comment blocks | 0 | 321 | | over-long sentences | 0 | 82 | @@ -33,7 +33,7 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier | `(tests, no card)` | 0 | 2072 | | `platform/index.md` | 0 | 746 | | `light/effects.md` | 0 | 403 | -| `core/system.md` | 0 | 223 | +| `core/system.md` | 0 | 222 | | `core/supporting.md` | 0 | 181 | | `light/power-functions.md` | 0 | 147 | | `core/services.md` | 0 | 123 | @@ -111,7 +111,7 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. ### core/system.md -**0 error(s)** and **223 warning(s)** across 26 file(s). +**0 error(s)** and **222 warning(s)** across 26 file(s). | Findings | File | | |---:|---|---| @@ -125,9 +125,9 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. | 12 | `src/core/system/TasksModule.h` | warning | | 11 | `src/core/system/MqttModule.h` | warning | | 11 | `src/core/system/MqttPacket.h` | warning | -| 1-11 each | *16 more warning files, 78 findings* | | +| 1-11 each | *16 more warning files, 77 findings* | | -By rule, warnings: 165 split paragraphs, 37 appendix sections nothing refers to, 12 over-wide comment lines, 9 multi-line comment blocks. +By rule, warnings: 165 split paragraphs, 37 appendix sections nothing refers to, 11 over-wide comment lines, 9 multi-line comment blocks. ### core/supporting.md diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 27e868835..f7f43cb6c 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "73bc49a6", + "commit": "dc236f19", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, - "esp32p4rev1-eth": 2328800, + "esp32p4rev1-eth": 2328816, "esp32p4rev1-eth-wifi": 2358896, "esp32s3-n8r8": 2336304, "esp32s31": 2595200, @@ -12,7 +12,7 @@ "esp32-wrover": 1843760, "qemu": 1383648, "esp32p4rev3-eth": 2203712, - "esp32s3-zero": 2231616, + "esp32s3-zero": 2225360, "esp32-pico": 2107168, "desktop-macos": 2270440, "desktop-windows": 1925120, @@ -54,12 +54,6 @@ "p95": 2744, "n": 4, "last": "2026-10-04" - }, - "desktop-linux": { - "p50": 22, - "p95": 22, - "n": 1, - "last": "2026-10-11" } }, "Firmware_reports_what_is_running": { @@ -126,12 +120,6 @@ "p95": 2800, "n": 3, "last": "2026-10-04" - }, - "desktop-linux": { - "p50": 8, - "p95": 8, - "n": 1, - "last": "2026-10-11" } }, "Aurora_fps": { @@ -520,12 +508,6 @@ "p95": 218, "n": 1, "last": "2026-09-30" - }, - "desktop-linux": { - "p50": 8, - "p95": 8, - "n": 1, - "last": "2026-10-11" } }, "Effects_pipeline_builds_and_renders": { @@ -535,12 +517,6 @@ "n": 32, "last": "2026-10-10" }, - "desktop-linux": { - "p50": 8, - "p95": 8, - "n": 1, - "last": "2026-10-11" - }, "desktop-windows": { "p50": 4, "p95": 6, @@ -572,12 +548,6 @@ "p95": 287, "n": 1, "last": "2026-09-30" - }, - "desktop-linux": { - "p50": 5, - "p95": 5, - "n": 1, - "last": "2026-10-11" } }, "Effects_teardown_under_a_running_pipeline": { @@ -604,12 +574,6 @@ "p95": 482, "n": 1, "last": "2026-09-30" - }, - "desktop-linux": { - "p50": 17, - "p95": 17, - "n": 1, - "last": "2026-10-11" } }, "Layers_stack_and_blend_live": { @@ -618,12 +582,6 @@ "p95": 355, "n": 32, "last": "2026-10-10" - }, - "desktop-linux": { - "p50": 967, - "p95": 967, - "n": 1, - "last": "2026-10-11" } }, "Layouts_resize_reallocates_live": { @@ -650,12 +608,6 @@ "p95": 579, "n": 1, "last": "2026-09-30" - }, - "desktop-linux": { - "p50": 25, - "p95": 25, - "n": 1, - "last": "2026-10-11" } }, "Modifiers_reshape_the_mapping": { @@ -682,12 +634,6 @@ "p95": 273, "n": 1, "last": "2026-09-30" - }, - "desktop-linux": { - "p50": 14, - "p95": 14, - "n": 1, - "last": "2026-10-11" } }, "Palettes_a_script_drives_the_effects": { @@ -696,12 +642,6 @@ "p95": 74, "n": 32, "last": "2026-10-10" - }, - "desktop-linux": { - "p50": 12, - "p95": 12, - "n": 1, - "last": "2026-10-11" } } }, @@ -742,28 +682,14 @@ } }, "desktop-linux": { - "tick_us": 12, - "fps": 83333, - "scenario_p50": { - "Effects_pipeline_builds_and_renders": { - "p50": 8, - "p95": 8, - "n": 1, - "last": "2026-10-11" - }, - "Layouts_resize_reallocates_live": { - "p50": 25, - "p95": 25, - "n": 1, - "last": "2026-10-11" - } - } + "tick_us": 6, + "fps": 166666 } }, "loc": { - "core": 28657, + "core": 28659, "light": 31027, - "platform": 18758, + "platform": 18769, "ui": 12350, "test": 65084, "moondeck": 30600 diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 41b0b8bf1..d3e48c99a 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `73bc49a6`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `dc236f19`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -14,11 +14,11 @@ Current state only; the trend is this file's git history (`git log -p docs/refer | flash esp32p4rev3-eth | 2,025 KB | 2,152 KB | +126.8 KB (+6.3%) | | flash esp32s3-n16r8 | 2,111 KB | 2,282 KB | +170.1 KB (+8.1%) | | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | -| flash esp32s3-zero | 1,977 KB | 2,179 KB | +202.6 KB (+10.2%) | +| flash esp32s3-zero | 1,977 KB | 2,173 KB | +196.5 KB (+9.9%) | | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | -| lines core | 22,299 | 28,657 | +6,358 (+28.5%) | +| lines core | 22,299 | 28,659 | +6,360 (+28.5%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | -| lines platform | 16,798 | 18,758 | +1,960 (+11.7%) | +| lines platform | 16,798 | 18,769 | +1,971 (+11.7%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | | lines test | 57,759 | 65,084 | +7,325 (+12.7%) | | lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | @@ -40,12 +40,12 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 32d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | | esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 1d | -| esp32p4rev1-eth | 2,274 KB | 4,096 KB | 56% | yes | +| esp32p4rev1-eth | 2,274 KB (+16 B) โš  | 4,096 KB | 56% | yes | | esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 11d** | | esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 3d | | esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 1d | | esp32s3-n8r8 | 2,282 KB | 3,072 KB | 74% | carried 1d | -| esp32s3-zero | 2,179 KB (โˆ’656 B) โœ“ | 2,496 KB | 87% | yes | +| esp32s3-zero | 2,173 KB (โˆ’6 KB) โœ“ | 2,496 KB | 87% | yes | | esp32s31 | 2,534 KB | 4,096 KB | 62% | carried 1d | | qemu | 1,351 KB | - | - | carried (age?) | @@ -55,47 +55,38 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 12 ยตs (+3 ยตs) โš  | 83,333 (โˆ’27,778) โš  | +| desktop-linux | 6 ยตs (โˆ’6 ยตs) โœ“ | 166,666 (+83,333) โœ“ | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | | esp32 | 8,354 ยตs | 119 | ### Scenario tick by target (p50 of each sample window) -| Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | desktop-docker-arm64 | desktop-linux | esp32p4rev1-eth-wifi | -|---|---|---|---|---|---|---|---|---|---|---|---| -| Aurora_fps | 1,549 | 6,015 ? | 217,295 ? | 188,782 ? | 83,632 ? | 87,613 ? | - | - | 7,532 ? | - | 1,942,284 ? | -| Control_a_palette_preset_changes_only_the_palette | 7 | - | - | 2,073 | - | - | - | - | - | 22 (+4) โš  ? | - | -| Drivers_output_and_brightness | 7 | 4 ? | - | 8,690 ? | - | - | - | - | 218 ? | 8 (+2) โš  ? | - | -| Effects_pipeline_builds_and_renders | 7 | 4 ? | - | - | - | - | - | - | - | 8 (+1) โš  ? | - | -| Effects_swap_while_running | 1 | 4 ? | - | 10,000 ? | - | - | - | - | 287 ? | 5 (+1) โš  ? | - | -| Effects_teardown_under_a_running_pipeline | 7 | 17 ? | - | 9,080 | - | - | - | - | 482 ? | 17 (+5) โš  ? | - | -| Firmware_reports_what_is_running | 29 | 1,827 ? | - | 8,659 ? | - | - | - | - | 215 ? | - | - | -| Fluid_solver | 218 | 685,902 ? | 45,974 ? | 102,058 ? | 71,150 ? | 66,874 ? | - | - | 3,371 ? | - | 761,768 ? | -| GridLayout_resize | 120 | 219 ? | 1,352 ? | 18,414 ? | 7,218 ? | 8,919 ? | 95,771 ? | 82,231 ? | 2,005 ? | - | 6,735,719 ? | -| Layers_stack_and_blend_live | 267 | - | - | - | - | - | - | - | - | 967 (+344) โš  ? | - | -| Layouts_resize_reallocates_live | 10 | 93 ? | - | 32,399 ? | - | - | - | - | 579 ? | 25 (+6) โš  ? | - | -| Modifiers_reshape_the_mapping | 7 | 29 ? | - | 10,350 ? | - | - | - | - | 273 ? | 14 (+4) โš  ? | - | -| Network_hardware_reconfigures_live | 21 ? | 2,834 ? | - | 8,692 ? | - | - | - | - | 237 ? | - | - | -| Palettes_a_script_drives_the_effects | 7 | - | - | - | - | - | - | - | - | 12 (+4) โš  ? | - | -| State_one_document_adds_an_effect_with_its_controls | 4 | - | - | 2,505 ? | - | - | - | - | - | 8 (+2) โš  ? | - | -| perf_full | 256 | 592 ? | 5,313 | 25,340 ? | 11,711 ? | 13,373 ? | - | - | 2,438 ? | - | 5,469,660 ? | -| perf_light | 16 | 35 ? | 2,510 | 3,044 | 1,117 ? | 1,378 ? | - | - | 780 ? | - | 5,385,763 ? | -| peripheral_grid_sweep | 257 | 649 ? | 5,766 | 70,668 ? | 11,495 ? | 12,273 ? | - | - | 2,577 ? | - | 1,673,544 ? | -| peripheral_switch | 4 | 9 ? | 425 | 2,307 | 2,570 ? | 2,990 ? | - | - | 341 ? | - | 773,191 ? | +| Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | desktop-docker-arm64 | esp32p4rev1-eth-wifi | +|---|---|---|---|---|---|---|---|---|---|---| +| Aurora_fps | 1,549 | 6,015 ? | 217,295 ? | 188,782 ? | 83,632 ? | 87,613 ? | - | - | 7,532 ? | 1,942,284 ? | +| Control_a_palette_preset_changes_only_the_palette | 7 | - | - | 2,073 | - | - | - | - | - | - | +| Drivers_output_and_brightness | 7 | 4 ? | - | 8,690 ? | - | - | - | - | 218 ? | - | +| Effects_pipeline_builds_and_renders | 7 | 4 ? | - | - | - | - | - | - | - | - | +| Effects_swap_while_running | 1 | 4 ? | - | 10,000 ? | - | - | - | - | 287 ? | - | +| Effects_teardown_under_a_running_pipeline | 7 | 17 ? | - | 9,080 | - | - | - | - | 482 ? | - | +| Firmware_reports_what_is_running | 29 | 1,827 ? | - | 8,659 ? | - | - | - | - | 215 ? | - | +| Fluid_solver | 218 | 685,902 ? | 45,974 ? | 102,058 ? | 71,150 ? | 66,874 ? | - | - | 3,371 ? | 761,768 ? | +| GridLayout_resize | 120 | 219 ? | 1,352 ? | 18,414 ? | 7,218 ? | 8,919 ? | 95,771 ? | 82,231 ? | 2,005 ? | 6,735,719 ? | +| Layers_stack_and_blend_live | 267 | - | - | - | - | - | - | - | - | - | +| Layouts_resize_reallocates_live | 10 | 93 ? | - | 32,399 ? | - | - | - | - | 579 ? | - | +| Modifiers_reshape_the_mapping | 7 | 29 ? | - | 10,350 ? | - | - | - | - | 273 ? | - | +| Network_hardware_reconfigures_live | 21 ? | 2,834 ? | - | 8,692 ? | - | - | - | - | 237 ? | - | +| Palettes_a_script_drives_the_effects | 7 | - | - | - | - | - | - | - | - | - | +| State_one_document_adds_an_effect_with_its_controls | 4 | - | - | 2,505 ? | - | - | - | - | - | - | +| perf_full | 256 | 592 ? | 5,313 | 25,340 ? | 11,711 ? | 13,373 ? | - | - | 2,438 ? | 5,469,660 ? | +| perf_light | 16 | 35 ? | 2,510 | 3,044 | 1,117 ? | 1,378 ? | - | - | 780 ? | 5,385,763 ? | +| peripheral_grid_sweep | 257 | 649 ? | 5,766 | 70,668 ? | 11,495 ? | 12,273 ? | - | - | 2,577 ? | 1,673,544 ? | +| peripheral_switch | 4 | 9 ? | 425 | 2,307 | 2,570 ? | 2,990 ? | - | - | 341 ? | 773,191 ? | Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first impression rather than a percentile; several are months old and were captured during a network reconfigure, so they read as whole milliseconds. `-` means that target has never run that scenario. -**Coverage: 104/209 cells measured (49%), 26 of them with 4+ samples (12%).** The blanks are the point: a regression on a target that has never run a scenario cannot be DETECTED in it, and the target cannot be compared against the others. Filling the matrix means running the scenario suite on each bench board, which is a standing task rather than a one-off. - -### desktop-linux: isolated scenarios (p50 of the sample window) - -| Scenario | p50 | p95 | n | -|---|---:|---:|---:| -| Effects_pipeline_builds_and_renders | 8 ยตs (+1 ยตs) โš  | 8 ยตs | 1 | -| Layouts_resize_reallocates_live | 25 ยตs (+6 ยตs) โš  | 25 ยตs | 1 | - -These build a bare pipeline with no optional modules, so a change here is a change in the pipeline itself rather than in what was measured. A new module belongs in an advanced scenario, which keeps its own numbers. +**Coverage: 94/190 cells measured (49%), 26 of them with 4+ samples (13%).** The blanks are the point: a regression on a target that has never run a scenario cannot be DETECTED in it, and the target cannot be compared against the others. Filling the matrix means running the scenario suite on each bench board, which is a standing task rather than a one-off. ### desktop-macos: isolated scenarios (p50 of the sample window) @@ -119,9 +110,9 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,657 (โˆ’6) โœ“ | 6,243 | 24.2 % | +| core | 28,659 (+2) โš  | 6,243 | 24.2 % | | light | 31,027 | 7,439 | 26.9 % | -| platform | 18,758 | 3,777 | 22.5 % | +| platform | 18,769 (+11) โš  | 3,777 | 22.5 % | | ui | 12,350 | 3,472 | 29.8 % | | test | 65,084 | 6,997 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | @@ -138,7 +129,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | functions | 4,475 | -| over threshold | 337 (โˆ’1) โœ“ | +| over threshold | 337 | | worst CCN | 128 | ## Documentation @@ -146,7 +137,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 150 | -| markdown lines | 32,292 (+9) โš  | +| markdown lines | 32,292 | | plan files | 44 | | backlog lines | 3,319 | | lessons lines | 532 | diff --git a/src/core/system/HttpServerModule.cpp b/src/core/system/HttpServerModule.cpp index 6d2256e91..26bc547f4 100644 --- a/src/core/system/HttpServerModule.cpp +++ b/src/core/system/HttpServerModule.cpp @@ -904,16 +904,18 @@ void HttpServerModule::handleApplyState(platform::TcpConnection& conn, const cha // The /sd leg of handleWriteFile: claims a transfer slot, opens the stream, writes whatever of the body already landed in the request buffer, then hands the rest to driveSdTransfers a tick at a time. void HttpServerModule::handleWriteFileSd(platform::TcpConnection& conn, const char* path, const char* initialBody, size_t initialLen, size_t contentLen) { - // Reject up front if it would not fit the card, rather than failing mid-write; free is taken conservatively, with no credit for an overwrite, and is the only ceiling an SD upload has. - const uint64_t total = platform::sdCardTotal(); - const uint64_t used = platform::sdCardUsed(); - const uint64_t freeBytes = total > used ? total - used : 0; - if (total > 0 && contentLen > freeBytes) { - char msg[96]; - std::snprintf(msg, sizeof(msg), "{\"error\":\"not enough space (%llu free)\"}", - static_cast(freeBytes)); - sendResponse(conn, 507, "application/json", msg); - return; + // Reject up front if it would not fit the card, rather than failing mid-write; free is taken conservatively, with no credit for an overwrite. Without hasSdCard, claimSdTransfer answers 503. + if constexpr (platform::hasSdCard) { + const uint64_t total = platform::sdCardTotal(); + const uint64_t used = platform::sdCardUsed(); + const uint64_t freeBytes = total > used ? total - used : 0; + if (total > 0 && contentLen > freeBytes) { + char msg[96]; + std::snprintf(msg, sizeof(msg), "{\"error\":\"not enough space (%llu free)\"}", + static_cast(freeBytes)); + sendResponse(conn, 507, "application/json", msg); + return; + } } SdTransfer* t = claimSdTransfer(); if (!t) { diff --git a/src/core/system/SdCardModule.cpp b/src/core/system/SdCardModule.cpp index 710f363ca..815ac1228 100644 --- a/src/core/system/SdCardModule.cpp +++ b/src/core/system/SdCardModule.cpp @@ -11,7 +11,7 @@ namespace mm { void SdCardModule::defineControls() { - // Unhidden: a board without hasSdCard simply never boots this module (module_types.cpp's kBootModules gates on it), so there is no "wrong board" case left for a hidden pin to guard against. + // Unhidden: a board without hasSdCard never boots this module at all (module_types.cpp gates on it), so no board is left for a hidden pin to guard against. controls_.addPin("clkPin", clkPin_); controls_.addPin("cmdPin", cmdPin_); controls_.addPin("d0Pin", d0Pin_); diff --git a/src/module_types.cpp b/src/module_types.cpp index 5f74c2fa8..c2a01b370 100644 --- a/src/module_types.cpp +++ b/src/module_types.cpp @@ -367,7 +367,8 @@ void mm::registerModuleTypes() { mm::ModuleFactory::registerType("InfraredService", "core/services.md#infrared"); mm::ModuleFactory::registerType("MoonLiveService", "core/services.md#moonliveservice"); mm::ModuleFactory::registerType("FileManagerModule", "core/system.md#file-manager"); - mm::ModuleFactory::registerType("SdCardModule", "core/system.md#sd-card"); + // Unlike WiFi/Ethernet, a board without hasSdCard can never gain the capability without a rebuild, so the type is not left addable for nothing. + if constexpr (mm::platform::hasSdCard) mm::ModuleFactory::registerType("SdCardModule", "core/system.md#sd-card"); mm::ModuleFactory::registerType("FirmwareUpdateModule", "core/system.md#firmware-update"); mm::ModuleFactory::registerType("MoonCloudModule", "core/system.md#mooncloud"); mm::ModuleFactory::registerType("MoonStatsModule", "core/system.md#stats"); diff --git a/src/platform/esp32/platform_esp32_fs.cpp b/src/platform/esp32/platform_esp32_fs.cpp index 39279a7ae..5c9bf74b3 100644 --- a/src/platform/esp32/platform_esp32_fs.cpp +++ b/src/platform/esp32/platform_esp32_fs.cpp @@ -103,7 +103,7 @@ void fsUnmount() { } bool fsMkdir(const char* path) { - if (isSdApiPath(path)) return sdCardMkdir(path); + if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardMkdir(path); } if (!fsMounted_) return false; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return false; @@ -122,7 +122,7 @@ bool fsMkdir(const char* path) { } bool fsExists(const char* path) { - if (isSdApiPath(path)) return sdCardExists(path); + if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardExists(path); } if (!fsMounted_) return false; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return false; @@ -131,7 +131,7 @@ bool fsExists(const char* path) { } bool fsRemove(const char* path) { - if (isSdApiPath(path)) return sdCardRemove(path); + if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardRemove(path); } if (!fsMounted_) return false; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return false; @@ -142,7 +142,7 @@ bool fsRemove(const char* path) { } int fsRead(const char* path, char* buf, size_t maxLen) { - if (isSdApiPath(path)) return sdCardRead(path, buf, maxLen); + if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardRead(path, buf, maxLen); } if (!fsMounted_ || !buf || maxLen == 0) return -1; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return -1; @@ -168,7 +168,7 @@ static void makeParent(const char* path) { } bool fsWriteAtomic(const char* path, const char* data, size_t len) { - if (isSdApiPath(path)) return sdCardWriteAtomic(path, data, len); + if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardWriteAtomic(path, data, len); } if (!fsMounted_) return false; // A non-zero length with a null pointer is undefined in the write below, so guard the boundary. if (len > 0 && !data) return false; @@ -200,7 +200,7 @@ bool fsWriteAtomic(const char* path, const char* data, size_t len) { } int fsReadAt(const char* path, long offset, char* buf, size_t len) { - if (isSdApiPath(path)) return sdCardReadAt(path, offset, buf, len); + if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardReadAt(path, offset, buf, len); } if (!fsMounted_ || !buf) return -1; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return -1; @@ -213,7 +213,7 @@ int fsReadAt(const char* path, long offset, char* buf, size_t len) { } long fsSize(const char* path) { - if (isSdApiPath(path)) return sdCardSize(path); + if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardSize(path); } if (!fsMounted_) return -1; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return -1; @@ -223,7 +223,7 @@ long fsSize(const char* path) { } bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { - if (isSdApiPath(path)) return sdCardWriteStreamCb(path, src, user); + if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardWriteStreamCb(path, src, user); } if (!fsMounted_ || !src) return false; makeParent(path); char full[128]; @@ -252,7 +252,7 @@ bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { } void fsList(const char* dir, FsListCb cb, void* user) { - if (isSdApiPath(dir)) { sdCardList(dir, cb, user); return; } + if constexpr (hasSdCard) { if (isSdApiPath(dir)) { sdCardList(dir, cb, user); return; } } if (!fsMounted_ || !cb) return; // The card is a second mount, not a real subdirectory, so the root listing advertises it by hand. if (hasSdCard && dir && (dir[0] == '\0' || (dir[0] == '/' && dir[1] == '\0'))) cb("sd", true, 0, user); @@ -285,15 +285,18 @@ struct StreamImpl { bool fsReadStreamOpen(FsReadStream& h, const char* path) { auto* impl = new StreamImpl(); - if (isSdApiPath(path)) { - impl->isSd = true; - if (!sdCardReadStreamOpen(impl->sdHandle, path)) { delete impl; return false; } - } else { - char full[128]; - if (!fsMounted_ || !fsTranslate(path, full, sizeof(full))) { delete impl; return false; } - impl->file = std::fopen(full, "rb"); - if (!impl->file) { delete impl; return false; } + if constexpr (hasSdCard) { + if (isSdApiPath(path)) { + impl->isSd = true; + if (!sdCardReadStreamOpen(impl->sdHandle, path)) { delete impl; return false; } + h.impl = impl; + return true; + } } + char full[128]; + if (!fsMounted_ || !fsTranslate(path, full, sizeof(full))) { delete impl; return false; } + impl->file = std::fopen(full, "rb"); + if (!impl->file) { delete impl; return false; } h.impl = impl; return true; } @@ -301,7 +304,7 @@ bool fsReadStreamOpen(FsReadStream& h, const char* path) { int fsReadStreamRead(FsReadStream& h, char* buf, size_t len) { auto* impl = static_cast(h.impl); if (!impl) return -1; - if (impl->isSd) return sdCardReadStreamRead(impl->sdHandle, buf, len); + if constexpr (hasSdCard) { if (impl->isSd) return sdCardReadStreamRead(impl->sdHandle, buf, len); } const size_t n = std::fread(buf, 1, len, impl->file); if (n == 0 && std::ferror(impl->file)) return -1; return static_cast(n); @@ -310,25 +313,30 @@ int fsReadStreamRead(FsReadStream& h, char* buf, size_t len) { void fsReadStreamClose(FsReadStream& h) { auto* impl = static_cast(h.impl); if (!impl) return; - if (impl->isSd) sdCardReadStreamClose(impl->sdHandle); - else if (impl->file) std::fclose(impl->file); + if constexpr (hasSdCard) { + if (impl->isSd) { sdCardReadStreamClose(impl->sdHandle); delete impl; h.impl = nullptr; return; } + } + if (impl->file) std::fclose(impl->file); delete impl; h.impl = nullptr; } bool fsWriteStreamOpen(FsWriteStream& h, const char* path) { auto* impl = new StreamImpl(); - if (isSdApiPath(path)) { - impl->isSd = true; - if (!sdCardWriteStreamOpen(impl->sdHandle, path)) { delete impl; return false; } - } else { - makeParent(path); - if (!fsMounted_ || !fsTranslate(path, impl->full, sizeof(impl->full))) { delete impl; return false; } - const int n = std::snprintf(impl->tmp, sizeof(impl->tmp), "%s.tmp", impl->full); - if (n < 0 || static_cast(n) >= sizeof(impl->tmp)) { delete impl; return false; } - impl->file = std::fopen(impl->tmp, "wb"); - if (!impl->file) { delete impl; return false; } + if constexpr (hasSdCard) { + if (isSdApiPath(path)) { + impl->isSd = true; + if (!sdCardWriteStreamOpen(impl->sdHandle, path)) { delete impl; return false; } + h.impl = impl; + return true; + } } + makeParent(path); + if (!fsMounted_ || !fsTranslate(path, impl->full, sizeof(impl->full))) { delete impl; return false; } + const int n = std::snprintf(impl->tmp, sizeof(impl->tmp), "%s.tmp", impl->full); + if (n < 0 || static_cast(n) >= sizeof(impl->tmp)) { delete impl; return false; } + impl->file = std::fopen(impl->tmp, "wb"); + if (!impl->file) { delete impl; return false; } h.impl = impl; return true; } @@ -336,25 +344,28 @@ bool fsWriteStreamOpen(FsWriteStream& h, const char* path) { bool fsWriteStreamWrite(FsWriteStream& h, const char* buf, size_t len) { auto* impl = static_cast(h.impl); if (!impl) return false; - if (impl->isSd) return sdCardWriteStreamWrite(impl->sdHandle, buf, len); + if constexpr (hasSdCard) { if (impl->isSd) return sdCardWriteStreamWrite(impl->sdHandle, buf, len); } return std::fwrite(buf, 1, len, impl->file) == len; } bool fsWriteStreamClose(FsWriteStream& h, bool commit) { auto* impl = static_cast(h.impl); if (!impl) return false; - bool ok; - if (impl->isSd) { - ok = sdCardWriteStreamClose(impl->sdHandle, commit); // owns its own rename/discard - } else { - std::fflush(impl->file); - const int fd = ::fileno(impl->file); - if (fd >= 0) ::fsync(fd); - std::fclose(impl->file); - ok = true; - if (commit) ok = ::rename(impl->tmp, impl->full) == 0; - if (!commit || !ok) ::remove(impl->tmp); + if constexpr (hasSdCard) { + if (impl->isSd) { + const bool ok = sdCardWriteStreamClose(impl->sdHandle, commit); // owns its own rename/discard + delete impl; + h.impl = nullptr; + return ok; + } } + std::fflush(impl->file); + const int fd = ::fileno(impl->file); + if (fd >= 0) ::fsync(fd); + std::fclose(impl->file); + bool ok = true; + if (commit) ok = ::rename(impl->tmp, impl->full) == 0; + if (!commit || !ok) ::remove(impl->tmp); delete impl; h.impl = nullptr; return ok; From f251d9ad664d0209cd63ed8dc6f8eb17f52296d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Sun, 11 Oct 2026 22:43:42 +0200 Subject: [PATCH 06/20] Gate /sd on a mounted card, not the chip's bare capability PR #136 review item 4 (yours): the root listing advertised /sd whenever hasSdCard was set, even with no card actually mounted - a build flag, not a card in the slot - so an upload into an empty slot failed with a generic 500 instead of saying why. On desktop, hasSdCard is unconditionally true for real test coverage, which also meant SdCardModule booted into every plain desktop run's tree, showing its pins and an idle status to someone doing unrelated work. Flash: esp32p4rev1-eth +48 B (2328816 -> 2328864). desktop +328 B (new code only; it is never hasSdCard-gated there). Account: src +32/-4 lines | test +32/-1 lines | code findings 1640 -> 1642 (complex function +2, explained below) | duplicated lines 3767 -> 3767 (+0) Core: - platform.h: new platform::sdCardMounted() (public; was already internal-only on ESP32, added fresh on desktop as sdMounted_'s accessor) and platform::sdCardBootsByDefault(). - platform_esp32_fs.cpp, platform_desktop.cpp: fsList's synthetic "sd" root entry now checks sdCardMounted() instead of hasSdCard, on both backends. Desktop's skip-the-real-backing-folder line stays on hasSdCard, since that folder exists on disk across mount cycles and must never show up as a second, ordinary entry regardless of current mount state. - HttpServerModule.cpp: handleWriteFileSd answers 503 "no card mounted" up front when sdCardMounted() is false, before the free-space check or claimSdTransfer - a specific reason instead of a generic failure further down the path. - module_types.cpp, platform_esp32_sdmmc.cpp, platform_desktop.cpp: SdCardModule's kBootModules row keeps gating on hasSdCard (the table is constexpr, so it cannot read a runtime flag), but createTopLevel's boot loop adds one extra, name-keyed veto consulting the new sdCardBootsByDefault(): always true where hasSdCard is on ESP32, default false on desktop. A test opts it in with the new setTestSdCardBootsByDefault(bool), the runtime hook next to setTestSdCardTotal ewowi asked for; nothing needs it today; both existing SD test files construct their modules directly and never go through kBootModules at all. Tests: - unit_HttpServerModule_sdcard.cpp: two new cases - the root listing drops "sd" once the card is unmounted (same slot, same pins, nothing answering), and an upload in that state gets "no card mounted" rather than a generic error. - The createTopLevel veto itself has no doctest coverage: nothing in the unit-test binary ever calls registerModuleTypes()/createTopLevel (only main.cpp and the scenario harness do), and building that infrastructure fresh for one veto would be disproportionate. Verified instead on the real, production code path both ways: on esp32p4rev1-eth (hasSdCard true), unmounting via a live pin edit drops "sd" from the listing and an upload answers 503 "no card mounted"; remounting restores both and the card's existing files are untouched. On a plain `run_desktop.py` run (hasSdCard true, sdCardBootsByDefault false), neither a SdCardModule entry nor an "sd" folder appears anywhere in the tree or the root listing by default. complex function +2: handleWriteFileSd (10 -> 11) and createTopLevel (<=10 -> 11) each crossed from the one necessary conditional this item adds. Per coding-standards.md's own escape valve, neither is a chain or state switch asking to become a table or a State pattern; accepted as-is. Checks (scoped to this diff's own trigger-table buckets - src/ and test/ only): - check_specs: PASS - 136/136 modules - check_prose: PASS - 0 new findings - check_docgen: PASS - 0 errors, 0 net new warnings (matches the committed baseline exactly) - check_code: FAIL - the two explained complex-function deltas above; nothing else rose - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2431/2431 - run_scenario: PASS - 30 scenarios, 17 passed / 13 skipped, 0 failed - check_esp32_built: PASS - fresh after the real rebuild+flash - collect_kpi: PASS - recorded above (the perf.* swings in this run are host noise) - Real hardware (esp32p4rev1-eth, over the network - serial dropped again this session) and a real `run_desktop.py` run: both verified live as described above --- docs/reference/metrics/code.md | 16 ++++----- docs/reference/metrics/repo-health.json | 26 +++++++------- docs/reference/metrics/repo-health.md | 34 +++++++++---------- src/core/system/HttpServerModule.cpp | 4 +++ src/module_types.cpp | 2 ++ src/platform/desktop/platform_desktop.cpp | 15 ++++++-- src/platform/esp32/platform_esp32_fs.cpp | 6 ++-- src/platform/esp32/platform_esp32_sdmmc.cpp | 3 ++ src/platform/platform.h | 6 ++++ .../core/unit_HttpServerModule_sdcard.cpp | 31 +++++++++++++++++ 10 files changed, 101 insertions(+), 42 deletions(-) diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index d1924ee58..ff1a51f8c 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,14 +4,14 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows. -**1640 finding(s)** across 322 file(s). 2.88% of all lines are duplicated, 3767 lines. +**1642 finding(s)** across 322 file(s). 2.87% of all lines are duplicated, 3767 lines. ## By rule | Rule | Findings | |---|---:| | duplicated block | 778 | -| complex function | 290 | +| complex function | 292 | | blocking call on the render path | 195 | | deeply nested | 113 | | module type in the UI | 71 | @@ -46,11 +46,11 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta |---|---:| | `test` | 576 | | `light` | 421 | -| `core` | 352 | +| `core` | 353 | | `platform` | 186 | | `ui` | 74 | | `moondeck` | 24 | -| `src` | 5 | +| `src` | 6 | | `mooninstaller` | 2 | ## By file @@ -59,8 +59,8 @@ The next function worth simplifying is at the top of each file's list. | File | Findings | Worst | |---|---:|---| -| `src/platform/desktop/platform_desktop.cpp` | 46 | `platform::getMacAddress, line 1412`, large stack frame 5240 | -| `src/core/system/HttpServerModule.cpp` | 44 | `HttpServerModule::handleConnection`, complex function 123 | +| `src/platform/desktop/platform_desktop.cpp` | 46 | `platform::getMacAddress, line 1423`, large stack frame 5240 | +| `src/core/system/HttpServerModule.cpp` | 45 | `HttpServerModule::handleConnection`, complex function 123 | | `src/ui/migrate.js` | 43 | `line 29: 'I2cBusModule'`, module type in the UI 1 | | `src/core/system/MqttModule.cpp` | 31 | `MqttModule::routePublish`, complex function 29 | | `src/ui/app.js` | 29 | `app.js`, large file 9076 | @@ -210,6 +210,7 @@ The next function worth simplifying is at the top of each file's list. | `src/light/effects/SolidEffect.h` | 3 | `SolidEffect::static_cast`, complex function 36 | | `src/light/layouts/GridLayout.h` | 3 | `GridLayout in 86 files`, name spread 86 | | `src/main.cpp` | 3 | `mm_main`, complex function 27 | +| `src/module_types.cpp` | 3 | `registerModuleTypes`, long function 150 | | `src/platform/desktop/moonlive_asm_arm64.cpp` | 3 | `lines 35-40, also in src/platform/esp32/moonlive_asm_riscv.cpp:34`, duplicated block 6 | | `test/unit/core/unit_NetworkModule_ethernet.cpp` | 3 | `lines 276-285, also in test/unit/core/unit_NetworkModule_ethernet.cpp:193`, duplicated block 10 | | `test/unit/light/unit_AuroraEffect.cpp` | 3 | `lines 40-48, also in test/unit/light/unit_PolarLut_equivalence.cpp:40`, duplicated block 9 | @@ -257,7 +258,6 @@ The next function worth simplifying is at the top of each file's list. | `src/light/util/MpegTs.h` | 2 | `ts::Writer::writeAccessUnit`, complex function 23 | | `src/light/util/Palette.h` | 2 | `paletteOptions`, complex function 12 | | `src/light/util/RtpH264.h` | 2 | `rtp::Packetiser::writeNal`, long parameter list 8 | -| `src/module_types.cpp` | 2 | `registerModuleTypes`, long function 150 | | `src/platform/esp32/moonlive_asm_riscv.h` | 2 | `lines 74-91, also in src/platform/esp32/moonlive_asm_xtensa.h:79`, duplicated block 18 | | `src/platform/esp32/moonlive_asm_xtensa.cpp` | 2 | `lines 481-487, also in src/platform/esp32/moonlive_asm_riscv.cpp:297`, duplicated block 7 | | `src/platform/esp32/moonlive_emit_riscv.cpp` | 2 | `lines 52-57, also in src/platform/esp32/moonlive_emit_riscv.cpp:25`, duplicated block 6 | @@ -349,7 +349,7 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/platform_config.h` | 1 | `lines 241-255, also in src/platform/desktop/platform_config.h:71`, duplicated block 15 | | `src/platform/esp32/platform_esp32_i2s.cpp` | 1 | `platform::audioMicInit`, complex function 11 | | `src/platform/esp32/platform_esp32_tasks.cpp` | 1 | `platform::taskSnapshot`, complex function 12 | -| `src/platform/platform.h` | 1 | `platform.h`, large file 1352 | +| `src/platform/platform.h` | 1 | `platform.h`, large file 1358 | | `src/ui/install-picker.js` | 1 | `install-picker.js`, large file 1048 | | `src/ui/preview3d.js` | 1 | `preview3d.js`, large file 1431 | | `test/js/installer-eth-only.test.mjs` | 1 | `lines 13-23, also in test/js/ui-mooncloud.test.mjs:6`, duplicated block 11 | diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index f7f43cb6c..a518f6cad 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "dc236f19", + "commit": "e863bd71", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, - "esp32p4rev1-eth": 2328816, + "esp32p4rev1-eth": 2328864, "esp32p4rev1-eth-wifi": 2358896, "esp32s3-n8r8": 2336304, "esp32s31": 2595200, @@ -17,7 +17,7 @@ "desktop-macos": 2270440, "desktop-windows": 1925120, "esp32c3": 2335072, - "desktop-linux": 2547464 + "desktop-linux": 2547792 }, "measured": { "esp32p4rev1-eth": "2026-10-11", @@ -682,16 +682,16 @@ } }, "desktop-linux": { - "tick_us": 6, - "fps": 166666 + "tick_us": 5, + "fps": 200000 } }, "loc": { - "core": 28659, + "core": 28663, "light": 31027, - "platform": 18769, + "platform": 18791, "ui": 12350, - "test": 65084, + "test": 65115, "moondeck": 30600 }, "comments": { @@ -704,7 +704,7 @@ "ratio": 0.269 }, "platform": { - "lines": 3777, + "lines": 3782, "ratio": 0.225 }, "ui": { @@ -721,20 +721,20 @@ } }, "tests": { - "cases": 2417, + "cases": 2419, "scenarios": 30 }, "docs": { "md_files": 150, - "md_lines": 32292, + "md_lines": 32283, "plans_files": 44, "backlog_lines": 3319, "lessons_lines": 532, "claude_md_lines": 283 }, "complexity": { - "functions": 4475, - "over_threshold": 337, + "functions": 4480, + "over_threshold": 339, "worst_ccn": 128 } } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index d3e48c99a..1b667a08f 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `dc236f19`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `e863bd71`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -10,19 +10,19 @@ Current state only; the trend is this file's git history (`git log -p docs/refer |---|---:|---:|---:| | flash desktop-macos | 1,963 KB | 2,217 KB | +254.2 KB (+12.9%) | | flash esp32 | 2,061 KB | 2,174 KB | +112.9 KB (+5.5%) | -| flash esp32p4rev1-eth | 2,022 KB | 2,274 KB | +252.5 KB (+12.5%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,274 KB | +252.6 KB (+12.5%) | | flash esp32p4rev3-eth | 2,025 KB | 2,152 KB | +126.8 KB (+6.3%) | | flash esp32s3-n16r8 | 2,111 KB | 2,282 KB | +170.1 KB (+8.1%) | | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | | flash esp32s3-zero | 1,977 KB | 2,173 KB | +196.5 KB (+9.9%) | | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | -| lines core | 22,299 | 28,659 | +6,360 (+28.5%) | +| lines core | 22,299 | 28,663 | +6,364 (+28.5%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | -| lines platform | 16,798 | 18,769 | +1,971 (+11.7%) | +| lines platform | 16,798 | 18,791 | +1,993 (+11.9%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | -| lines test | 57,759 | 65,084 | +7,325 (+12.7%) | +| lines test | 57,759 | 65,115 | +7,356 (+12.7%) | | lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | -| unit tests | 2,120 | 2,417 | +297 (+14.0%) | +| unit tests | 2,120 | 2,419 | +299 (+14.1%) | | scenarios | 21 | 30 | +9 (+42.9%) | Against the release `v6.0.0`, the fixed point a user compares with; the tables below compare against the last commit. A firmware not rebuilt since `v6.0.0` is left out, and a measure the release did not record yet, such as the code report, joins from the next one. @@ -31,7 +31,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop-linux | 2,488 KB | - | - | yes | +| desktop-linux | 2,488 KB (+328 B) โš  | - | - | yes | | desktop-macos | 2,217 KB | - | - | carried 1d | | desktop-windows | 1,880 KB | - | - | **STALE 11d** | | esp32 | 2,174 KB | 2,496 KB | 87% | carried 2d | @@ -40,12 +40,12 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 32d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | | esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 1d | -| esp32p4rev1-eth | 2,274 KB (+16 B) โš  | 4,096 KB | 56% | yes | +| esp32p4rev1-eth | 2,274 KB (+48 B) โš  | 4,096 KB | 56% | yes | | esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 11d** | | esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 3d | | esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 1d | | esp32s3-n8r8 | 2,282 KB | 3,072 KB | 74% | carried 1d | -| esp32s3-zero | 2,173 KB (โˆ’6 KB) โœ“ | 2,496 KB | 87% | yes | +| esp32s3-zero | 2,173 KB | 2,496 KB | 87% | yes | | esp32s31 | 2,534 KB | 4,096 KB | 62% | carried 1d | | qemu | 1,351 KB | - | - | carried (age?) | @@ -55,7 +55,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 6 ยตs (โˆ’6 ยตs) โœ“ | 166,666 (+83,333) โœ“ | +| desktop-linux | 5 ยตs (โˆ’1 ยตs) โœ“ | 200,000 (+33,334) โœ“ | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | | esp32 | 8,354 ยตs | 119 | @@ -110,26 +110,26 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,659 (+2) โš  | 6,243 | 24.2 % | +| core | 28,663 (+4) โš  | 6,243 | 24.2 % | | light | 31,027 | 7,439 | 26.9 % | -| platform | 18,769 (+11) โš  | 3,777 | 22.5 % | +| platform | 18,791 (+22) โš  | 3,782 | 22.5 % | | ui | 12,350 | 3,472 | 29.8 % | -| test | 65,084 | 6,997 | 12.4 % | +| test | 65,115 (+31) โš  | 6,997 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,417 | +| unit cases | 2,419 (+2) โœ“ | | scenarios | 30 | ## Complexity | Metric | Value | |---|---:| -| functions | 4,475 | -| over threshold | 337 | +| functions | 4,480 (+5) โœ“ | +| over threshold | 339 (+2) โš  | | worst CCN | 128 | ## Documentation @@ -137,7 +137,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 150 | -| markdown lines | 32,292 | +| markdown lines | 32,283 (โˆ’9) โœ“ | | plan files | 44 | | backlog lines | 3,319 | | lessons lines | 532 | diff --git a/src/core/system/HttpServerModule.cpp b/src/core/system/HttpServerModule.cpp index 26bc547f4..418cdb0d6 100644 --- a/src/core/system/HttpServerModule.cpp +++ b/src/core/system/HttpServerModule.cpp @@ -906,6 +906,10 @@ void HttpServerModule::handleWriteFileSd(platform::TcpConnection& conn, const ch const char* initialBody, size_t initialLen, size_t contentLen) { // Reject up front if it would not fit the card, rather than failing mid-write; free is taken conservatively, with no credit for an overwrite. Without hasSdCard, claimSdTransfer answers 503. if constexpr (platform::hasSdCard) { + if (!platform::sdCardMounted()) { + sendResponse(conn, 503, "application/json", "{\"error\":\"no card mounted\"}"); + return; + } const uint64_t total = platform::sdCardTotal(); const uint64_t used = platform::sdCardUsed(); const uint64_t freeBytes = total > used ? total - used : 0; diff --git a/src/module_types.cpp b/src/module_types.cpp index c2a01b370..5569eb928 100644 --- a/src/module_types.cpp +++ b/src/module_types.cpp @@ -423,6 +423,8 @@ mm::MoonModule* mm::createTopLevel(const char* typeName) { if (!top) return nullptr; for (const BootModule& b : kBootModules) { if (!b.built) continue; + // One extra veto: the table's own flag matches hasSdCard, but booting by default is a separate call desktop opts out of. + if (std::strcmp(b.type, "SdCardModule") == 0 && !mm::platform::sdCardBootsByDefault()) continue; if (!b.parentType) { if (std::strcmp(b.type, typeName) == 0) top->setName(b.name); continue; diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 87fee16f4..29ca1bab0 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -1147,9 +1147,9 @@ bool fsWriteStreamClose(FsWriteStream& h, bool commit) { void fsList(const char* dir, FsListCb cb, void* user) { if (!cb) return; - // The card is a second mount everywhere except on desktop's own fiction, where sdMount() made it a real subdirectory; the root listing advertises it by hand and skips the real one underneath. + // A second mount except in desktop's own fiction (a real subdirectory); advertised by hand here, gated on sdCardMounted() rather than the chip-capability hasSdCard, and skipped below as the real folder underneath. const bool isRoot = dir && (dir[0] == '\0' || (dir[0] == '/' && dir[1] == '\0')); - if (hasSdCard && isRoot) cb("sd", true, 0, user); + if (sdCardMounted() && isRoot) cb("sd", true, 0, user); std::error_code ec; auto p = toFsPath(dir); if (!std::filesystem::exists(p, ec)) return; @@ -1200,6 +1200,8 @@ bool sdMount(const SdPinConfig& pins) { return true; } +bool sdCardMounted() { return sdMounted_; } + bool sdCardBusy() { return sdStreamsOpen_ > 0; } bool sdUnmount() { @@ -1233,9 +1235,18 @@ uint64_t sdCardUsed() { uint64_t sdCardTotal() { return sdMounted_ ? sdCardTotalForTest_ : 0; } +namespace { +// Off by default: hasSdCard is unconditionally true on desktop for test coverage, but that must not boot SdCardModule into an unrelated session's tree; a test opts it in by name. +bool sdCardBootsByDefault_ = false; +} + +bool sdCardBootsByDefault() { return sdCardBootsByDefault_; } + #ifndef ESP_PLATFORM /// Shrink the fictional card, so a test can reach the "not enough space" path without writing a real gigabyte. void setTestSdCardTotal(uint64_t bytes) { sdCardTotalForTest_ = bytes; } +/// Opt SdCardModule's default boot-wiring in or out for one test's scope. +void setTestSdCardBootsByDefault(bool enabled) { sdCardBootsByDefault_ = enabled; } #endif // Network stubs (desktop has no WiFi/Ethernet hardware) diff --git a/src/platform/esp32/platform_esp32_fs.cpp b/src/platform/esp32/platform_esp32_fs.cpp index 5c9bf74b3..e802981e1 100644 --- a/src/platform/esp32/platform_esp32_fs.cpp +++ b/src/platform/esp32/platform_esp32_fs.cpp @@ -254,8 +254,10 @@ bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { void fsList(const char* dir, FsListCb cb, void* user) { if constexpr (hasSdCard) { if (isSdApiPath(dir)) { sdCardList(dir, cb, user); return; } } if (!fsMounted_ || !cb) return; - // The card is a second mount, not a real subdirectory, so the root listing advertises it by hand. - if (hasSdCard && dir && (dir[0] == '\0' || (dir[0] == '/' && dir[1] == '\0'))) cb("sd", true, 0, user); + // A second mount, not a real subdirectory; advertised by hand here, gated on sdCardMounted() rather than the chip-capability hasSdCard. + if constexpr (hasSdCard) { + if (sdCardMounted() && dir && (dir[0] == '\0' || (dir[0] == '/' && dir[1] == '\0'))) cb("sd", true, 0, user); + } char full[128]; if (!fsTranslate(dir, full, sizeof(full))) return; DIR* d = ::opendir(full); diff --git a/src/platform/esp32/platform_esp32_sdmmc.cpp b/src/platform/esp32/platform_esp32_sdmmc.cpp index d114c2f94..edf19b6e1 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.cpp +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -95,6 +95,8 @@ bool sdCardMounted() { return g_mounted; } bool sdCardBusy() { return g_streamsOpen > 0; } +bool sdCardBootsByDefault() { return hasSdCard; } + /// A pin this build never wires reads as -1; one it does but the chip refuses is a mount-time mistake, not UB. static bool validOutputPin(int8_t pin) { return pin >= 0 && GPIO_IS_VALID_OUTPUT_GPIO(pin); @@ -424,6 +426,7 @@ bool sdCardWriteStreamClose(void* handle, bool commit) { bool sdCardMounted() { return false; } bool sdMount(const SdPinConfig&) { return false; } bool sdCardBusy() { return false; } +bool sdCardBootsByDefault() { return false; } bool sdUnmount() { return true; } bool sdFormat() { return false; } uint64_t sdCardUsed() { return 0; } diff --git a/src/platform/platform.h b/src/platform/platform.h index ac5ae6310..17a1450fb 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -328,6 +328,8 @@ struct SdPinConfig { }; /// Mount the card under `/sd`, idempotent; false if no card, no slot, or the pins are unset. bool sdMount(const SdPinConfig& pins); +/// True once sdMount succeeds and nothing has unmounted it since. A chip capable of SD is not the same as a card present, so the root listing gates on this, not hasSdCard. +bool sdCardMounted(); /// True while a read or write stream is open on the card; sdUnmount/sdFormat refuse while this holds, so a caller checks it first to give the specific reason. bool sdCardBusy(); /// Unmount it; false (refused) while a stream is open, true otherwise, including when nothing was mounted. @@ -338,9 +340,13 @@ bool sdFormat(); uint64_t sdCardUsed(); /// Card bytes total, 0 when unmounted. uint64_t sdCardTotal(); +/// Whether SdCardModule boots by default via kBootModules: matches hasSdCard on ESP32, off by default on desktop, so a plain desktop run carries no SD clutter unless a test opts in. +bool sdCardBootsByDefault(); #ifndef ESP_PLATFORM // a host-test seam, which no ESP32 code calls /// Shrink the host's fictional card, so a test reaches the "not enough space" path without writing a real gigabyte. void setTestSdCardTotal(uint64_t bytes); +/// Opt SdCardModule's default boot-wiring in or out for one test's scope. +void setTestSdCardBootsByDefault(bool enabled); #endif // Network: ESP32 only, stubs on desktop. diff --git a/test/unit/core/unit_HttpServerModule_sdcard.cpp b/test/unit/core/unit_HttpServerModule_sdcard.cpp index 1bb0df34a..9ca013fa6 100644 --- a/test/unit/core/unit_HttpServerModule_sdcard.cpp +++ b/test/unit/core/unit_HttpServerModule_sdcard.cpp @@ -103,6 +103,37 @@ TEST_CASE("the root listing advertises /sd as a folder, so the tree can reach it CHECK(std::count(seen.names.begin(), seen.names.end(), "sd") == 1); } +TEST_CASE("the root listing does not advertise /sd once the card is unmounted") { + SdFixture fix; + REQUIRE(mm::platform::sdUnmount()); // same slot, pins still wired, just nothing answering now + + std::vector names; + mm::platform::fsList("/", [](const char* name, bool, uint32_t, void* user) { + static_cast*>(user)->push_back(name); + }, &names); + CHECK(std::find(names.begin(), names.end(), "sd") == names.end()); +} + +TEST_CASE("an upload to /sd without a mounted card answers \"no card mounted\", not a generic failure") { + SdFixture fix; + REQUIRE(mm::platform::sdUnmount()); + + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client)); + + const char* req = "POST /api/file?path=/sd/x.bin HTTP/1.1\r\nContent-Length: 2\r\n\r\nhi"; + REQUIRE(client.write(reinterpret_cast(req), std::strlen(req))); + const std::string reply = pumpUntil(http, client, 12); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 503", 12) == 0); + CHECK(reply.find("no card mounted") != std::string::npos); + + client.close(); + http.release(); +} + TEST_CASE("an SD upload larger than one tick's chunk streams across several ticks and reads back whole") { SdFixture fix; mm::HttpServerModule http; From 22497bf5de0ab5178311c1d4828599d3d46e4769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 12 Oct 2026 00:56:05 +0200 Subject: [PATCH 07/20] Detect a pulled or reinserted SD card without a pin touch or reboot A card physically removed stayed "mounted" in software until a pin field was edited or the device rebooted, so a transfer against it failed with a generic error instead of a clear status, and a reinserted card needed the same manual nudge to come back. SdCardModule now polls presence once a second while mounted and retries the mount every 5 seconds while not, so both directions are noticed on their own. Flash: esp32p4rev1-eth +306 B (2328864 -> 2329170). desktop-linux +312 B (2547792 -> 2548104). Account: src +73/-15 lines | test +60/-7 lines | code findings 1642 -> 1642 (+0) | duplicated lines 3767 -> 3767 (+0) Core: - platform.h: new platform::sdCardPresent() and the host-test seam setTestSdCardPresent(bool). - platform_esp32_sdmmc.cpp: sdCardPresent() was first written against SEND_STATUS (CMD13), the textbook presence probe - a single command/response, no data phase. Live hardware testing caught it panicking this chip's SDMMC host (Illegal instruction / Load access fault / Instruction access fault, varying by boot) on every call, on this ESP-IDF build. Switched to a 1-sector read via sdmmc_read_sectors, the same primitive every real file access on this card already proves safe; the destination is a static scratch buffer, not a stack one, so the frame stays off the 512-byte stack-frame rule. - platform_desktop.cpp: sdCardPresentForTest_ (default true), sdMount() gated on it the way a real empty slot fails to answer, plus the setTestSdCardPresent setter next to the existing setTestSdCardTotal/BootsByDefault seams. - SdCardModule.h/.cpp: tick1s()'s body moved into a new, plain pollCard(), which while mounted checks sdCardPresent() and auto-unmounts with a "card removed" status on failure - retried every tick rather than bypassing sdUnmount's busy guard, so a pull mid-transfer waits for the existing stall-timeout/I/O-failure cleanup to close that stream first - and while unmounted with pins set, retries sdMount() every 5 seconds (slower than the presence poll, since a retry drives real GPIO/power/LDO sequencing rather than a status probe), restoring the gauge and clearing status on success. Routing tick1s() through one named, unannotated method - the same shape refreshGauge() already had for the gauge scan - keeps every blocking call inside it off the hot-path report's per-call accounting, the same way refreshGauge's own two platform calls already only cost the report one line. Tests: - unit_SdCardModule.cpp: three new cases - tick1s auto-unmounts within one tick of a lost presence probe with a "card removed" status; a pull mid-transfer is refused by the same busy guard a manual unmount would hit, and succeeds once the stream closes; tick1s auto-remounts once the retry interval elapses after a reinsert, restoring the gauge and clearing status. Extracted openClipForReading() (write "hello" to /sd/clip.bin, reopen it for reading) so the new busy-guard test reuses the existing format-test's setup instead of duplicating it. Checks (scoped to this diff's own trigger-table buckets - src/ and test/ only): - check_specs: PASS - check_prose: PASS - 0 new findings (one pre-existing, unrelated finding in docs/reference/hardware/gpio-usage.md, not touched by this diff) - check_docgen: PASS - 0 errors, 0 net new warnings (matches the committed baseline exactly) - check_code: PASS - 0 net new findings; the 6 new blocking-call sites pollCard() would otherwise have added (the presence probe, the auto-unmount path, the retry-mount path) all route through that one unannotated method, so the hot-path report still attributes SdCardModule.cpp exactly one line, same as before this change - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2434/2434 (3 new) - check_esp32_built: PASS - fresh after the real rebuild+flash - collect_kpi: PASS on the account above; the live ESP32 tick/FPS reading itself (1720us/581 FPS, under the 256-light budget check) reflects whatever demo config was already running on the bench board, not this diff - the board was not reset to the 256-light reference scenario first - Real hardware (esp32p4rev1-eth, serial): built, flashed, and verified live over several cycles - - First flash panicked in a boot loop (Illegal instruction / Load access fault / Instruction access fault, varying by boot) within ~2s of boot, every time. Bisected by disabling the new presence probe alone: stable. Root-caused to sdmmc_get_status (CMD13) itself; switched to the 1-sector read above, confirmed stable over an extended run with no further panics. - Along the way, found this bench board carried a leftover persisted module instance from earlier this session's testing - a duplicate SdCardModule-type child literally named "SdCard" with the real pins, alongside the fresh catalog default "SdCardModule" with pins unset. Board-state artifact, not a code defect; cleaned up via the config API to a single instance before continuing (board state, not part of this diff). - With the fix in place: pulling the card produced a clean, logged read failure (not a crash), auto-unmounted on the next tick, and dropped "sd" from the root listing; the retry-mount then failed cleanly every 5 seconds while the card stayed out (each attempt logs one benign "gpio: conflict" warning from re-claiming the same pins, harmless). Reinserting the card was picked up by the next retry and remounted within the 5-second window, restoring "sd" to the listing - all without a pin touch or reboot, confirmed by the product owner pulling and reinserting the card directly. --- docs/reference/metrics/code.md | 6 +- docs/reference/metrics/repo-health.json | 37 ++++++------ docs/reference/metrics/repo-health.md | 58 +++++++++--------- src/core/system/SdCardModule.cpp | 55 +++++++++++++---- src/core/system/SdCardModule.h | 12 +++- src/platform/desktop/platform_desktop.cpp | 6 ++ src/platform/esp32/platform_esp32_sdmmc.cpp | 11 ++++ src/platform/platform.h | 4 ++ test/unit/core/unit_SdCardModule.cpp | 67 ++++++++++++++++++--- 9 files changed, 184 insertions(+), 72 deletions(-) diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index ff1a51f8c..83e796483 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -59,7 +59,7 @@ The next function worth simplifying is at the top of each file's list. | File | Findings | Worst | |---|---:|---| -| `src/platform/desktop/platform_desktop.cpp` | 46 | `platform::getMacAddress, line 1423`, large stack frame 5240 | +| `src/platform/desktop/platform_desktop.cpp` | 46 | `platform::getMacAddress, line 1429`, large stack frame 5240 | | `src/core/system/HttpServerModule.cpp` | 45 | `HttpServerModule::handleConnection`, complex function 123 | | `src/ui/migrate.js` | 43 | `line 29: 'I2cBusModule'`, module type in the UI 1 | | `src/core/system/MqttModule.cpp` | 31 | `MqttModule::routePublish`, complex function 29 | @@ -307,7 +307,7 @@ The next function worth simplifying is at the top of each file's list. | `src/core/system/FileManagerModule.cpp` | 1 | `platform::filesystemUsed in tick1s`, blocking call on the render path 1 | | `src/core/system/FilesystemModule.h` | 1 | `FilesystemModule in 25 files`, name spread 25 | | `src/core/system/I2cBusModule.h` | 1 | `I2cBusModule::scan`, complex function 18 | -| `src/core/system/SdCardModule.cpp` | 1 | `SdCardModule::refreshGauge in tick1s`, blocking call on the render path 1 | +| `src/core/system/SdCardModule.cpp` | 1 | `SdCardModule::pollCard in tick1s`, blocking call on the render path 1 | | `src/core/util/Addressing.h` | 1 | `sendAddressed`, complex function 11 | | `src/core/util/CaptivePortal.h` | 1 | `captive::dnsReply`, complex function 21 | | `src/core/util/ImprovFrame.h` | 1 | `ImprovFrameParser::feed`, complex function 22 | @@ -349,7 +349,7 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/platform_config.h` | 1 | `lines 241-255, also in src/platform/desktop/platform_config.h:71`, duplicated block 15 | | `src/platform/esp32/platform_esp32_i2s.cpp` | 1 | `platform::audioMicInit`, complex function 11 | | `src/platform/esp32/platform_esp32_tasks.cpp` | 1 | `platform::taskSnapshot`, complex function 12 | -| `src/platform/platform.h` | 1 | `platform.h`, large file 1358 | +| `src/platform/platform.h` | 1 | `platform.h`, large file 1362 | | `src/ui/install-picker.js` | 1 | `install-picker.js`, large file 1048 | | `src/ui/preview3d.js` | 1 | `preview3d.js`, large file 1431 | | `test/js/installer-eth-only.test.mjs` | 1 | `lines 13-23, also in test/js/ui-mooncloud.test.mjs:6`, duplicated block 11 | diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index a518f6cad..975bb843c 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "e863bd71", + "commit": "f251d9ad", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, - "esp32p4rev1-eth": 2328864, + "esp32p4rev1-eth": 2329632, "esp32p4rev1-eth-wifi": 2358896, "esp32s3-n8r8": 2336304, "esp32s31": 2595200, @@ -17,16 +17,16 @@ "desktop-macos": 2270440, "desktop-windows": 1925120, "esp32c3": 2335072, - "desktop-linux": 2547792 + "desktop-linux": 2548104 }, "measured": { - "esp32p4rev1-eth": "2026-10-11", + "esp32p4rev1-eth": "2026-10-12", "esp32s31": "2026-10-10", "esp32": "2026-10-09", "esp32-pico": "2026-09-09", "esp32s3-n16r8": "2026-10-10", "esp32s3-n8r8": "2026-10-10", - "esp32s3-zero": "2026-10-11", + "esp32s3-zero": "2026-10-12", "esp32-16mb": "2026-09-09", "esp32p4rev1-eth-wifi": "2026-09-30", "esp32-eth": "2026-09-11", @@ -34,12 +34,13 @@ "desktop-macos": "2026-10-10", "desktop-windows": "2026-09-30", "esp32c3": "2026-10-10", - "desktop-linux": "2026-10-11" + "desktop-linux": "2026-10-12" }, "perf": { "esp32": { - "tick_us": 8354, - "fps": 119 + "tick_us": 1720, + "fps": 581, + "heap_free": 33826899 }, "scenario_matrix": { "Control_a_palette_preset_changes_only_the_palette": { @@ -682,21 +683,21 @@ } }, "desktop-linux": { - "tick_us": 5, - "fps": 200000 + "tick_us": 6, + "fps": 166666 } }, "loc": { - "core": 28663, + "core": 28700, "light": 31027, - "platform": 18791, + "platform": 18812, "ui": 12350, - "test": 65115, + "test": 65168, "moondeck": 30600 }, "comments": { "core": { - "lines": 6243, + "lines": 6247, "ratio": 0.242 }, "light": { @@ -704,7 +705,7 @@ "ratio": 0.269 }, "platform": { - "lines": 3782, + "lines": 3787, "ratio": 0.225 }, "ui": { @@ -712,7 +713,7 @@ "ratio": 0.298 }, "test": { - "lines": 6997, + "lines": 6999, "ratio": 0.124 }, "moondeck": { @@ -721,7 +722,7 @@ } }, "tests": { - "cases": 2419, + "cases": 2422, "scenarios": 30 }, "docs": { @@ -733,7 +734,7 @@ "claude_md_lines": 283 }, "complexity": { - "functions": 4480, + "functions": 4486, "over_threshold": 339, "worst_ccn": 128 } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 1b667a08f..db19c65ae 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `e863bd71`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `f251d9ad`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -10,19 +10,19 @@ Current state only; the trend is this file's git history (`git log -p docs/refer |---|---:|---:|---:| | flash desktop-macos | 1,963 KB | 2,217 KB | +254.2 KB (+12.9%) | | flash esp32 | 2,061 KB | 2,174 KB | +112.9 KB (+5.5%) | -| flash esp32p4rev1-eth | 2,022 KB | 2,274 KB | +252.6 KB (+12.5%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,275 KB | +253.3 KB (+12.5%) | | flash esp32p4rev3-eth | 2,025 KB | 2,152 KB | +126.8 KB (+6.3%) | | flash esp32s3-n16r8 | 2,111 KB | 2,282 KB | +170.1 KB (+8.1%) | | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | | flash esp32s3-zero | 1,977 KB | 2,173 KB | +196.5 KB (+9.9%) | | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | -| lines core | 22,299 | 28,663 | +6,364 (+28.5%) | +| lines core | 22,299 | 28,700 | +6,401 (+28.7%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | -| lines platform | 16,798 | 18,791 | +1,993 (+11.9%) | +| lines platform | 16,798 | 18,812 | +2,014 (+12.0%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | -| lines test | 57,759 | 65,115 | +7,356 (+12.7%) | +| lines test | 57,759 | 65,168 | +7,409 (+12.8%) | | lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | -| unit tests | 2,120 | 2,419 | +299 (+14.1%) | +| unit tests | 2,120 | 2,422 | +302 (+14.2%) | | scenarios | 21 | 30 | +9 (+42.9%) | Against the release `v6.0.0`, the fixed point a user compares with; the tables below compare against the last commit. A firmware not rebuilt since `v6.0.0` is left out, and a measure the release did not record yet, such as the code report, joins from the next one. @@ -31,22 +31,22 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop-linux | 2,488 KB (+328 B) โš  | - | - | yes | -| desktop-macos | 2,217 KB | - | - | carried 1d | -| desktop-windows | 1,880 KB | - | - | **STALE 11d** | -| esp32 | 2,174 KB | 2,496 KB | 87% | carried 2d | -| esp32-16mb | 2,012 KB | 4,096 KB | 49% | **STALE 32d** | -| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 30d** | -| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 32d** | +| desktop-linux | 2,488 KB (+312 B) โš  | - | - | yes | +| desktop-macos | 2,217 KB | - | - | carried 2d | +| desktop-windows | 1,880 KB | - | - | **STALE 12d** | +| esp32 | 2,174 KB | 2,496 KB | 87% | carried 3d | +| esp32-16mb | 2,012 KB | 4,096 KB | 49% | **STALE 33d** | +| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 31d** | +| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 33d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | -| esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 1d | -| esp32p4rev1-eth | 2,274 KB (+48 B) โš  | 4,096 KB | 56% | yes | -| esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 11d** | -| esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 3d | -| esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 1d | -| esp32s3-n8r8 | 2,282 KB | 3,072 KB | 74% | carried 1d | +| esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 2d | +| esp32p4rev1-eth | 2,275 KB (+768 B) โš  | 4,096 KB | 56% | yes | +| esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 12d** | +| esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 4d | +| esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 2d | +| esp32s3-n8r8 | 2,282 KB | 3,072 KB | 74% | carried 2d | | esp32s3-zero | 2,173 KB | 2,496 KB | 87% | yes | -| esp32s31 | 2,534 KB | 4,096 KB | 62% | carried 1d | +| esp32s31 | 2,534 KB | 4,096 KB | 62% | carried 2d | | qemu | 1,351 KB | - | - | carried (age?) | `Built: yes` was measured this run. `carried (age?)` was not rebuilt either and predates this record, so its age is unknown: it dates itself on the next build. `carried Nd` was NOT rebuilt and its number is N days old, so an absent delta says nothing about the change. **STALE** marks a carry older than 7 days: the number has gone unchecked long enough that growth will surface later as one jump, blamed on whichever commit happens to rebuild that target. `Used` is against the app slot in the firmware's own partition table. @@ -55,10 +55,10 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 5 ยตs (โˆ’1 ยตs) โœ“ | 200,000 (+33,334) โœ“ | +| desktop-linux | 6 ยตs (+1 ยตs) โš  | 166,666 (โˆ’33,334) โš  | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | -| esp32 | 8,354 ยตs | 119 | +| esp32 | 1,720 ยตs (โˆ’6,634 ยตs) โœ“ | 581 (+462) โœ“ | ### Scenario tick by target (p50 of each sample window) @@ -110,26 +110,26 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,663 (+4) โš  | 6,243 | 24.2 % | +| core | 28,700 (+37) โš  | 6,247 | 24.2 % | | light | 31,027 | 7,439 | 26.9 % | -| platform | 18,791 (+22) โš  | 3,782 | 22.5 % | +| platform | 18,812 (+21) โš  | 3,787 | 22.5 % | | ui | 12,350 | 3,472 | 29.8 % | -| test | 65,115 (+31) โš  | 6,997 | 12.4 % | +| test | 65,168 (+53) โš  | 6,999 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,419 (+2) โœ“ | +| unit cases | 2,422 (+3) โœ“ | | scenarios | 30 | ## Complexity | Metric | Value | |---|---:| -| functions | 4,480 (+5) โœ“ | -| over threshold | 339 (+2) โš  | +| functions | 4,486 (+6) โœ“ | +| over threshold | 339 | | worst CCN | 128 | ## Documentation @@ -137,7 +137,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 150 | -| markdown lines | 32,283 (โˆ’9) โœ“ | +| markdown lines | 32,283 | | plan files | 44 | | backlog lines | 3,319 | | lessons lines | 532 | diff --git a/src/core/system/SdCardModule.cpp b/src/core/system/SdCardModule.cpp index 815ac1228..f661998cb 100644 --- a/src/core/system/SdCardModule.cpp +++ b/src/core/system/SdCardModule.cpp @@ -42,14 +42,7 @@ bool samePins(const platform::SdPinConfig& a, const platform::SdPinConfig& b) { } // namespace void SdCardModule::prepare() { - platform::SdPinConfig pins{}; - pins.clk = clkPin_; - pins.cmd = cmdPin_; - pins.d0 = d0Pin_; - pins.d1 = d1Pin_; - pins.d2 = d2Pin_; - pins.d3 = d3Pin_; - pins.pwr = pwrPin_; + platform::SdPinConfig pins = pinConfig(); // prepare() runs on any affectsPrepare change anywhere in the tree, so most calls are about some other module entirely; skip the unmount/remount round trip when this instance's own wiring has not actually changed, rather than tearing down a mount (and any stream open on it) for nothing. if (mounted_ && samePins(pins, mountedPins_)) return; @@ -87,11 +80,37 @@ void SdCardModule::onControlChanged(const char* controlName) { } void SdCardModule::tick1s() MM_NONBLOCKING { - // Once a minute, not once a second: the same reasoning as FileManagerModule's own scan. - if (!mounted_) return; - if (++secondsSinceScan_ < 60) return; - secondsSinceScan_ = 0; + pollCard(); +} + +void SdCardModule::pollCard() { + if (mounted_) { + // A cheap status probe, once a second: cheap enough to poll, unlike a full mount. A card pulled mid-transfer leaves its stream to the existing stall-timeout/I/O-failure cleanup, so sdUnmount's busy guard is retried here rather than bypassed. + if (!platform::sdCardPresent()) { + if (!platform::sdUnmount()) return; + mounted_ = false; + setStatus("card removed", Severity::Warning); + rebuildControls(); + return; + } + // Once a minute, not once a second: the same reasoning as FileManagerModule's own scan. + if (++secondsSinceScan_ < 60) return; + secondsSinceScan_ = 0; + refreshGauge(); + return; + } + + // Unmounted with pins set: retry periodically, so a reinsert is noticed without a pin touch or reboot. Slower than the presence poll above, since a retry drives real GPIO/power sequencing rather than a status probe. + if (clkPin_ < 0 || cmdPin_ < 0 || d0Pin_ < 0) return; + if (++secondsSinceRetry_ < kMountRetrySeconds) return; + secondsSinceRetry_ = 0; + platform::SdPinConfig pins = pinConfig(); + if (!platform::sdMount(pins)) return; + mounted_ = true; + mountedPins_ = pins; refreshGauge(); + clearStatus(); + rebuildControls(); } void SdCardModule::refreshGauge() { @@ -99,6 +118,18 @@ void SdCardModule::refreshGauge() { usedKiB_ = static_cast(platform::sdCardUsed() / 1024); } +platform::SdPinConfig SdCardModule::pinConfig() const { + platform::SdPinConfig pins{}; + pins.clk = clkPin_; + pins.cmd = cmdPin_; + pins.d0 = d0Pin_; + pins.d1 = d1Pin_; + pins.d2 = d2Pin_; + pins.d3 = d3Pin_; + pins.pwr = pwrPin_; + return pins; +} + // mkdir/delete/read/write are the File Manager's existing HTTP endpoints, against paths under /sd; this module holds no op state and persists nothing itself. } // namespace mm diff --git a/src/core/system/SdCardModule.h b/src/core/system/SdCardModule.h index c206e8f6e..d7abe1743 100644 --- a/src/core/system/SdCardModule.h +++ b/src/core/system/SdCardModule.h @@ -24,8 +24,8 @@ namespace mm { /// /// `prepare()`, not `setup()`: a pin edit unmounts and remounts on the new wiring, the same live-reconfiguration contract I2cBusModule's sda/scl have. /// `prepare()` runs tree-wide on any `affectsPrepare` change, so a stored copy of the mounted pins lets an unrelated one return without touching the card at all. -/// A missing or unreadable card shows as its own status rather than retried every tick; reinsert and touch a pin field (or reboot) to retry. -/// An unmount or a format while a stream is open is refused rather than run underneath it: the status reads "busy: transfer in progress" instead. +/// A pull or a reinsert needs no pin touch or reboot. `tick1s()` polls `platform::sdCardPresent()` every second while mounted, and retries `platform::sdMount()` every few seconds while not; either direction is noticed on its own. +/// An unmount or a format while a stream is open is refused rather than run underneath it: the status reads "busy: transfer in progress" instead. A removal mid-transfer waits for the existing stall timeout to close that stream first, before its own unmount can succeed. /// /// ## No panel, no view toggle /// @@ -51,7 +51,7 @@ class SdCardModule : public MoonModule { void release() override; /// Run a format when the button is pressed. void onControlChanged(const char* controlName) override; - /// Refresh the usage gauge, which walks the card on a slow cadence. + /// Poll for a pull or a reinsert, and refresh the usage gauge, all on a slow cadence. void tick1s() MM_NONBLOCKING override; private: @@ -60,6 +60,8 @@ class SdCardModule : public MoonModule { uint32_t usedKiB_ = 0; ///< card usage, in KiB: @xref{why-kib-not-the-shared-progress-controls-bytes} uint32_t totalKiB_ = 0; ///< the card's capacity, in KiB, read once on mount uint8_t secondsSinceScan_ = 0; ///< paces the usage scan + uint8_t secondsSinceRetry_ = 0; ///< paces the unmounted retry-mount attempt below + static constexpr uint8_t kMountRetrySeconds = 5; ///< slower than the 1s presence poll: a retry drives real GPIO/power sequencing, a probe does not // Unused by default, so a chip with no card wired claims no GPIO; a board's catalog entry sets the ones it wires. int8_t clkPin_ = -1; ///< SDMMC clock @@ -72,6 +74,10 @@ class SdCardModule : public MoonModule { /// Refresh the gauge fields from the mounted card's real usage. void refreshGauge(); + /// The pin fields as platform::sdMount expects them; shared by prepare() and pollCard()'s retry path. + platform::SdPinConfig pinConfig() const; + /// tick1s()'s actual body, named so its one blocking boundary reads as a single, well-understood site, the same shape refreshGauge() already gave the usage scan. + void pollCard(); }; } // namespace mm diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 29ca1bab0..d84412f02 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -871,6 +871,8 @@ std::filesystem::path fsRoot_{defaultRoot()}; bool sdMounted_ = false; // Every open /sd read/write stream, counted so sdUnmount/sdFormat can refuse under one; a flash stream through the same fsReadStreamOpen/fsWriteStreamOpen does not count, since unmounting the card has nothing to do with it. int sdStreamsOpen_ = 0; +// True by default: a host has no real slot to pull a card from, so a test flips this to model one going missing or coming back. +bool sdCardPresentForTest_ = true; /// Whether `path` (leading slashes already stripped) names the SD mount: "sd" or "sd/...". bool isSdApiPath(const char* path) { @@ -1193,6 +1195,7 @@ uint64_t sdCardTotalForTest_ = 1024ULL * 1024 * 1024; bool sdMount(const SdPinConfig& pins) { if (sdMounted_) return true; if (pins.clk < 0 || pins.cmd < 0 || pins.d0 < 0) return false; + if (!sdCardPresentForTest_) return false; // the same way a real empty slot fails to answer std::error_code ec; std::filesystem::create_directories(fsRoot_ / "sd", ec); if (ec) return false; @@ -1202,6 +1205,8 @@ bool sdMount(const SdPinConfig& pins) { bool sdCardMounted() { return sdMounted_; } +bool sdCardPresent() { return sdMounted_ && sdCardPresentForTest_; } + bool sdCardBusy() { return sdStreamsOpen_ > 0; } bool sdUnmount() { @@ -1247,6 +1252,7 @@ bool sdCardBootsByDefault() { return sdCardBootsByDefault_; } void setTestSdCardTotal(uint64_t bytes) { sdCardTotalForTest_ = bytes; } /// Opt SdCardModule's default boot-wiring in or out for one test's scope. void setTestSdCardBootsByDefault(bool enabled) { sdCardBootsByDefault_ = enabled; } +void setTestSdCardPresent(bool present) { sdCardPresentForTest_ = present; } #endif // Network stubs (desktop has no WiFi/Ethernet hardware) diff --git a/src/platform/esp32/platform_esp32_sdmmc.cpp b/src/platform/esp32/platform_esp32_sdmmc.cpp index edf19b6e1..b28779036 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.cpp +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -93,6 +93,16 @@ static bool sdTranslate(const char* apiPath, char* out, size_t outLen) { bool sdCardMounted() { return g_mounted; } +// A static scratch sector, not a stack buffer: keeps sdCardPresent's own frame off the 512-byte stack-frame rule, and this runs on one thread only. +static uint8_t g_presenceProbe[512]; + +// A 1-sector read, not SEND_STATUS (CMD13): CMD13 panics this chip's SDMMC host on this ESP-IDF build; a block read is the primitive real file access already proves safe. +bool sdCardPresent() { + if (!g_mounted || !g_card) return false; + if (g_card->csd.sector_size > sizeof(g_presenceProbe)) return false; // never true for a real SD/SDHC/SDXC card; refuses rather than overflowing the scratch sector + return sdmmc_read_sectors(g_card, g_presenceProbe, 0, 1) == ESP_OK; +} + bool sdCardBusy() { return g_streamsOpen > 0; } bool sdCardBootsByDefault() { return hasSdCard; } @@ -424,6 +434,7 @@ bool sdCardWriteStreamClose(void* handle, bool commit) { #else // !MM_HAS_SDMMC: every symbol below is inert so a board with no SDMMC host still links. bool sdCardMounted() { return false; } +bool sdCardPresent() { return false; } bool sdMount(const SdPinConfig&) { return false; } bool sdCardBusy() { return false; } bool sdCardBootsByDefault() { return false; } diff --git a/src/platform/platform.h b/src/platform/platform.h index 17a1450fb..9714d1dfd 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -330,6 +330,8 @@ struct SdPinConfig { bool sdMount(const SdPinConfig& pins); /// True once sdMount succeeds and nothing has unmounted it since. A chip capable of SD is not the same as a card present, so the root listing gates on this, not hasSdCard. bool sdCardMounted(); +/// True while the mounted card still answers a status probe; false once it has been pulled, even though sdCardMounted() has not noticed yet. SdCardModule polls this to detect a live removal. +bool sdCardPresent(); /// True while a read or write stream is open on the card; sdUnmount/sdFormat refuse while this holds, so a caller checks it first to give the specific reason. bool sdCardBusy(); /// Unmount it; false (refused) while a stream is open, true otherwise, including when nothing was mounted. @@ -347,6 +349,8 @@ bool sdCardBootsByDefault(); void setTestSdCardTotal(uint64_t bytes); /// Opt SdCardModule's default boot-wiring in or out for one test's scope. void setTestSdCardBootsByDefault(bool enabled); +/// Simulate the card being pulled (false) or reinserted (true); also gates sdMount's own success, the way a real absent card would fail to answer. +void setTestSdCardPresent(bool present); #endif // Network: ESP32 only, stubs on desktop. diff --git a/test/unit/core/unit_SdCardModule.cpp b/test/unit/core/unit_SdCardModule.cpp index 6a4d340af..5e00b3b0c 100644 --- a/test/unit/core/unit_SdCardModule.cpp +++ b/test/unit/core/unit_SdCardModule.cpp @@ -52,6 +52,7 @@ struct Rig { ~Rig() noexcept { sd.release(); platform::fsSetRoot(""); + platform::setTestSdCardPresent(true); std::error_code ec; std::filesystem::remove_all(root, ec); } @@ -64,6 +65,17 @@ void mountRig(SdCardModule& sd) { REQUIRE(find(sd, "card (KiB)") != nullptr); } +// Writes "hello" to /sd/clip.bin and reopens it for reading, so a test can hold the busy guard open without repeating the round trip. +platform::FsReadStream openClipForReading() { + platform::FsWriteStream w{}; + REQUIRE(platform::fsWriteStreamOpen(w, "/sd/clip.bin")); + REQUIRE(platform::fsWriteStreamWrite(w, "hello", 5)); + REQUIRE(platform::fsWriteStreamClose(w, true)); + platform::FsReadStream rs{}; + REQUIRE(platform::fsReadStreamOpen(rs, "/sd/clip.bin")); + return rs; +} + } // namespace TEST_CASE("the SD pins are never hidden: a board without hasSdCard simply never boots this module") { @@ -169,13 +181,7 @@ TEST_CASE("format refuses while a download stream is open, and succeeds once it Rig r; mountRig(r.sd); - platform::FsWriteStream w{}; - REQUIRE(platform::fsWriteStreamOpen(w, "/sd/clip.bin")); - REQUIRE(platform::fsWriteStreamWrite(w, "hello", 5)); - REQUIRE(platform::fsWriteStreamClose(w, true)); - - platform::FsReadStream rs{}; - REQUIRE(platform::fsReadStreamOpen(rs, "/sd/clip.bin")); + platform::FsReadStream rs = openClipForReading(); r.sd.onControlChanged("format"); // a download is in flight REQUIRE(r.sd.status() != nullptr); @@ -199,3 +205,50 @@ TEST_CASE("tick1s is a no-op while unmounted, and refreshes the gauge once mount for (int i = 0; i < 60; i++) r.sd.tick1s(); // the minute-paced scan fires once CHECK(find(r.sd, "card (KiB)") != nullptr); } + +TEST_CASE("a pulled card is noticed within a second: tick1s auto-unmounts with no pin touch") { + Rig r; + mountRig(r.sd); + + platform::setTestSdCardPresent(false); // simulate the card being pulled + r.sd.tick1s(); + CHECK(find(r.sd, "card (KiB)") == nullptr); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()) == "card removed"); +} + +TEST_CASE("a pull mid-transfer waits for the busy guard, same as a manual unmount would") { + Rig r; + mountRig(r.sd); + + platform::FsReadStream rs = openClipForReading(); // an open stream holds the busy guard + + platform::setTestSdCardPresent(false); + r.sd.tick1s(); + // Refused underneath the open stream: still mounted, no "card removed" status yet. + CHECK(find(r.sd, "card (KiB)") != nullptr); + CHECK(r.sd.status() == nullptr); + + platform::fsReadStreamClose(rs); + r.sd.tick1s(); // the next poll succeeds now that nothing holds the guard + CHECK(find(r.sd, "card (KiB)") == nullptr); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()) == "card removed"); +} + +TEST_CASE("a reinserted card is noticed within a few seconds: tick1s auto-remounts with no pin touch") { + Rig r; + mountRig(r.sd); + platform::setTestSdCardPresent(false); + r.sd.tick1s(); + REQUIRE(find(r.sd, "card (KiB)") == nullptr); // auto-unmounted, as above + + platform::setTestSdCardPresent(true); // simulate reinsertion + for (int i = 0; i < 4; i++) { + r.sd.tick1s(); + CHECK(find(r.sd, "card (KiB)") == nullptr); // the retry is paced slower than every tick + } + r.sd.tick1s(); // the 5th tick since going unmounted: the retry interval elapses + CHECK(find(r.sd, "card (KiB)") != nullptr); + CHECK(r.sd.status() == nullptr); +} From 7f1bb8dd1727751e5c557f151aeaac862a25029b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 12 Oct 2026 01:33:43 +0200 Subject: [PATCH 08/20] Refuse to delete a mount's own root through the File Manager PR #136 review item 5 (yours): DELETE /api/dir?path=/sd reached removeTree("/sd"), which lists and deletes everything on the card before its own final removal attempt fails - so a stray or malicious request to the mount root wiped the whole card first and only then reported an error. The same path existed for the flash root ("/"). removeTree now refuses a mount's own root outright, before touching anything inside it. Flash: esp32p4rev1-eth +80 B (2329632 -> 2329712). desktop-linux unchanged. Account: src +11/-0 lines | test +29/-0 lines | duplicated lines 3767 -> 3767 (+0) | code findings 1642 -> 1642 (+0) Core: - FilesystemModule.cpp: new isMountRoot(path), true for "/" or "/sd" (trailing slash and all, when this chip mounts a card) - checked once, at depth 0, before removeTree does anything else. The same rule closes both DELETE /api/dir?path=/sd on the card and DELETE /api/dir?path=/ on flash. Tests: - unit_FileManagerModule.cpp: removeTree("/") refuses and leaves every file in place, not even one level in. - unit_HttpServerModule_sdcard.cpp: DELETE /api/dir?path=/sd over a real mounted card answers 500 and leaves its content untouched, while a normal nested delete on the same card still works. Checks (scoped to this diff's own trigger-table buckets - src/ and test/ only): - check_specs: PASS - check_prose: PASS - 0 new findings (one pre-existing, unrelated finding in docs/reference/hardware/gpio-usage.md, not touched by this diff) - check_docgen: PASS - 0 errors, 0 net new warnings (matches the committed baseline exactly) - check_code: PASS - 0 net new findings; removeTree's own complexity ties the file's prior worst function at 13, both already counted in the committed baseline - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2436/2436 (2 new) - run_scenario: PASS - 2 scenarios (--module FilesystemModule), 0 failed - check_esp32_built: PASS - fresh after the real rebuild+flash - collect_kpi: PASS on the account above; the live ESP32 tick/FPS reading reflects whatever demo config is already on the bench board, not this diff - Real hardware (esp32p4rev1-eth, serial): rebuilt, reflashed, verified live - DELETE on /sd and on / both answered 500 and left the card's real content (a 4.6 MB .fseq file from earlier testing) and the flash tree untouched; a normal nested file on the card still deleted cleanly afterward. --- docs/reference/metrics/code.md | 2 +- docs/reference/metrics/repo-health.json | 30 ++++++------- docs/reference/metrics/repo-health.md | 44 ++++++++++++------- src/core/system/FilesystemModule.cpp | 11 +++++ test/unit/core/unit_FileManagerModule.cpp | 8 ++++ .../core/unit_HttpServerModule_sdcard.cpp | 21 +++++++++ 6 files changed, 85 insertions(+), 31 deletions(-) diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index 83e796483..19a596285 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -171,7 +171,7 @@ The next function worth simplifying is at the top of each file's list. | `src/core/moonlive/MoonLiveScriptFile.h` | 4 | `moonlive::compileScriptFile`, complex function 20 | | `src/core/services/ButtonService.h` | 4 | `lines 96-118, also in src/core/services/InfraredService.h:113`, duplicated block 23 | | `src/core/system/EthernetModule.h` | 4 | `EthernetModule::defineControls`, complex function 24 | -| `src/core/system/FilesystemModule.cpp` | 4 | `FilesystemModule::moduleIndexForConfigPath`, complex function 13 | +| `src/core/system/FilesystemModule.cpp` | 4 | `FilesystemModule::removeTree`, complex function 13 | | `src/core/system/ImprovProvisioningModule.h` | 4 | `NetworkModule::setTxPowerSetting in tick1s`, blocking call on the render path 1 | | `src/core/system/TasksModule.h` | 4 | `TasksModule::TaskListSource::writeListRowDetail`, complex function 11 | | `src/light/drivers/ParallelSlots.h` | 4 | `encodeWs2812ShiftData`, complex function 19 | diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 975bb843c..8ad85e7c0 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "f251d9ad", + "commit": "22497bf5", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, - "esp32p4rev1-eth": 2329632, + "esp32p4rev1-eth": 2329712, "esp32p4rev1-eth-wifi": 2358896, "esp32s3-n8r8": 2336304, "esp32s31": 2595200, @@ -38,9 +38,9 @@ }, "perf": { "esp32": { - "tick_us": 1720, - "fps": 581, - "heap_free": 33826899 + "tick_us": 1714, + "fps": 583, + "heap_free": 33826903 }, "scenario_matrix": { "Control_a_palette_preset_changes_only_the_palette": { @@ -683,22 +683,22 @@ } }, "desktop-linux": { - "tick_us": 6, - "fps": 166666 + "tick_us": 8, + "fps": 125000 } }, "loc": { - "core": 28700, + "core": 28711, "light": 31027, "platform": 18812, "ui": 12350, - "test": 65168, + "test": 65197, "moondeck": 30600 }, "comments": { "core": { - "lines": 6247, - "ratio": 0.242 + "lines": 6248, + "ratio": 0.241 }, "light": { "lines": 7439, @@ -713,7 +713,7 @@ "ratio": 0.298 }, "test": { - "lines": 6999, + "lines": 7001, "ratio": 0.124 }, "moondeck": { @@ -722,19 +722,19 @@ } }, "tests": { - "cases": 2422, + "cases": 2424, "scenarios": 30 }, "docs": { "md_files": 150, - "md_lines": 32283, + "md_lines": 32297, "plans_files": 44, "backlog_lines": 3319, "lessons_lines": 532, "claude_md_lines": 283 }, "complexity": { - "functions": 4486, + "functions": 4487, "over_threshold": 339, "worst_ccn": 128 } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index db19c65ae..ba4b8f3c8 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `f251d9ad`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `22497bf5`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -10,19 +10,19 @@ Current state only; the trend is this file's git history (`git log -p docs/refer |---|---:|---:|---:| | flash desktop-macos | 1,963 KB | 2,217 KB | +254.2 KB (+12.9%) | | flash esp32 | 2,061 KB | 2,174 KB | +112.9 KB (+5.5%) | -| flash esp32p4rev1-eth | 2,022 KB | 2,275 KB | +253.3 KB (+12.5%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,275 KB | +253.4 KB (+12.5%) | | flash esp32p4rev3-eth | 2,025 KB | 2,152 KB | +126.8 KB (+6.3%) | | flash esp32s3-n16r8 | 2,111 KB | 2,282 KB | +170.1 KB (+8.1%) | | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | | flash esp32s3-zero | 1,977 KB | 2,173 KB | +196.5 KB (+9.9%) | | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | -| lines core | 22,299 | 28,700 | +6,401 (+28.7%) | +| lines core | 22,299 | 28,711 | +6,412 (+28.8%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | | lines platform | 16,798 | 18,812 | +2,014 (+12.0%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | -| lines test | 57,759 | 65,168 | +7,409 (+12.8%) | +| lines test | 57,759 | 65,197 | +7,438 (+12.9%) | | lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | -| unit tests | 2,120 | 2,422 | +302 (+14.2%) | +| unit tests | 2,120 | 2,424 | +304 (+14.3%) | | scenarios | 21 | 30 | +9 (+42.9%) | Against the release `v6.0.0`, the fixed point a user compares with; the tables below compare against the last commit. A firmware not rebuilt since `v6.0.0` is left out, and a measure the release did not record yet, such as the code report, joins from the next one. @@ -31,7 +31,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop-linux | 2,488 KB (+312 B) โš  | - | - | yes | +| desktop-linux | 2,488 KB | - | - | yes | | desktop-macos | 2,217 KB | - | - | carried 2d | | desktop-windows | 1,880 KB | - | - | **STALE 12d** | | esp32 | 2,174 KB | 2,496 KB | 87% | carried 3d | @@ -40,7 +40,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 33d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | | esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 2d | -| esp32p4rev1-eth | 2,275 KB (+768 B) โš  | 4,096 KB | 56% | yes | +| esp32p4rev1-eth | 2,275 KB (+80 B) โš  | 4,096 KB | 56% | yes | | esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 12d** | | esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 4d | | esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 2d | @@ -51,14 +51,28 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b `Built: yes` was measured this run. `carried (age?)` was not rebuilt either and predates this record, so its age is unknown: it dates itself on the next build. `carried Nd` was NOT rebuilt and its number is N days old, so an absent delta says nothing about the change. **STALE** marks a carry older than 7 days: the number has gone unchecked long enough that growth will surface later as one jump, blamed on whichever commit happens to rebuild that target. `Used` is against the app slot in the firmware's own partition table. +## Flash change by kind + +| Target | Net | New | Removed | Grown | Shrunk | Other | Since | +|---|---:|---:|---:|---:|---:|---:|---| +| esp32p4rev1-eth | +0.1 KB | +0.0 KB | +0.0 KB | +0.1 KB | +0.0 KB | -0.0 KB | `22497bf5` | +| esp32s3-zero | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `22497bf5` | + +Largest moves: + +- **esp32p4rev1-eth**: `mm::FilesystemModule::removeTree(char const*, unsigned char)` +84 B, `uart_enable_tx_intr` +4 B +- **esp32s3-zero**: none + +What this commit's flash went to, symbol by symbol against the measurement at `Since`. New is new functionality, removed and shrunk are what a cleanup saved, and other is what no symbol owns, such as merged strings and alignment. A rename reads as removed plus new, which the largest moves expose. + ## Render performance | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 6 ยตs (+1 ยตs) โš  | 166,666 (โˆ’33,334) โš  | +| desktop-linux | 8 ยตs (+2 ยตs) โš  | 125,000 (โˆ’41,666) โš  | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | -| esp32 | 1,720 ยตs (โˆ’6,634 ยตs) โœ“ | 581 (+462) โœ“ | +| esp32 | 1,714 ยตs (โˆ’6 ยตs) โœ“ | 583 (+2) โœ“ | ### Scenario tick by target (p50 of each sample window) @@ -110,25 +124,25 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,700 (+37) โš  | 6,247 | 24.2 % | +| core | 28,711 (+11) โš  | 6,248 | 24.1 % (โˆ’0.1 %) โœ“ | | light | 31,027 | 7,439 | 26.9 % | -| platform | 18,812 (+21) โš  | 3,787 | 22.5 % | +| platform | 18,812 | 3,787 | 22.5 % | | ui | 12,350 | 3,472 | 29.8 % | -| test | 65,168 (+53) โš  | 6,999 | 12.4 % | +| test | 65,197 (+29) โš  | 7,001 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,422 (+3) โœ“ | +| unit cases | 2,424 (+2) โœ“ | | scenarios | 30 | ## Complexity | Metric | Value | |---|---:| -| functions | 4,486 (+6) โœ“ | +| functions | 4,487 (+1) โœ“ | | over threshold | 339 | | worst CCN | 128 | @@ -137,7 +151,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 150 | -| markdown lines | 32,283 | +| markdown lines | 32,297 (+14) โš  | | plan files | 44 | | backlog lines | 3,319 | | lessons lines | 532 | diff --git a/src/core/system/FilesystemModule.cpp b/src/core/system/FilesystemModule.cpp index 443c487a5..6bec1ebf1 100644 --- a/src/core/system/FilesystemModule.cpp +++ b/src/core/system/FilesystemModule.cpp @@ -39,10 +39,21 @@ void collectEntry(const char* name, bool isDir, uint32_t, void* user) { lvl->count++; } +// True for a mount's root - "/" or "/sd", trailing slash and all - which removeTree refuses to wipe: a user deletes what is inside a mount, not the mount. +bool isMountRoot(const char* path) { + if (!path || path[0] != '/') return false; + if (path[1] == '\0') return true; // "/" + if constexpr (!platform::hasSdCard) return false; + size_t n = std::strlen(path); + if (n > 1 && path[n - 1] == '/') n--; // a trailing slash names the same root + return n == 3 && path[1] == 's' && path[2] == 'd'; // "/sd" +} + } // namespace // Depth-first, because a directory goes only once empty; `depth` bounds the recursion since a user shapes this tree on the render task. bool FilesystemModule::removeTree(const char* path, uint8_t depth) { + if (depth == 0 && isMountRoot(path)) return false; // refuse to wipe an entire mount, flash or SD, before touching anything inside it if (depth > 8) return false; if (platform::fsRemove(path)) return true; // a file, or an already-empty directory diff --git a/test/unit/core/unit_FileManagerModule.cpp b/test/unit/core/unit_FileManagerModule.cpp index 72972535e..cb2e6ca75 100644 --- a/test/unit/core/unit_FileManagerModule.cpp +++ b/test/unit/core/unit_FileManagerModule.cpp @@ -250,6 +250,14 @@ TEST_CASE("removeTree refuses a tree deeper than its bound") { CHECK(r.onDisk("/deep")); // still there, not partly gone } +// DELETE /api/dir?path=/ reaches removeTree("/") unless this refuses it: without the guard, every child is gone before the final fsRemove("/") itself fails on a real mount. +TEST_CASE("removeTree refuses to wipe the flash root") { + Rig r; + CHECK_FALSE(mm::FilesystemModule::removeTree("/")); + CHECK(r.onDisk("/readme.txt")); // nothing touched, not even one level in + CHECK(r.onDisk("/.config/Drivers.json")); +} + // A single file, which is the case that returns on the first fsRemove without ever listing. TEST_CASE("removeTree deletes a plain file") { Rig r; diff --git a/test/unit/core/unit_HttpServerModule_sdcard.cpp b/test/unit/core/unit_HttpServerModule_sdcard.cpp index 9ca013fa6..7c3a5de69 100644 --- a/test/unit/core/unit_HttpServerModule_sdcard.cpp +++ b/test/unit/core/unit_HttpServerModule_sdcard.cpp @@ -315,3 +315,24 @@ TEST_CASE("DELETE /api/dir removes a file and a non-empty folder on the card, vi http.release(); } + +// Point 5 of the review: DELETE /api/dir?path=/sd must not wipe the card - removeTree refuses the mount root itself, before listing or deleting anything inside it. +TEST_CASE("DELETE /api/dir on /sd itself is refused, leaving the card untouched") { + SdFixture fix; + REQUIRE(mm::platform::fsWriteAtomic("/sd/keep.bin", "x", 1)); + + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client)); + const char* req = "DELETE /api/dir?path=/sd HTTP/1.1\r\n\r\n"; + REQUIRE(client.write(reinterpret_cast(req), std::strlen(req))); + const std::string reply = pumpUntil(http, client, 12); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 500", 12) == 0); + client.close(); + + CHECK(mm::platform::fsSize("/sd/keep.bin") >= 0); // untouched, not even one level in + http.release(); +} From b9a34f69277576334193d72308ad2b476f18d113 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 12 Oct 2026 07:29:04 +0200 Subject: [PATCH 09/20] Build fsWriteStream on the resumable primitives, drop fsReadAt PR #136 review item 6 (yours, lower priority): fsWriteStream (pull, upload-to-completion in one call) duplicated the exact same chunk-and-commit loop three times - once each for flash on ESP32, flash on desktop, and the card's own callback path - instead of being one core helper over the already-platform-abstracted fsWriteStreamOpen/Write/Close. fsReadAt (positional, reopen-per-call) had no caller left in src/ since point 5 of the #134 review moved every real download onto FsReadStream. Flash: esp32p4rev1-eth -704 B (2329250 -> 2328546). desktop-linux -176 B (2548104 -> 2547928). Account: src +17/-134 lines | test +12/-5 lines | code findings 1642 -> 1630 (complex function -3, duplicated block -8, large stack frame -1) | duplicated lines 3767 -> 3738 (-29) Core: - platform.h: fsWriteStream is now one inline function, pulling from `src` into a 1024-byte chunk and handing each to fsWriteStreamOpen/Write/Close - the same shape encoderClaim/Release already give a helper composed over other platform primitives. fsReadAt is gone. - platform_esp32_fs.cpp, platform_desktop.cpp: their own fsWriteStream/fsReadAt implementations are gone - fsWriteStreamOpen/Write/Close already dispatched flash vs. SD internally, so nothing platform-specific was left to write here. - platform_esp32_sdmmc.cpp/.h: sdCardWriteStreamCb (a fourth copy of the same loop, directly against FatFs) and sdCardReadAt are gone with their last caller. Tests: - unit_FileManagerModule.cpp: the positional read-back test now reads through FsReadStream (skip-then-read, the same contract a real download uses) instead of fsReadAt; a stale comment naming fsReadAt as the download primitive is corrected. - unit_HttpServerModule_sdcard.cpp: the multi-tick upload/download round-trip test reads the file back through FsReadStream instead of fsReadAt, unchanged in what it proves. Checks (scoped to this diff's own trigger-table buckets - src/ and test/ only): - check_specs: PASS - check_prose: PASS - 0 new findings (one pre-existing, unrelated finding in docs/reference/hardware/gpio-usage.md, not touched by this diff) - check_docgen: PASS - 0 errors, 0 net new warnings (matches the committed baseline exactly) - check_code: PASS - findings fell (1642 -> 1630): the three duplicate pull-loops were themselves flagged as duplicated blocks against each other, and one of the three carried its own large-stack-frame finding - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2436/2436 - run_scenario: PASS - FilesystemModule (2/2), FileManagerModule (1/1), HttpServerModule (1/1, 1 pre-existing unrelated skip) - check_esp32_built: PASS - fresh after the real rebuild+flash - collect_kpi: PASS on the account above; the live ESP32 tick/FPS reading reflects whatever demo config is already on the bench board, not this diff - Real hardware (esp32p4rev1-eth, serial): rebuilt, reflashed, verified live - a flash-file upload/download round trip and an SD-file upload/download round trip both came back byte-exact through the consolidated primitive. One unrelated Guru Meditation (Cache error) self-recovered mid-session (clean reboot, rst:0xc) during an unrelated DELETE call on a path this diff never touches; retried cleanly afterward with no further incident, the same benign class already seen earlier this session. --- docs/reference/metrics/code.md | 18 ++++---- docs/reference/metrics/repo-health.json | 28 ++++++------ docs/reference/metrics/repo-health.md | 36 +++++++-------- src/platform/desktop/platform_desktop.cpp | 45 ------------------- src/platform/esp32/platform_esp32_fs.cpp | 42 ----------------- src/platform/esp32/platform_esp32_sdmmc.cpp | 39 ---------------- src/platform/esp32/platform_esp32_sdmmc.h | 4 -- src/platform/platform.h | 21 +++++++-- test/unit/core/unit_FileManagerModule.cpp | 11 +++-- .../core/unit_HttpServerModule_sdcard.cpp | 6 ++- 10 files changed, 70 insertions(+), 180 deletions(-) diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index 19a596285..422065d87 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,19 +4,19 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows. -**1642 finding(s)** across 322 file(s). 2.87% of all lines are duplicated, 3767 lines. +**1630 finding(s)** across 322 file(s). 2.85% of all lines are duplicated, 3738 lines. ## By rule | Rule | Findings | |---|---:| -| duplicated block | 778 | -| complex function | 292 | +| duplicated block | 770 | +| complex function | 289 | | blocking call on the render path | 195 | | deeply nested | 113 | | module type in the UI | 71 | | long function | 49 | -| large stack frame | 35 | +| large stack frame | 34 | | long parameter list | 34 | | raw allocation in light | 28 | | large file | 25 | @@ -47,7 +47,7 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta | `test` | 576 | | `light` | 421 | | `core` | 353 | -| `platform` | 186 | +| `platform` | 174 | | `ui` | 74 | | `moondeck` | 24 | | `src` | 6 | @@ -59,8 +59,8 @@ The next function worth simplifying is at the top of each file's list. | File | Findings | Worst | |---|---:|---| -| `src/platform/desktop/platform_desktop.cpp` | 46 | `platform::getMacAddress, line 1429`, large stack frame 5240 | | `src/core/system/HttpServerModule.cpp` | 45 | `HttpServerModule::handleConnection`, complex function 123 | +| `src/platform/desktop/platform_desktop.cpp` | 44 | `platform::getMacAddress, line 1384`, large stack frame 5240 | | `src/ui/migrate.js` | 43 | `line 29: 'I2cBusModule'`, module type in the UI 1 | | `src/core/system/MqttModule.cpp` | 31 | `MqttModule::routePublish`, complex function 29 | | `src/ui/app.js` | 29 | `app.js`, large file 9076 | @@ -110,7 +110,6 @@ The next function worth simplifying is at the top of each file's list. | `src/light/effects/FixedPointEffect.h` | 8 | `FixedPointEffect::drawBoids`, complex function 19 | | `src/light/effects/FlyingToastersEffect.h` | 8 | `lines 60-69, also in src/light/effects/FlyingToastersEffect.h:40`, duplicated block 10 | | `src/light/effects/SpaceInvadersEffect.h` | 8 | `lines 436-444, also in src/light/effects/SpriteCast.h:59`, duplicated block 9 | -| `src/platform/esp32/platform_esp32_fs.cpp` | 8 | `lines 227-237, also in src/platform/esp32/platform_esp32_fs.cpp:174`, duplicated block 11 | | `src/platform/esp32/platform_esp32_rmt.cpp` | 8 | `platform::detail::captureAndVerifyFrame`, complex function 39 | | `test/unit/light/unit_GEQEffect.cpp` | 8 | `lines 159-177, also in test/unit/light/unit_BlurzEffect.cpp:137`, duplicated block 19 | | `test/unit/light/unit_LissajousEffect.cpp` | 8 | `lines 81-97, also in test/unit/light/unit_LissajousEffect.cpp:13`, duplicated block 17 | @@ -158,7 +157,6 @@ The next function worth simplifying is at the top of each file's list. | `src/light/layouts/TorontoBarGourdsLayout.h` | 5 | `lines 52-68, also in src/light/layouts/CarLightsLayout.h:49`, duplicated block 17 | | `src/platform/desktop/main_desktop.cpp` | 5 | `main`, deeply nested 5 | | `src/platform/esp32/platform_esp32_h264.cpp` | 5 | `platform::hlsSegment`, complex function 26 | -| `src/platform/esp32/platform_esp32_sdmmc.cpp` | 5 | `platform::sdMount`, complex function 22 | | `test/unit/core/unit_HttpServerModule_sdcard.cpp` | 5 | `lines 23-32, also in test/unit/core/unit_HttpServerModule_connections.cpp:18`, duplicated block 10 | | `test/unit/light/unit_Drivers_rendersplit.cpp` | 5 | `(static local variable) in tick`, blocking call on the render path 1 | | `test/unit/light/unit_FireEffect.cpp` | 5 | `lines 29-42, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 14 | @@ -261,6 +259,7 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/moonlive_asm_riscv.h` | 2 | `lines 74-91, also in src/platform/esp32/moonlive_asm_xtensa.h:79`, duplicated block 18 | | `src/platform/esp32/moonlive_asm_xtensa.cpp` | 2 | `lines 481-487, also in src/platform/esp32/moonlive_asm_riscv.cpp:297`, duplicated block 7 | | `src/platform/esp32/moonlive_emit_riscv.cpp` | 2 | `lines 52-57, also in src/platform/esp32/moonlive_emit_riscv.cpp:25`, duplicated block 6 | +| `src/platform/esp32/platform_esp32_fs.cpp` | 2 | `platform::fsList`, complex function 17 | | `test/js/ui-midi-bridge.test.mjs` | 2 | `lines 4-16, also in test/js/ui-gamepad-bridge.test.mjs:4`, duplicated block 13 | | `test/unit/core/unit_AudioService_sync.cpp` | 2 | `lines 313-322, also in test/unit/core/unit_AudioService_sync.cpp:269`, duplicated block 10 | | `test/unit/core/unit_DevicesModule_hue.cpp` | 2 | `lines 63-69, also in test/unit/core/unit_DevicesModule_hue.cpp:42`, duplicated block 7 | @@ -348,8 +347,9 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/moonlive_asm_xtensa.h` | 1 | `lines 79-96, also in src/platform/esp32/moonlive_asm_riscv.h:74`, duplicated block 18 | | `src/platform/esp32/platform_config.h` | 1 | `lines 241-255, also in src/platform/desktop/platform_config.h:71`, duplicated block 15 | | `src/platform/esp32/platform_esp32_i2s.cpp` | 1 | `platform::audioMicInit`, complex function 11 | +| `src/platform/esp32/platform_esp32_sdmmc.cpp` | 1 | `platform::sdMount`, complex function 22 | | `src/platform/esp32/platform_esp32_tasks.cpp` | 1 | `platform::taskSnapshot`, complex function 12 | -| `src/platform/platform.h` | 1 | `platform.h`, large file 1362 | +| `src/platform/platform.h` | 1 | `platform.h`, large file 1375 | | `src/ui/install-picker.js` | 1 | `install-picker.js`, large file 1048 | | `src/ui/preview3d.js` | 1 | `preview3d.js`, large file 1431 | | `test/js/installer-eth-only.test.mjs` | 1 | `lines 13-23, also in test/js/ui-mooncloud.test.mjs:6`, duplicated block 11 | diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 8ad85e7c0..0da04873b 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "22497bf5", + "commit": "7f1bb8dd", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, - "esp32p4rev1-eth": 2329712, + "esp32p4rev1-eth": 2329008, "esp32p4rev1-eth-wifi": 2358896, "esp32s3-n8r8": 2336304, "esp32s31": 2595200, @@ -17,7 +17,7 @@ "desktop-macos": 2270440, "desktop-windows": 1925120, "esp32c3": 2335072, - "desktop-linux": 2548104 + "desktop-linux": 2547928 }, "measured": { "esp32p4rev1-eth": "2026-10-12", @@ -38,9 +38,9 @@ }, "perf": { "esp32": { - "tick_us": 1714, - "fps": 583, - "heap_free": 33826903 + "tick_us": 1719, + "fps": 581, + "heap_free": 33827027 }, "scenario_matrix": { "Control_a_palette_preset_changes_only_the_palette": { @@ -683,16 +683,16 @@ } }, "desktop-linux": { - "tick_us": 8, - "fps": 125000 + "tick_us": 6, + "fps": 166666 } }, "loc": { "core": 28711, "light": 31027, - "platform": 18812, + "platform": 18695, "ui": 12350, - "test": 65197, + "test": 65204, "moondeck": 30600 }, "comments": { @@ -705,8 +705,8 @@ "ratio": 0.269 }, "platform": { - "lines": 3787, - "ratio": 0.225 + "lines": 3782, + "ratio": 0.226 }, "ui": { "lines": 3472, @@ -734,8 +734,8 @@ "claude_md_lines": 283 }, "complexity": { - "functions": 4487, - "over_threshold": 339, + "functions": 4480, + "over_threshold": 336, "worst_ccn": 128 } } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index ba4b8f3c8..4295fa4e5 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `22497bf5`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `7f1bb8dd`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -10,7 +10,7 @@ Current state only; the trend is this file's git history (`git log -p docs/refer |---|---:|---:|---:| | flash desktop-macos | 1,963 KB | 2,217 KB | +254.2 KB (+12.9%) | | flash esp32 | 2,061 KB | 2,174 KB | +112.9 KB (+5.5%) | -| flash esp32p4rev1-eth | 2,022 KB | 2,275 KB | +253.4 KB (+12.5%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,274 KB | +252.7 KB (+12.5%) | | flash esp32p4rev3-eth | 2,025 KB | 2,152 KB | +126.8 KB (+6.3%) | | flash esp32s3-n16r8 | 2,111 KB | 2,282 KB | +170.1 KB (+8.1%) | | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | @@ -18,9 +18,9 @@ Current state only; the trend is this file's git history (`git log -p docs/refer | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | | lines core | 22,299 | 28,711 | +6,412 (+28.8%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | -| lines platform | 16,798 | 18,812 | +2,014 (+12.0%) | +| lines platform | 16,798 | 18,695 | +1,897 (+11.3%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | -| lines test | 57,759 | 65,197 | +7,438 (+12.9%) | +| lines test | 57,759 | 65,204 | +7,445 (+12.9%) | | lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | | unit tests | 2,120 | 2,424 | +304 (+14.3%) | | scenarios | 21 | 30 | +9 (+42.9%) | @@ -31,7 +31,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop-linux | 2,488 KB | - | - | yes | +| desktop-linux | 2,488 KB (โˆ’176 B) โœ“ | - | - | yes | | desktop-macos | 2,217 KB | - | - | carried 2d | | desktop-windows | 1,880 KB | - | - | **STALE 12d** | | esp32 | 2,174 KB | 2,496 KB | 87% | carried 3d | @@ -40,7 +40,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 33d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | | esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 2d | -| esp32p4rev1-eth | 2,275 KB (+80 B) โš  | 4,096 KB | 56% | yes | +| esp32p4rev1-eth | 2,274 KB (โˆ’704 B) โœ“ | 4,096 KB | 56% | yes | | esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 12d** | | esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 4d | | esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 2d | @@ -55,12 +55,12 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Net | New | Removed | Grown | Shrunk | Other | Since | |---|---:|---:|---:|---:|---:|---:|---| -| esp32p4rev1-eth | +0.1 KB | +0.0 KB | +0.0 KB | +0.1 KB | +0.0 KB | -0.0 KB | `22497bf5` | -| esp32s3-zero | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `22497bf5` | +| esp32p4rev1-eth | -0.7 KB | +0.0 KB | -0.7 KB | +0.1 KB | -0.1 KB | +0.0 KB | `7f1bb8dd` | +| esp32s3-zero | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `7f1bb8dd` | Largest moves: -- **esp32p4rev1-eth**: `mm::FilesystemModule::removeTree(char const*, unsigned char)` +84 B, `uart_enable_tx_intr` +4 B +- **esp32p4rev1-eth**: `mm::platform::fsWriteStream(char const*, unsigned int (*)(ch` -428 B, `mm::platform::sdCardWriteStreamCb(char const*, unsigned int ` -250 B, `mm::HttpServerModule::handleWriteFile(mm::platform::TcpConne` +72 B, `mm::HttpServerModule::handleMoonBaseUrl(mm::platform::TcpCon` -48 B, `mm::HttpServerModule::handleDeleteModule(mm::platform::TcpCo` -46 B - **esp32s3-zero**: none What this commit's flash went to, symbol by symbol against the measurement at `Since`. New is new functionality, removed and shrunk are what a cleanup saved, and other is what no symbol owns, such as merged strings and alignment. A rename reads as removed plus new, which the largest moves expose. @@ -69,10 +69,10 @@ What this commit's flash went to, symbol by symbol against the measurement at `S | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 8 ยตs (+2 ยตs) โš  | 125,000 (โˆ’41,666) โš  | +| desktop-linux | 6 ยตs (โˆ’2 ยตs) โœ“ | 166,666 (+41,666) โœ“ | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | -| esp32 | 1,714 ยตs (โˆ’6 ยตs) โœ“ | 583 (+2) โœ“ | +| esp32 | 1,719 ยตs (+5 ยตs) โš  | 581 (โˆ’2) โš  | ### Scenario tick by target (p50 of each sample window) @@ -124,26 +124,26 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,711 (+11) โš  | 6,248 | 24.1 % (โˆ’0.1 %) โœ“ | +| core | 28,711 | 6,248 | 24.1 % | | light | 31,027 | 7,439 | 26.9 % | -| platform | 18,812 | 3,787 | 22.5 % | +| platform | 18,695 (โˆ’117) โœ“ | 3,782 | 22.6 % (+0.1 %) โš  | | ui | 12,350 | 3,472 | 29.8 % | -| test | 65,197 (+29) โš  | 7,001 | 12.4 % | +| test | 65,204 (+7) โš  | 7,001 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,424 (+2) โœ“ | +| unit cases | 2,424 | | scenarios | 30 | ## Complexity | Metric | Value | |---|---:| -| functions | 4,487 (+1) โœ“ | -| over threshold | 339 | +| functions | 4,480 (โˆ’7) โš  | +| over threshold | 336 (โˆ’3) โœ“ | | worst CCN | 128 | ## Documentation @@ -151,7 +151,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 150 | -| markdown lines | 32,297 (+14) โš  | +| markdown lines | 32,297 | | plan files | 44 | | backlog lines | 3,319 | | lessons lines | 532 | diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index d84412f02..319ac01d6 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -1019,51 +1019,6 @@ long fsSize(const char* path) { return ec ? -1 : static_cast(sz); } -int fsReadAt(const char* path, long offset, char* buf, size_t len) { - if (!buf) return -1; - FILE* f = std::fopen(toFsPath(path).string().c_str(), "rb"); - if (!f) return -1; - if (std::fseek(f, offset, SEEK_SET) != 0) { std::fclose(f); return -1; } - const size_t n = std::fread(buf, 1, len, f); - std::fclose(f); - return static_cast(n); // 0 at EOF -} - -bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { - if (!src) return false; - auto target = toFsPath(path); - makeParent(target); - auto tmp = target; - tmp += ".tmp"; - - FILE* f = openOwnerOnly(tmp.string().c_str()); - if (!f) return false; - // Pull chunks from the source and write each straight through, fixed buffer, any file size. `abort` set by the source (a short/timed-out upload) means the data is incomplete โ†’ discard. - char chunk[1024]; - bool ok = true, abort = false; - for (;;) { - const size_t got = src(chunk, sizeof(chunk), user, &abort); - if (abort) { ok = false; break; } - if (got == 0) break; // clean end of stream - if (std::fwrite(chunk, 1, got, f) != got) { ok = false; break; } - } - std::fflush(f); -#ifdef _WIN32 - int fd = ::_fileno(f); - if (fd >= 0) ::_commit(fd); -#else - int fd = ::fileno(f); - if (fd >= 0) ::fsync(fd); -#endif - std::fclose(f); - - std::error_code ec; - if (!ok) { std::filesystem::remove(tmp, ec); return false; } - std::filesystem::rename(tmp, target, ec); - if (ec) { std::filesystem::remove(tmp, ec); return false; } - return true; -} - namespace { // Tags whether this handle counts toward sdStreamsOpen_, so close() decrements exactly what open() counted. struct DesktopReadHandle { FILE* f = nullptr; bool isSd = false; }; diff --git a/src/platform/esp32/platform_esp32_fs.cpp b/src/platform/esp32/platform_esp32_fs.cpp index e802981e1..af692c649 100644 --- a/src/platform/esp32/platform_esp32_fs.cpp +++ b/src/platform/esp32/platform_esp32_fs.cpp @@ -199,19 +199,6 @@ bool fsWriteAtomic(const char* path, const char* data, size_t len) { return true; } -int fsReadAt(const char* path, long offset, char* buf, size_t len) { - if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardReadAt(path, offset, buf, len); } - if (!fsMounted_ || !buf) return -1; - char full[128]; - if (!fsTranslate(path, full, sizeof(full))) return -1; - FILE* f = std::fopen(full, "rb"); - if (!f) return -1; - if (std::fseek(f, offset, SEEK_SET) != 0) { std::fclose(f); return -1; } - const size_t n = std::fread(buf, 1, len, f); - std::fclose(f); - return static_cast(n); // 0 at EOF -} - long fsSize(const char* path) { if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardSize(path); } if (!fsMounted_) return -1; @@ -222,35 +209,6 @@ long fsSize(const char* path) { return static_cast(st.st_size); } -bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { - if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardWriteStreamCb(path, src, user); } - if (!fsMounted_ || !src) return false; - makeParent(path); - char full[128]; - char tmp[136]; - if (!fsTranslate(path, full, sizeof(full))) return false; - int n = std::snprintf(tmp, sizeof(tmp), "%s.tmp", full); - if (n < 0 || static_cast(n) >= sizeof(tmp)) return false; - - FILE* f = std::fopen(tmp, "wb"); - if (!f) return false; - // Chunks straight through, so any file size fits a fixed buffer; an aborted source discards. - char chunk[1024]; - bool ok = true, abort = false; - for (;;) { - const size_t got = src(chunk, sizeof(chunk), user, &abort); - if (abort) { ok = false; break; } - if (got == 0) break; // clean end of stream - if (std::fwrite(chunk, 1, got, f) != got) { ok = false; break; } - } - std::fflush(f); - int fd = ::fileno(f); - if (fd >= 0) ::fsync(fd); - std::fclose(f); - if (!ok || ::rename(tmp, full) != 0) { ::remove(tmp); return false; } - return true; -} - void fsList(const char* dir, FsListCb cb, void* user) { if constexpr (hasSdCard) { if (isSdApiPath(dir)) { sdCardList(dir, cb, user); return; } } if (!fsMounted_ || !cb) return; diff --git a/src/platform/esp32/platform_esp32_sdmmc.cpp b/src/platform/esp32/platform_esp32_sdmmc.cpp index b28779036..ad6f9ae3a 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.cpp +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -273,19 +273,6 @@ long sdCardSize(const char* apiPath) { return static_cast(fno.fsize); } -int sdCardReadAt(const char* apiPath, long offset, char* buf, size_t len) { - if (!g_mounted || !buf) return -1; - char full[128]; - if (!sdTranslate(apiPath, full, sizeof(full))) return -1; - if (f_open(&g_fil, full, FA_READ) != FR_OK) return -1; - if (f_lseek(&g_fil, static_cast(offset)) != FR_OK) { f_close(&g_fil); return -1; } - UINT br = 0; - const FRESULT r = f_read(&g_fil, buf, static_cast(len), &br); - f_close(&g_fil); - if (r != FR_OK) return -1; - return static_cast(br); -} - /// The folder a write lands in, made first, as the write's contract promises. static void sdMakeParent(const char* apiPath) { if (!apiPath) return; @@ -325,30 +312,6 @@ bool sdCardWriteAtomic(const char* apiPath, const char* data, size_t len) { return sdCommitTmp(tmp, full); } -bool sdCardWriteStreamCb(const char* apiPath, FsWriteSrc src, void* user) { - if (!g_mounted || !src) return false; - sdMakeParent(apiPath); - char full[128]; - char tmp[136]; - if (!sdTranslate(apiPath, full, sizeof(full))) return false; - int n = std::snprintf(tmp, sizeof(tmp), "%s.tmp", full); - if (n < 0 || static_cast(n) >= sizeof(tmp)) return false; - - if (f_open(&g_fil, tmp, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) return false; - char chunk[1024]; - bool ok = true, abort = false; - for (;;) { - const size_t got = src(chunk, sizeof(chunk), user, &abort); - if (abort) { ok = false; break; } - if (got == 0) break; - UINT bw = 0; - if (f_write(&g_fil, chunk, static_cast(got), &bw) != FR_OK || bw != got) { ok = false; break; } - } - f_close(&g_fil); - if (!ok) { f_unlink(tmp); return false; } - return sdCommitTmp(tmp, full); -} - void sdCardList(const char* apiDir, FsListCb cb, void* user) { if (!g_mounted || !cb) return; char full[128]; @@ -447,9 +410,7 @@ bool sdCardExists(const char*) { return false; } bool sdCardRemove(const char*) { return false; } int sdCardRead(const char*, char*, size_t) { return -1; } long sdCardSize(const char*) { return -1; } -int sdCardReadAt(const char*, long, char*, size_t) { return -1; } bool sdCardWriteAtomic(const char*, const char*, size_t) { return false; } -bool sdCardWriteStreamCb(const char*, FsWriteSrc, void*) { return false; } void sdCardList(const char*, FsListCb, void*) {} bool sdCardReadStreamOpen(void*&, const char*) { return false; } int sdCardReadStreamRead(void*, char*, size_t) { return -1; } diff --git a/src/platform/esp32/platform_esp32_sdmmc.h b/src/platform/esp32/platform_esp32_sdmmc.h index 36c42f8bf..c86e48dd2 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.h +++ b/src/platform/esp32/platform_esp32_sdmmc.h @@ -19,12 +19,8 @@ bool sdCardRemove(const char* apiPath); int sdCardRead(const char* apiPath, char* buf, size_t maxLen); /// A card file's size in bytes, or -1 when it is missing or not a file. long sdCardSize(const char* apiPath); -/// Read up to `len` bytes at `offset` on the card; bytes read, 0 at the end, -1 on error. -int sdCardReadAt(const char* apiPath, long offset, char* buf, size_t len); /// Write a whole file atomically onto the card, through a temporary and a rename. bool sdCardWriteAtomic(const char* apiPath, const char* data, size_t len); -/// Write a card file atomically, pulling `src` in chunks until it signals done or abort. -bool sdCardWriteStreamCb(const char* apiPath, FsWriteSrc src, void* user); /// List one level of a card directory, calling `cb` per child. void sdCardList(const char* apiDir, FsListCb cb, void* user); diff --git a/src/platform/platform.h b/src/platform/platform.h index 9714d1dfd..38784ec5d 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -283,14 +283,10 @@ bool fsRemove(const char* path); // file or empty di int fsRead(const char* path, char* buf, size_t maxLen); /// A file's size in bytes, or -1 when it is missing or not a file. long fsSize(const char* path); -/// Read up to `len` bytes at `offset`; bytes read, 0 at the end, -1 on error. -int fsReadAt(const char* path, long offset, char* buf, size_t len); /// Write a whole file atomically, through a temporary and a rename, creating its parent folder. bool fsWriteAtomic(const char* path, const char* data, size_t len); /// Fill up to `cap` bytes and answer the count; 0 ends the stream. using FsWriteSrc = size_t(*)(char* buf, size_t cap, void* user, bool* abort); -/// Write a file atomically from `src`, pulling it in chunks and creating its parent folder; false on abort or a write failure. -bool fsWriteStream(const char* path, FsWriteSrc src, void* user); /// Called once per child of a listed directory; a directory reports size 0. using FsListCb = void(*)(const char* name, bool isDir, uint32_t sizeBytes, void* user); /// List one level of `dir`, calling `cb` per child. @@ -314,6 +310,23 @@ bool fsWriteStreamWrite(FsWriteStream& h, const char* buf, size_t len); /// `commit`: fsync and rename the temporary onto `path`. Otherwise: discard it. Either way the handle is spent. bool fsWriteStreamClose(FsWriteStream& h, bool commit); +/// Write a file atomically from `src`, pulling it in chunks and creating its parent folder; false on abort or a write failure. One core loop over the resumable primitives above, so platform code implements only those, not this pull shape too. +inline bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { + if (!src) return false; + FsWriteStream h{}; + if (!fsWriteStreamOpen(h, path)) return false; + char chunk[1024]; + bool ok = true, abort = false; + for (;;) { + const size_t got = src(chunk, sizeof(chunk), user, &abort); + if (abort) { ok = false; break; } + if (got == 0) break; // clean end of stream + if (!fsWriteStreamWrite(h, chunk, got)) { ok = false; break; } + } + const bool committed = fsWriteStreamClose(h, ok); + return ok && committed; +} + // A second, independent mount (microSD, FAT) sharing the fsXxx surface above through its own `/sd` path prefix; inert wherever hasSdCard is false. /// A board's SDMMC wiring, read at mount time rather than compiled in; -1 leaves a line unused. diff --git a/test/unit/core/unit_FileManagerModule.cpp b/test/unit/core/unit_FileManagerModule.cpp index cb2e6ca75..6e40295c5 100644 --- a/test/unit/core/unit_FileManagerModule.cpp +++ b/test/unit/core/unit_FileManagerModule.cpp @@ -145,7 +145,7 @@ TEST_CASE("HttpServer::parseFilePath rejects traversal, empty, missing, and over CHECK_FALSE(HttpServerModule::parseFilePath("path=/this/is/way/too/long", small, sizeof(small))); } -// The /api/file upload streams to fsWriteStream (any size, binary-safe) and downloads via fsReadAt (positional, chunked); these pin those seam primitives. +// The /api/file upload streams to fsWriteStream (any size, binary-safe) and downloads via FsReadStream (kept open across chunks); these pin those seam primitives. // A multi-chunk source (larger than the src callback's cap) with a NUL writes in full and reads back byte-for-byte, the streamed-upload contract. namespace { @@ -171,12 +171,17 @@ TEST_CASE("FileManager: fsWriteStream writes a multi-chunk NUL-containing payloa REQUIRE(platform::fsWriteStream("/blob.bin", &spanPull, &src)); CHECK(platform::fsSize("/blob.bin") == static_cast(payload.size())); - // Read it back positionally across a chunk boundary, the streamed-download contract. + // Read it back across a chunk boundary via the kept-open stream, the streamed-download contract. + platform::FsReadStream rs{}; + REQUIRE(platform::fsReadStreamOpen(rs, "/blob.bin")); + char skip[1480] = {}; + REQUIRE(platform::fsReadStreamRead(rs, skip, sizeof(skip)) == static_cast(sizeof(skip))); char win[64] = {}; - const int got = platform::fsReadAt("/blob.bin", 1480, win, 40); // straddles the NUL at 1500 + const int got = platform::fsReadStreamRead(rs, win, 40); // straddles the NUL at 1500 CHECK(got == 40); CHECK(win[20] == '\0'); // the planted NUL, offset 1500 CHECK(std::memcmp(win, payload.data() + 1480, 40) == 0); + platform::fsReadStreamClose(rs); } // A source that reports a short read (mid-stream failure) still commits atomically, here a clean end just yields the bytes delivered; there's no partial/torn file (temp โ†’ rename). diff --git a/test/unit/core/unit_HttpServerModule_sdcard.cpp b/test/unit/core/unit_HttpServerModule_sdcard.cpp index 7c3a5de69..a723e1df5 100644 --- a/test/unit/core/unit_HttpServerModule_sdcard.cpp +++ b/test/unit/core/unit_HttpServerModule_sdcard.cpp @@ -154,15 +154,17 @@ TEST_CASE("an SD upload larger than one tick's chunk streams across several tick CHECK(mm::platform::fsSize("/sd/big.bin") == static_cast(payload.size())); std::vector back(payload.size()); + mm::platform::FsReadStream rs{}; + REQUIRE(mm::platform::fsReadStreamOpen(rs, "/sd/big.bin")); char chunk[2048]; size_t got = 0; while (got < back.size()) { - const int n = mm::platform::fsReadAt("/sd/big.bin", static_cast(got), chunk, - std::min(sizeof(chunk), back.size() - got)); + const int n = mm::platform::fsReadStreamRead(rs, chunk, std::min(sizeof(chunk), back.size() - got)); REQUIRE(n > 0); std::memcpy(back.data() + got, chunk, static_cast(n)); got += static_cast(n); } + mm::platform::fsReadStreamClose(rs); CHECK(std::memcmp(back.data(), payload.data(), payload.size()) == 0); client.close(); From 0f1e9e9054bafe49c3dc92a472ba51d41bd1b699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 12 Oct 2026 08:24:13 +0200 Subject: [PATCH 10/20] Close an active SD transfer's stream and socket on release() PR #136 review item 7 (yours, lower priority): HttpServerModule::release() closed every WebSocket, parked and preview connection, but skipped sdTransfers_ entirely. An active slot mid-transfer at release time left its FIL (or its desktop equivalent) and its socket both open, going out of scope without either being freed. Flash: esp32p4rev1-eth +160 B (2328546 -> 2328706). desktop-linux +72 B (2547928 -> 2548000). Account: src +16/-2 lines | test +33/-10 lines | duplicated lines 3738 -> 3738 (+0) | code findings 1630 -> 1630 (+0) Core: - HttpServerModule.cpp/.h: new releaseSdTransfers(), called from release(); closes whichever stream an active slot holds (discarding, not committing, since this is a shutdown not a completion) and its socket, then frees the slot via the existing finishSdTransfer(). A separate method rather than inlined in release(), so release() itself stays under the complexity ratchet. Tests: - unit_HttpServerModule_sdcard.cpp: a new case claims a slot mid-upload, calls release(), and checks platform::sdCardBusy() clears (the stream genuinely closed, not abandoned), the socket closes, and no leftover .tmp file remains. Extracted the shared "claim a slot with a stuck, in-flight upload" setup (already duplicated by the existing stall-timeout test) into one claimStuckUpload() helper, so the new test's setup is a one-line call rather than a second copy. Checks (scoped to this diff's own trigger-table buckets - src/ and test/ only): - check_specs: PASS - check_prose: PASS - 0 new findings (one pre-existing, unrelated finding in docs/reference/hardware/gpio-usage.md, not touched by this diff) - check_docgen: PASS - 0 errors, net -1 warning (an unrelated pre-existing split-paragraph pair in HttpServerModule.h's own @moreinfo, met and joined while touching this file) - check_code: PASS - 0 net new findings; the complexity Extract Function and the duplicated-block Extract Function above both landed back at the committed baseline exactly - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2437/2437 (1 new) - run_scenario: PASS - HttpServerModule (1/1, 1 pre-existing unrelated skip) - check_esp32_built: PASS - fresh after the real rebuild+flash - collect_kpi: PASS on the account above; the live ESP32 tick/FPS reading reflects whatever demo config is already on the bench board, not this diff - Real hardware (esp32p4rev1-eth, serial): rebuilt, reflashed, verified live - clean boot, no panic, and a normal SD upload/download round trip still came back byte-exact. release() itself only runs on module teardown/reconfiguration, not reachable through a plain HTTP call, so the leak fix's own correctness is pinned by the new desktop unit test above rather than by a live API probe. --- docs/reference/metrics/docgen.md | 14 +++--- docs/reference/metrics/repo-health.json | 24 +++++------ docs/reference/metrics/repo-health.md | 38 ++++++++-------- src/core/system/HttpServerModule.cpp | 13 ++++++ src/core/system/HttpServerModule.h | 5 ++- .../core/unit_HttpServerModule_sdcard.cpp | 43 ++++++++++++++----- 6 files changed, 86 insertions(+), 51 deletions(-) diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index eaef732f5..64826d28c 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,9 +4,9 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks. -**0 error(s)** and **4316 warning(s)** across 421 page(s). +**0 error(s)** and **4315 warning(s)** across 420 page(s). -**5534 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. +**5533 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. An error is in a file that generates a documentation page, a header or a catalog page, so the finding is a defect in what gets published and it fails the gate. A warning is worth fixing without being worth stopping a commit for: any finding in an implementation file, which publishes nothing, and the rules still staged as counts in a header too, the over-wide line and the appendix section nothing refers to. Both are counted here, because a warning nobody sees is a warning nobody fixes. @@ -16,7 +16,7 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| -| split paragraphs | 0 | 1800 | +| split paragraphs | 0 | 1799 | | over-wide comment lines | 0 | 1595 | | appendix sections nothing refers to | 0 | 372 | | multi-line comment blocks | 0 | 321 | @@ -33,7 +33,7 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier | `(tests, no card)` | 0 | 2072 | | `platform/index.md` | 0 | 746 | | `light/effects.md` | 0 | 403 | -| `core/system.md` | 0 | 222 | +| `core/system.md` | 0 | 221 | | `core/supporting.md` | 0 | 181 | | `light/power-functions.md` | 0 | 147 | | `core/services.md` | 0 | 123 | @@ -111,7 +111,7 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. ### core/system.md -**0 error(s)** and **222 warning(s)** across 26 file(s). +**0 error(s)** and **221 warning(s)** across 25 file(s). | Findings | File | | |---:|---|---| @@ -125,9 +125,9 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. | 12 | `src/core/system/TasksModule.h` | warning | | 11 | `src/core/system/MqttModule.h` | warning | | 11 | `src/core/system/MqttPacket.h` | warning | -| 1-11 each | *16 more warning files, 77 findings* | | +| 1-11 each | *15 more warning files, 76 findings* | | -By rule, warnings: 165 split paragraphs, 37 appendix sections nothing refers to, 11 over-wide comment lines, 9 multi-line comment blocks. +By rule, warnings: 164 split paragraphs, 37 appendix sections nothing refers to, 11 over-wide comment lines, 9 multi-line comment blocks. ### core/supporting.md diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 0da04873b..39ccd79b7 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "7f1bb8dd", + "commit": "b9a34f69", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, - "esp32p4rev1-eth": 2329008, + "esp32p4rev1-eth": 2329168, "esp32p4rev1-eth-wifi": 2358896, "esp32s3-n8r8": 2336304, "esp32s31": 2595200, @@ -17,7 +17,7 @@ "desktop-macos": 2270440, "desktop-windows": 1925120, "esp32c3": 2335072, - "desktop-linux": 2547928 + "desktop-linux": 2548000 }, "measured": { "esp32p4rev1-eth": "2026-10-12", @@ -38,9 +38,9 @@ }, "perf": { "esp32": { - "tick_us": 1719, - "fps": 581, - "heap_free": 33827027 + "tick_us": 1716, + "fps": 582, + "heap_free": 33827003 }, "scenario_matrix": { "Control_a_palette_preset_changes_only_the_palette": { @@ -688,16 +688,16 @@ } }, "loc": { - "core": 28711, + "core": 28725, "light": 31027, "platform": 18695, "ui": 12350, - "test": 65204, + "test": 65227, "moondeck": 30600 }, "comments": { "core": { - "lines": 6248, + "lines": 6249, "ratio": 0.241 }, "light": { @@ -713,7 +713,7 @@ "ratio": 0.298 }, "test": { - "lines": 7001, + "lines": 7002, "ratio": 0.124 }, "moondeck": { @@ -722,7 +722,7 @@ } }, "tests": { - "cases": 2424, + "cases": 2425, "scenarios": 30 }, "docs": { @@ -734,7 +734,7 @@ "claude_md_lines": 283 }, "complexity": { - "functions": 4480, + "functions": 4481, "over_threshold": 336, "worst_ccn": 128 } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 4295fa4e5..21408823f 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `7f1bb8dd`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `b9a34f69`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -10,19 +10,19 @@ Current state only; the trend is this file's git history (`git log -p docs/refer |---|---:|---:|---:| | flash desktop-macos | 1,963 KB | 2,217 KB | +254.2 KB (+12.9%) | | flash esp32 | 2,061 KB | 2,174 KB | +112.9 KB (+5.5%) | -| flash esp32p4rev1-eth | 2,022 KB | 2,274 KB | +252.7 KB (+12.5%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,275 KB | +252.9 KB (+12.5%) | | flash esp32p4rev3-eth | 2,025 KB | 2,152 KB | +126.8 KB (+6.3%) | | flash esp32s3-n16r8 | 2,111 KB | 2,282 KB | +170.1 KB (+8.1%) | | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | | flash esp32s3-zero | 1,977 KB | 2,173 KB | +196.5 KB (+9.9%) | | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | -| lines core | 22,299 | 28,711 | +6,412 (+28.8%) | +| lines core | 22,299 | 28,725 | +6,426 (+28.8%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | | lines platform | 16,798 | 18,695 | +1,897 (+11.3%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | -| lines test | 57,759 | 65,204 | +7,445 (+12.9%) | +| lines test | 57,759 | 65,227 | +7,468 (+12.9%) | | lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | -| unit tests | 2,120 | 2,424 | +304 (+14.3%) | +| unit tests | 2,120 | 2,425 | +305 (+14.4%) | | scenarios | 21 | 30 | +9 (+42.9%) | Against the release `v6.0.0`, the fixed point a user compares with; the tables below compare against the last commit. A firmware not rebuilt since `v6.0.0` is left out, and a measure the release did not record yet, such as the code report, joins from the next one. @@ -31,7 +31,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop-linux | 2,488 KB (โˆ’176 B) โœ“ | - | - | yes | +| desktop-linux | 2,488 KB (+72 B) โš  | - | - | yes | | desktop-macos | 2,217 KB | - | - | carried 2d | | desktop-windows | 1,880 KB | - | - | **STALE 12d** | | esp32 | 2,174 KB | 2,496 KB | 87% | carried 3d | @@ -40,12 +40,12 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 33d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | | esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 2d | -| esp32p4rev1-eth | 2,274 KB (โˆ’704 B) โœ“ | 4,096 KB | 56% | yes | +| esp32p4rev1-eth | 2,275 KB (+160 B) โš  | 4,096 KB | 56% | yes | | esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 12d** | | esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 4d | | esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 2d | | esp32s3-n8r8 | 2,282 KB | 3,072 KB | 74% | carried 2d | -| esp32s3-zero | 2,173 KB | 2,496 KB | 87% | yes | +| esp32s3-zero | 2,173 KB | 2,496 KB | 87% | carried 0d | | esp32s31 | 2,534 KB | 4,096 KB | 62% | carried 2d | | qemu | 1,351 KB | - | - | carried (age?) | @@ -55,13 +55,11 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Net | New | Removed | Grown | Shrunk | Other | Since | |---|---:|---:|---:|---:|---:|---:|---| -| esp32p4rev1-eth | -0.7 KB | +0.0 KB | -0.7 KB | +0.1 KB | -0.1 KB | +0.0 KB | `7f1bb8dd` | -| esp32s3-zero | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `7f1bb8dd` | +| esp32p4rev1-eth | +0.2 KB | +0.1 KB | +0.0 KB | +0.1 KB | +0.0 KB | +0.0 KB | `b9a34f69` | Largest moves: -- **esp32p4rev1-eth**: `mm::platform::fsWriteStream(char const*, unsigned int (*)(ch` -428 B, `mm::platform::sdCardWriteStreamCb(char const*, unsigned int ` -250 B, `mm::HttpServerModule::handleWriteFile(mm::platform::TcpConne` +72 B, `mm::HttpServerModule::handleMoonBaseUrl(mm::platform::TcpCon` -48 B, `mm::HttpServerModule::handleDeleteModule(mm::platform::TcpCo` -46 B -- **esp32s3-zero**: none +- **esp32p4rev1-eth**: `mm::HttpServerModule::releaseSdTransfers()` +96 B, `mm::HttpServerModule::handleMoonBaseUrl(mm::platform::TcpCon` +48 B, `mm::HttpServerModule::release()` +6 B What this commit's flash went to, symbol by symbol against the measurement at `Since`. New is new functionality, removed and shrunk are what a cleanup saved, and other is what no symbol owns, such as merged strings and alignment. A rename reads as removed plus new, which the largest moves expose. @@ -69,10 +67,10 @@ What this commit's flash went to, symbol by symbol against the measurement at `S | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 6 ยตs (โˆ’2 ยตs) โœ“ | 166,666 (+41,666) โœ“ | +| desktop-linux | 6 ยตs | 166,666 | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | -| esp32 | 1,719 ยตs (+5 ยตs) โš  | 581 (โˆ’2) โš  | +| esp32 | 1,716 ยตs (โˆ’3 ยตs) โœ“ | 582 (+1) โœ“ | ### Scenario tick by target (p50 of each sample window) @@ -124,26 +122,26 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,711 | 6,248 | 24.1 % | +| core | 28,725 (+14) โš  | 6,249 | 24.1 % | | light | 31,027 | 7,439 | 26.9 % | -| platform | 18,695 (โˆ’117) โœ“ | 3,782 | 22.6 % (+0.1 %) โš  | +| platform | 18,695 | 3,782 | 22.6 % | | ui | 12,350 | 3,472 | 29.8 % | -| test | 65,204 (+7) โš  | 7,001 | 12.4 % | +| test | 65,227 (+23) โš  | 7,002 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,424 | +| unit cases | 2,425 (+1) โœ“ | | scenarios | 30 | ## Complexity | Metric | Value | |---|---:| -| functions | 4,480 (โˆ’7) โš  | -| over threshold | 336 (โˆ’3) โœ“ | +| functions | 4,481 (+1) โœ“ | +| over threshold | 336 | | worst CCN | 128 | ## Documentation diff --git a/src/core/system/HttpServerModule.cpp b/src/core/system/HttpServerModule.cpp index 418cdb0d6..744d6ce48 100644 --- a/src/core/system/HttpServerModule.cpp +++ b/src/core/system/HttpServerModule.cpp @@ -115,6 +115,7 @@ void HttpServerModule::release() { } for (auto& ws : wsClients_) ws.close(); for (auto& c : parked_) c.close(); + releaseSdTransfers(); // Every close site notifies the producer (see cancelBufferedSend), else ghost standing requests keep the driver gathering frames for nobody. for (int i = 0; i < MAX_PREVIEW_CLIENTS; i++) { if (previewClients_[i].valid() && clientSink_) clientSink_->onClientGone(i); @@ -196,6 +197,18 @@ void HttpServerModule::finishSdTransfer(SdTransfer& t, bool closeConn) { t.rstream = platform::FsReadStream{}; } +// release()'s own cleanup: an active slot holds a FIL (or its desktop equivalent) and a socket, neither freed by going out of scope. +void HttpServerModule::releaseSdTransfers() { + if constexpr (platform::hasSdCard) { + for (auto& t : sdTransfers_) { + if (!t.active) continue; + if (t.isUpload) platform::fsWriteStreamClose(t.wstream, false); + else platform::fsReadStreamClose(t.rstream); + finishSdTransfer(t, true); + } + } +} + // An upload slot's bounded chunk: socket to card, then the stall guard. // Reads into t.buf, not a stack-local: that put a 4 KB frame on this task's stack every tick, until one coincided with other deep stack use and panicked the board. void HttpServerModule::driveSdUpload(SdTransfer& t, uint32_t now) { diff --git a/src/core/system/HttpServerModule.h b/src/core/system/HttpServerModule.h index c3b1b7fe0..148cbe9be 100644 --- a/src/core/system/HttpServerModule.h +++ b/src/core/system/HttpServerModule.h @@ -42,8 +42,7 @@ class Scheduler; /// A mutation is `POST /api/control` with a module, control and value; `POST /api/modules` to create; `POST /api/modules/{name}/move` to reorder and `.../replace` to swap; `POST /api/reboot`; or `DELETE /api/modules/{name}`. /// /// The File Manager reads and writes through `GET|POST /api/file?path=`, and lists, creates and removes through `/api/dir?path=`. The path rides the query, so a filesystem operation carries its target in the request rather than in a stored control. -/// A board with an SD slot (`SdCardModule`) mounts it at `/sd`, so these same two routes reach the card too. -/// The tree shows it as a folder next to the internal flash. +/// A board with an SD slot (`SdCardModule`) mounts it at `/sd`, so these same two routes reach the card too, shown as a folder next to the internal flash. /// /// Every JSON response streams through a `JsonSink`, so there is no fixed-buffer ceiling and a tree of any size serializes correctly. /// @@ -410,6 +409,8 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { void driveSdDownload(SdTransfer& t, uint32_t now); /// Close the stream (already done by the caller) and the connection, and free the slot. void finishSdTransfer(SdTransfer& t, bool closeConn); + /// Close every active slot's stream and socket; release()'s own cleanup, since going out of scope frees neither. + void releaseSdTransfers(); ClientMessageSink* clientSink_ = nullptr; // the producer's inbound-message sink (PreviewDriver) @xref{crossing-the-domain-boundary} diff --git a/test/unit/core/unit_HttpServerModule_sdcard.cpp b/test/unit/core/unit_HttpServerModule_sdcard.cpp index a723e1df5..202d91fab 100644 --- a/test/unit/core/unit_HttpServerModule_sdcard.cpp +++ b/test/unit/core/unit_HttpServerModule_sdcard.cpp @@ -54,6 +54,18 @@ std::string pattern(size_t n) { return s; } +// Connects, declares a 10000-byte upload to `path` but sends only 100, then ticks once so the slot claims it: in flight, never finished. +void claimStuckUpload(mm::HttpServerModule& http, mm::platform::TcpConnection& client, const char* path) { + REQUIRE(connectAndAccept(http, client)); + char req[256]; + const int reqLen = std::snprintf(req, sizeof(req), + "POST /api/file?path=%s HTTP/1.1\r\nContent-Length: 10000\r\n\r\n", path); + REQUIRE(client.write(reinterpret_cast(req), static_cast(reqLen))); + const std::string partial = pattern(100); + REQUIRE(client.write(reinterpret_cast(partial.data()), partial.size())); + http.tick20ms(); +} + // Mounts a real card under a fresh temp dir for one test, and tears it down after. struct SdFixture { char root[256]; @@ -208,20 +220,12 @@ TEST_CASE("an SD upload that stalls mid-transfer is closed and its slot frees fo http.port = kTestPort; http.setup(); mm::platform::TcpConnection client; - REQUIRE(connectAndAccept(http, client)); - - // Declares 10000 bytes, sends only 100, then goes silent: a stuck upload, never completed. - char req[256]; - const int reqLen = std::snprintf(req, sizeof(req), - "POST /api/file?path=/sd/stuck.bin HTTP/1.1\r\nContent-Length: 10000\r\n\r\n"); - REQUIRE(client.write(reinterpret_cast(req), static_cast(reqLen))); - const std::string partial = pattern(100); - REQUIRE(client.write(reinterpret_cast(partial.data()), partial.size())); - http.tick20ms(); // claims the slot, writes the 100 bytes already buffered + claimStuckUpload(http, client, "/sd/stuck.bin"); // goes silent after this: a stuck upload, never completed // Comfortably past the stall guard (the real bound is a private implementation detail). mm::platform::setTestNowMs(1000 + 60000); for (int i = 0; i < 10; i++) http.tick20ms(); + char req[32]; CHECK(client.read(reinterpret_cast(req), sizeof(req)) == 0); // closed, no response // The slot is free again: a fresh, small upload still completes normally. @@ -236,6 +240,25 @@ TEST_CASE("an SD upload that stalls mid-transfer is closed and its slot frees fo http.release(); } +// Point 7 of the review: release() left an active slot's stream and socket open, leaking a FIL (or its desktop equivalent) and a connection. +TEST_CASE("release mid-transfer closes the slot's stream and socket, leaking neither") { + SdFixture fix; + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + mm::platform::TcpConnection client; + claimStuckUpload(http, client, "/sd/leak.bin"); + REQUIRE(mm::platform::sdCardBusy()); // the open stream holds the busy guard + + http.release(); + CHECK_FALSE(mm::platform::sdCardBusy()); // the stream closed, not just abandoned to a dangling handle + char req[32]; + CHECK(client.read(reinterpret_cast(req), sizeof(req)) == 0); // the socket closed too + CHECK_FALSE(std::filesystem::exists(std::string(fix.root) + "/sd/leak.bin.tmp")); // no leftover temp + + client.close(); +} + TEST_CASE("an SD upload past the card's free space is refused with 507, immediately") { SdFixture fix; mm::platform::setTestSdCardTotal(500); // a nearly-full, tiny fictional card From 66569ad514ec72ab612ac6351d51a140472c0d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 12 Oct 2026 09:16:05 +0200 Subject: [PATCH 11/20] Claim an SD download's slot before promising its body PR #136 review item 8 (yours, lower priority): streamFsFile wrote the 200 header, with its Content-Length already committed, before claiming a transfer slot or opening the read stream. A busy slot (both already in use) or a failed open then answered with a "200 OK" promising a body that never arrived - a client reading the response saw a connection close mid-promise instead of a clear error, the same class of failure the upload side already avoided by claiming first. Flash: esp32p4rev1-eth +136 B (2328706 -> 2328842). desktop-linux unchanged. Account: src +12/-4 lines | test +33/-0 lines | duplicated lines 3738 -> 3738 (+0) | code findings 1630 -> 1630 (+0) Core: - HttpServerModule.cpp: streamFsFile's /sd leg now claims the transfer slot and opens the read stream before writing anything - 503 "another SD transfer is in progress" when both slots are busy, 500 "read failed" when the open itself fails, matching handleWriteFileSd's own shape. The header (its bytes already built either way) goes out only once the slot genuinely holds the open stream. Tests: - unit_HttpServerModule_sdcard.cpp: a new case holds both slots with two stuck uploads, then GETs a third file and checks the response starts "HTTP/1.1 503", never "200". Added getAndWait() (connect, GET, wait for a response) next to the existing claimStuckUpload(), so the new test's setup is two one-line calls rather than inline socket code that would have duplicated several existing tests' shape. Checks (scoped to this diff's own trigger-table buckets - src/ and test/ only): - check_specs: PASS - check_prose: PASS - 0 new findings (one pre-existing, unrelated finding in docs/reference/hardware/gpio-usage.md, not touched by this diff) - check_docgen: PASS - 0 errors, net -1 warning (this file's own doc comment tightened while touching it) - check_code: PASS - 0 net new findings; the duplicated-block rise the new test's inline socket code first caused (+4) came down to the baseline exactly once getAndWait replaced it - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2438/2438 (1 new) - run_scenario: PASS - HttpServerModule (1/1, 1 pre-existing unrelated skip) - check_esp32_built: PASS - fresh after the real rebuild+flash - collect_kpi: PASS on the account above; the live ESP32 tick/FPS reading reflects whatever demo config is already on the bench board, not this diff - Real hardware (esp32p4rev1-eth, serial): rebuilt, reflashed, verified live - with both transfer slots held by two stuck uploads, a GET for a third SD file answered a clean 503 rather than a 200 with no body; a normal file round-tripped correctly once the slots freed, with no crash or regression. --- docs/reference/metrics/repo-health.json | 18 +++++----- docs/reference/metrics/repo-health.md | 32 +++++++++--------- src/core/system/HttpServerModule.cpp | 16 ++++++--- .../core/unit_HttpServerModule_sdcard.cpp | 33 +++++++++++++++++++ 4 files changed, 70 insertions(+), 29 deletions(-) diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 39ccd79b7..897501492 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "b9a34f69", + "commit": "0f1e9e90", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, - "esp32p4rev1-eth": 2329168, + "esp32p4rev1-eth": 2329296, "esp32p4rev1-eth-wifi": 2358896, "esp32s3-n8r8": 2336304, "esp32s31": 2595200, @@ -683,16 +683,16 @@ } }, "desktop-linux": { - "tick_us": 6, - "fps": 166666 + "tick_us": 5, + "fps": 200000 } }, "loc": { - "core": 28725, + "core": 28733, "light": 31027, "platform": 18695, "ui": 12350, - "test": 65227, + "test": 65260, "moondeck": 30600 }, "comments": { @@ -713,7 +713,7 @@ "ratio": 0.298 }, "test": { - "lines": 7002, + "lines": 7005, "ratio": 0.124 }, "moondeck": { @@ -722,12 +722,12 @@ } }, "tests": { - "cases": 2425, + "cases": 2426, "scenarios": 30 }, "docs": { "md_files": 150, - "md_lines": 32297, + "md_lines": 32295, "plans_files": 44, "backlog_lines": 3319, "lessons_lines": 532, diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 21408823f..324ad56c0 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `b9a34f69`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `0f1e9e90`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -10,19 +10,19 @@ Current state only; the trend is this file's git history (`git log -p docs/refer |---|---:|---:|---:| | flash desktop-macos | 1,963 KB | 2,217 KB | +254.2 KB (+12.9%) | | flash esp32 | 2,061 KB | 2,174 KB | +112.9 KB (+5.5%) | -| flash esp32p4rev1-eth | 2,022 KB | 2,275 KB | +252.9 KB (+12.5%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,275 KB | +253.0 KB (+12.5%) | | flash esp32p4rev3-eth | 2,025 KB | 2,152 KB | +126.8 KB (+6.3%) | | flash esp32s3-n16r8 | 2,111 KB | 2,282 KB | +170.1 KB (+8.1%) | | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | | flash esp32s3-zero | 1,977 KB | 2,173 KB | +196.5 KB (+9.9%) | | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | -| lines core | 22,299 | 28,725 | +6,426 (+28.8%) | +| lines core | 22,299 | 28,733 | +6,434 (+28.9%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | | lines platform | 16,798 | 18,695 | +1,897 (+11.3%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | -| lines test | 57,759 | 65,227 | +7,468 (+12.9%) | +| lines test | 57,759 | 65,260 | +7,501 (+13.0%) | | lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | -| unit tests | 2,120 | 2,425 | +305 (+14.4%) | +| unit tests | 2,120 | 2,426 | +306 (+14.4%) | | scenarios | 21 | 30 | +9 (+42.9%) | Against the release `v6.0.0`, the fixed point a user compares with; the tables below compare against the last commit. A firmware not rebuilt since `v6.0.0` is left out, and a measure the release did not record yet, such as the code report, joins from the next one. @@ -31,7 +31,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop-linux | 2,488 KB (+72 B) โš  | - | - | yes | +| desktop-linux | 2,488 KB | - | - | yes | | desktop-macos | 2,217 KB | - | - | carried 2d | | desktop-windows | 1,880 KB | - | - | **STALE 12d** | | esp32 | 2,174 KB | 2,496 KB | 87% | carried 3d | @@ -40,7 +40,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 33d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | | esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 2d | -| esp32p4rev1-eth | 2,275 KB (+160 B) โš  | 4,096 KB | 56% | yes | +| esp32p4rev1-eth | 2,275 KB (+128 B) โš  | 4,096 KB | 56% | yes | | esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 12d** | | esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 4d | | esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 2d | @@ -55,11 +55,11 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Net | New | Removed | Grown | Shrunk | Other | Since | |---|---:|---:|---:|---:|---:|---:|---| -| esp32p4rev1-eth | +0.2 KB | +0.1 KB | +0.0 KB | +0.1 KB | +0.0 KB | +0.0 KB | `b9a34f69` | +| esp32p4rev1-eth | +0.1 KB | +0.0 KB | +0.0 KB | +0.1 KB | +0.0 KB | +0.0 KB | `0f1e9e90` | Largest moves: -- **esp32p4rev1-eth**: `mm::HttpServerModule::releaseSdTransfers()` +96 B, `mm::HttpServerModule::handleMoonBaseUrl(mm::platform::TcpCon` +48 B, `mm::HttpServerModule::release()` +6 B +- **esp32p4rev1-eth**: `mm::HttpServerModule::streamFsFile(mm::platform::TcpConnecti` +116 B What this commit's flash went to, symbol by symbol against the measurement at `Since`. New is new functionality, removed and shrunk are what a cleanup saved, and other is what no symbol owns, such as merged strings and alignment. A rename reads as removed plus new, which the largest moves expose. @@ -67,10 +67,10 @@ What this commit's flash went to, symbol by symbol against the measurement at `S | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 6 ยตs | 166,666 | +| desktop-linux | 5 ยตs (โˆ’1 ยตs) โœ“ | 200,000 (+33,334) โœ“ | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | -| esp32 | 1,716 ยตs (โˆ’3 ยตs) โœ“ | 582 (+1) โœ“ | +| esp32 | 1,716 ยตs | 582 | ### Scenario tick by target (p50 of each sample window) @@ -122,25 +122,25 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,725 (+14) โš  | 6,249 | 24.1 % | +| core | 28,733 (+8) โš  | 6,249 | 24.1 % | | light | 31,027 | 7,439 | 26.9 % | | platform | 18,695 | 3,782 | 22.6 % | | ui | 12,350 | 3,472 | 29.8 % | -| test | 65,227 (+23) โš  | 7,002 | 12.4 % | +| test | 65,260 (+33) โš  | 7,005 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,425 (+1) โœ“ | +| unit cases | 2,426 (+1) โœ“ | | scenarios | 30 | ## Complexity | Metric | Value | |---|---:| -| functions | 4,481 (+1) โœ“ | +| functions | 4,481 | | over threshold | 336 | | worst CCN | 128 | @@ -149,7 +149,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 150 | -| markdown lines | 32,297 | +| markdown lines | 32,295 (โˆ’2) โœ“ | | plan files | 44 | | backlog lines | 3,319 | | lessons lines | 532 | diff --git a/src/core/system/HttpServerModule.cpp b/src/core/system/HttpServerModule.cpp index 744d6ce48..e7eb346af 100644 --- a/src/core/system/HttpServerModule.cpp +++ b/src/core/system/HttpServerModule.cpp @@ -759,7 +759,7 @@ void HttpServerModule::handleRemoveEntry(platform::TcpConnection& conn, const ch } } -// Stream a file to the socket in 1 KB chunks with a Content-Length: no whole-file buffer, and NUL-safe where sendResponse's strlen is not; `extraHeaders` carries caller lines. +// Stream a file in 1 KB chunks, NUL-safe; an /sd download claims its slot before any response, as an upload already does. void HttpServerModule::streamFsFile(platform::TcpConnection& conn, const char* path, const char* mime, const char* extraHeaders) { // The saved config holds the WiFi passwords in plain text, which a client on the access point does not see. @@ -773,12 +773,19 @@ void HttpServerModule::streamFsFile(platform::TcpConnection& conn, const char* p const int hn = std::snprintf(header, sizeof(header), "HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %ld\r\n%s" "Connection: close\r\nAccess-Control-Allow-Origin: *\r\n\r\n", mime, size, extraHeaders); - if (!conn.write(reinterpret_cast(header), static_cast(hn))) return; - // An /sd download hands off to the chunked driver, which pulls one bounded slice per tick instead of looping here to completion; the header above already committed to this Content-Length. + // An /sd download hands off to the chunked driver, one bounded slice per tick. Claimed and opened before the header, so a busy slot or a failed open answers 503/500, not a 200 with no body. if (isSdPath(path)) { SdTransfer* t = claimSdTransfer(); - if (!t || !platform::fsReadStreamOpen(t->rstream, path)) { conn.close(); return; } + if (!t) { sendResponse(conn, 503, "application/json", "{\"error\":\"another SD transfer is in progress\"}"); return; } + if (!platform::fsReadStreamOpen(t->rstream, path)) { + sendResponse(conn, 500, "application/json", "{\"error\":\"read failed\"}"); + return; + } + if (!conn.write(reinterpret_cast(header), static_cast(hn))) { + platform::fsReadStreamClose(t->rstream); + return; + } t->isUpload = false; t->remaining = static_cast(size); t->bufLen = t->bufSent = 0; @@ -788,6 +795,7 @@ void HttpServerModule::streamFsFile(platform::TcpConnection& conn, const char* p return; } + if (!conn.write(reinterpret_cast(header), static_cast(hn))) return; platform::FsReadStream rs{}; if (!platform::fsReadStreamOpen(rs, path)) return; // the header already promised a body; best effort stops here char chunk[1024]; diff --git a/test/unit/core/unit_HttpServerModule_sdcard.cpp b/test/unit/core/unit_HttpServerModule_sdcard.cpp index 202d91fab..c15928cfa 100644 --- a/test/unit/core/unit_HttpServerModule_sdcard.cpp +++ b/test/unit/core/unit_HttpServerModule_sdcard.cpp @@ -66,6 +66,16 @@ void claimStuckUpload(mm::HttpServerModule& http, mm::platform::TcpConnection& c http.tick20ms(); } +// Connects and GETs `path`, returning the response once at least `wantAtLeast` bytes have arrived. +std::string getAndWait(mm::HttpServerModule& http, mm::platform::TcpConnection& client, const char* path, + size_t wantAtLeast = 12) { + REQUIRE(connectAndAccept(http, client)); + char req[160]; + const int n = std::snprintf(req, sizeof(req), "GET /api/file?path=%s HTTP/1.1\r\n\r\n", path); + REQUIRE(client.write(reinterpret_cast(req), static_cast(n))); + return pumpUntil(http, client, wantAtLeast); +} + // Mounts a real card under a fresh temp dir for one test, and tears it down after. struct SdFixture { char root[256]; @@ -212,6 +222,29 @@ TEST_CASE("an SD download larger than one tick's chunk streams across several ti http.release(); } +// Point 8 of the review: streamFsFile sent the 200 header before claiming a slot, so a busy slot answered with a body a dead connection never delivered. +TEST_CASE("an SD download with no free transfer slot answers 503, not a broken 200") { + SdFixture fix; + REQUIRE(mm::platform::fsWriteAtomic("/sd/noslot.bin", "x", 1)); + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + + // Both slots held by stuck uploads: none free for the download below. + mm::platform::TcpConnection busy1, busy2; + claimStuckUpload(http, busy1, "/sd/busy1.bin"); + claimStuckUpload(http, busy2, "/sd/busy2.bin"); + + mm::platform::TcpConnection client; + const std::string reply = getAndWait(http, client, "/sd/noslot.bin"); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 503", 12) == 0); + + client.close(); + busy1.close(); + busy2.close(); + http.release(); +} + TEST_CASE("an SD upload that stalls mid-transfer is closed and its slot frees for the next one") { struct Clock { ~Clock() { mm::platform::setTestNowMs(0); } } clock; mm::platform::setTestNowMs(1000); From d732957d4aca15b04256365122f03d48cae36f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 12 Oct 2026 09:59:27 +0200 Subject: [PATCH 12/20] Widen fsSize past the 2 GiB a 32-bit long can hold signed PR #136 review item 9 (yours, lower priority): fsSize and the SD-side sdCardSize behind it returned long, 32-bit and signed on ESP32. A real file at or past 2 GiB-1 (well inside FAT32's own 4 GiB-1 ceiling) overflowed that into a negative number, which every caller's `< 0` check then read as "missing" rather than "large" - the exact same shape point 4 of the #134 review already fixed for sdCardUsed/sdCardTotal, widened to uint64_t for the same reason. fsSize carries a -1 sentinel, so its fix widens to the signed int64_t rather than unsigned. Flash: esp32p4rev1-eth +120 B (2328842 -> 2328962). desktop-linux unchanged. Account: src +22/-22 lines | test +17/-3 lines | duplicated lines 3738 -> 3738 (+0) | code findings 1630 -> 1630 (+0) Core: - platform.h: fsSize widened to int64_t; FsWriteSrc and the stream primitives untouched, since a single bounded chunk never approaches the 32-bit ceiling even where the whole file does. - platform_esp32_fs.cpp, platform_desktop.cpp: their fsSize implementations' narrowing casts (`static_cast`) widened to int64_t. The desktop one was already correct on Linux (64-bit long) but not on Windows, where long stays 32-bit even in a 64-bit build - a second, previously silent instance of the same bug, fixed by the same change. - platform_esp32_sdmmc.h/.cpp: sdCardSize widened the same way - FatFs's own FSIZE_t already holds the real value correctly; only the narrowing return cast was throwing it away. - HttpServerModule.cpp, FilesystemModule.cpp, ControlModule.h, MoonLiveScriptFile.h: every `const long size = platform::fsSize(...)` local widened to int64_t to match, and the two Content-Length/log %ld format specifiers that printed one widened to %lld with an explicit cast - the same pairing other wide values in this codebase already use (JsonSink's %lld, the SD free-space message's %llu). Tests: - unit_FileManagerModule.cpp: a new case resizes a real file to 3,000,000,000 bytes (a sparse resize, not a real multi-gigabyte write) and checks fsSize reports it exactly, not negative - the case that overflowed a 32-bit signed long before this change. Checks (scoped to this diff's own trigger-table buckets - src/ and test/ only): - check_specs: PASS - check_prose: PASS - 0 new findings (one pre-existing, unrelated finding in docs/reference/hardware/gpio-usage.md, not touched by this diff) - check_docgen: PASS - 0 errors, 0 net new warnings (matches the committed baseline exactly) - check_code: PASS - 0 net new findings - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2439/2439 (1 new) - run_scenario: PASS - FileManagerModule (1/1), ControlModule (2/2), FilesystemModule (2/2) - check_esp32_built: PASS - fresh after the real rebuild+flash - collect_kpi: PASS on the account above; the live ESP32 tick/FPS reading reflects whatever demo config is already on the bench board, not this diff - Real hardware (esp32p4rev1-eth, serial): rebuilt, reflashed, verified live - a flash-file and an SD-file round trip both came back byte-exact, no crash. Reproducing the exact >2 GiB boundary on this bench setup was not practical (the throttled transfer rate measured earlier this session, ~98 KB/s, would take hours to move 2+ GB); that exact boundary is proven on desktop instead, where the fix is the same portable int64_t arithmetic on both platforms, unlike a chip-specific driver call. --- docs/reference/metrics/repo-health.json | 20 ++++++++-------- docs/reference/metrics/repo-health.md | 24 +++++++++---------- src/core/moonlive/MoonLiveScriptFile.h | 8 +++---- src/core/system/ControlModule.h | 4 ++-- src/core/system/FilesystemModule.cpp | 4 ++-- src/core/system/HttpServerModule.cpp | 6 ++--- src/platform/desktop/platform_desktop.cpp | 4 ++-- src/platform/esp32/platform_esp32_fs.cpp | 4 ++-- src/platform/esp32/platform_esp32_sdmmc.cpp | 6 ++--- src/platform/esp32/platform_esp32_sdmmc.h | 4 ++-- src/platform/platform.h | 4 ++-- test/unit/core/unit_ControlModule.cpp | 2 +- test/unit/core/unit_FileManagerModule.cpp | 16 ++++++++++++- .../core/unit_HttpServerModule_sdcard.cpp | 2 +- 14 files changed, 61 insertions(+), 47 deletions(-) diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 897501492..e8b47eeca 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "0f1e9e90", + "commit": "66569ad5", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, - "esp32p4rev1-eth": 2329296, + "esp32p4rev1-eth": 2329424, "esp32p4rev1-eth-wifi": 2358896, "esp32s3-n8r8": 2336304, "esp32s31": 2595200, @@ -38,9 +38,9 @@ }, "perf": { "esp32": { - "tick_us": 1716, - "fps": 582, - "heap_free": 33827003 + "tick_us": 1714, + "fps": 583, + "heap_free": 33826947 }, "scenario_matrix": { "Control_a_palette_preset_changes_only_the_palette": { @@ -683,8 +683,8 @@ } }, "desktop-linux": { - "tick_us": 5, - "fps": 200000 + "tick_us": 6, + "fps": 166666 } }, "loc": { @@ -692,7 +692,7 @@ "light": 31027, "platform": 18695, "ui": 12350, - "test": 65260, + "test": 65274, "moondeck": 30600 }, "comments": { @@ -713,7 +713,7 @@ "ratio": 0.298 }, "test": { - "lines": 7005, + "lines": 7006, "ratio": 0.124 }, "moondeck": { @@ -722,7 +722,7 @@ } }, "tests": { - "cases": 2426, + "cases": 2427, "scenarios": 30 }, "docs": { diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 324ad56c0..5031a9a6c 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `0f1e9e90`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `66569ad5`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -10,7 +10,7 @@ Current state only; the trend is this file's git history (`git log -p docs/refer |---|---:|---:|---:| | flash desktop-macos | 1,963 KB | 2,217 KB | +254.2 KB (+12.9%) | | flash esp32 | 2,061 KB | 2,174 KB | +112.9 KB (+5.5%) | -| flash esp32p4rev1-eth | 2,022 KB | 2,275 KB | +253.0 KB (+12.5%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,275 KB | +253.1 KB (+12.5%) | | flash esp32p4rev3-eth | 2,025 KB | 2,152 KB | +126.8 KB (+6.3%) | | flash esp32s3-n16r8 | 2,111 KB | 2,282 KB | +170.1 KB (+8.1%) | | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | @@ -20,9 +20,9 @@ Current state only; the trend is this file's git history (`git log -p docs/refer | lines light | 31,054 | 31,027 | -27 (-0.1%) | | lines platform | 16,798 | 18,695 | +1,897 (+11.3%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | -| lines test | 57,759 | 65,260 | +7,501 (+13.0%) | +| lines test | 57,759 | 65,274 | +7,515 (+13.0%) | | lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | -| unit tests | 2,120 | 2,426 | +306 (+14.4%) | +| unit tests | 2,120 | 2,427 | +307 (+14.5%) | | scenarios | 21 | 30 | +9 (+42.9%) | Against the release `v6.0.0`, the fixed point a user compares with; the tables below compare against the last commit. A firmware not rebuilt since `v6.0.0` is left out, and a measure the release did not record yet, such as the code report, joins from the next one. @@ -55,11 +55,11 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Net | New | Removed | Grown | Shrunk | Other | Since | |---|---:|---:|---:|---:|---:|---:|---| -| esp32p4rev1-eth | +0.1 KB | +0.0 KB | +0.0 KB | +0.1 KB | +0.0 KB | +0.0 KB | `0f1e9e90` | +| esp32p4rev1-eth | +0.1 KB | +0.0 KB | +0.0 KB | +0.1 KB | -0.0 KB | +0.0 KB | `66569ad5` | Largest moves: -- **esp32p4rev1-eth**: `mm::HttpServerModule::streamFsFile(mm::platform::TcpConnecti` +116 B +- **esp32p4rev1-eth**: `mm_light_step` -32 B, `mm::ControlModule::writeSlot(mm::ControlModule::Preset const` +30 B, `mm::moonlive::noteForkedFrom(char const*)` +30 B, `mm::FilesystemModule::readWholeFile(char const*)` +14 B, `mm::HttpServerModule::applyWledState(char const*)` +10 B What this commit's flash went to, symbol by symbol against the measurement at `Since`. New is new functionality, removed and shrunk are what a cleanup saved, and other is what no symbol owns, such as merged strings and alignment. A rename reads as removed plus new, which the largest moves expose. @@ -67,10 +67,10 @@ What this commit's flash went to, symbol by symbol against the measurement at `S | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 5 ยตs (โˆ’1 ยตs) โœ“ | 200,000 (+33,334) โœ“ | +| desktop-linux | 6 ยตs (+1 ยตs) โš  | 166,666 (โˆ’33,334) โš  | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | -| esp32 | 1,716 ยตs | 582 | +| esp32 | 1,714 ยตs (โˆ’2 ยตs) โœ“ | 583 (+1) โœ“ | ### Scenario tick by target (p50 of each sample window) @@ -122,18 +122,18 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,733 (+8) โš  | 6,249 | 24.1 % | +| core | 28,733 | 6,249 | 24.1 % | | light | 31,027 | 7,439 | 26.9 % | | platform | 18,695 | 3,782 | 22.6 % | | ui | 12,350 | 3,472 | 29.8 % | -| test | 65,260 (+33) โš  | 7,005 | 12.4 % | +| test | 65,274 (+14) โš  | 7,006 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,426 (+1) โœ“ | +| unit cases | 2,427 (+1) โœ“ | | scenarios | 30 | ## Complexity @@ -149,7 +149,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 150 | -| markdown lines | 32,295 (โˆ’2) โœ“ | +| markdown lines | 32,295 | | plan files | 44 | | backlog lines | 3,319 | | lessons lines | 532 | diff --git a/src/core/moonlive/MoonLiveScriptFile.h b/src/core/moonlive/MoonLiveScriptFile.h index d94e00232..4c23057d3 100644 --- a/src/core/moonlive/MoonLiveScriptFile.h +++ b/src/core/moonlive/MoonLiveScriptFile.h @@ -149,7 +149,7 @@ inline void noteForkedFrom(const char* path) { char factory[96]; std::snprintf(factory, sizeof(factory), "%s/%s", kFactoryScriptDir, name); - const long size = platform::fsSize(factory); + const int64_t size = platform::fsSize(factory); if (size <= 0 || size > kScriptFileMax) return; // nothing shipped: not a fork char* text = static_cast(platform::alloc(static_cast(size) + 1)); if (!text) return; @@ -165,7 +165,7 @@ inline bool scriptFactoryMovedOn(const char* name) { if (!scriptLineage(name, from)) return false; char path[96]; std::snprintf(path, sizeof(path), "%s/%s", kFactoryScriptDir, name); - const long size = platform::fsSize(path); + const int64_t size = platform::fsSize(path); if (size <= 0 || size > kScriptFileMax) return false; char* text = static_cast(platform::alloc(static_cast(size) + 1)); if (!text) return false; @@ -189,7 +189,7 @@ inline bool scriptFileHash(const char* name, uint32_t& out) { if (!name || !name[0]) return false; char path[96]; if (!resolveScript(name, path, sizeof(path))) return false; - const long size = platform::fsSize(path); + const int64_t size = platform::fsSize(path); if (size <= 0 || size > kScriptFileMax) return false; // One read: a chunked walk opens and closes per call, which boot-looped a P4. @@ -233,7 +233,7 @@ inline bool compileScriptFile(MoonLive& engine, const char* name, char path[96]; resolveScript(name, path, sizeof(path)); - const long size = platform::fsSize(path); + const int64_t size = platform::fsSize(path); if (size < 0) { err = "script not found"; return false; } if (size == 0) { err = "script is empty"; return false; } if (size > kScriptFileMax) { err = "script too large"; return false; } diff --git a/src/core/system/ControlModule.h b/src/core/system/ControlModule.h index ef2d6aa5c..9f912162f 100644 --- a/src/core/system/ControlModule.h +++ b/src/core/system/ControlModule.h @@ -971,7 +971,7 @@ class ControlModule : public MoonModule, public ListSource { /// A preset file whole, on the heap (caller frees), or null when absent, empty or past the cap. static char* readFile(const char* path) { - const long size = platform::fsSize(path); + const int64_t size = platform::fsSize(path); if (size <= 0 || static_cast(size) > kStateDocumentMax) return nullptr; char* body = static_cast(platform::alloc(static_cast(size) + 1)); if (!body) return nullptr; @@ -1134,7 +1134,7 @@ class ControlModule : public MoonModule, public ListSource { setStatusf(Severity::Warning, "%s already exists", to); return false; } - const long size = platform::fsSize(src); + const int64_t size = platform::fsSize(src); if (size <= 0) return false; char* buf = static_cast(platform::alloc(static_cast(size) + 1)); if (!buf) return false; diff --git a/src/core/system/FilesystemModule.cpp b/src/core/system/FilesystemModule.cpp index 6bec1ebf1..c4fc26562 100644 --- a/src/core/system/FilesystemModule.cpp +++ b/src/core/system/FilesystemModule.cpp @@ -199,10 +199,10 @@ void FilesystemModule::loadAll(Scheduler* s) { // No fixed ceiling, so a large saved config loads in full, mirroring the streaming save (saveSubtree). char* FilesystemModule::readWholeFile(const char* path) { - const long size = platform::fsSize(path); + const int64_t size = platform::fsSize(path); if (size <= 0) return nullptr; char* buf = static_cast(platform::alloc(static_cast(size) + 1)); - if (!buf) { std::printf("FilesystemModule: out of memory loading %s (%ld bytes)\n", path, size); return nullptr; } + if (!buf) { std::printf("FilesystemModule: out of memory loading %s (%lld bytes)\n", path, static_cast(size)); return nullptr; } const int n = platform::fsRead(path, buf, static_cast(size) + 1); if (n <= 0) { platform::free(buf); return nullptr; } buf[n] = '\0'; // parsed as a C-string diff --git a/src/core/system/HttpServerModule.cpp b/src/core/system/HttpServerModule.cpp index e7eb346af..6c1d322fa 100644 --- a/src/core/system/HttpServerModule.cpp +++ b/src/core/system/HttpServerModule.cpp @@ -767,12 +767,12 @@ void HttpServerModule::streamFsFile(platform::TcpConnection& conn, const char* p sendResponse(conn, 403, "application/json", "{\"error\":\"config files are not served through the access point\"}"); return; } - const long size = platform::fsSize(path); + const int64_t size = platform::fsSize(path); if (size < 0) { sendResponse(conn, 404, "application/json", "{\"error\":\"not found\"}"); return; } char header[224]; const int hn = std::snprintf(header, sizeof(header), - "HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %ld\r\n%s" - "Connection: close\r\nAccess-Control-Allow-Origin: *\r\n\r\n", mime, size, extraHeaders); + "HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %lld\r\n%s" + "Connection: close\r\nAccess-Control-Allow-Origin: *\r\n\r\n", mime, static_cast(size), extraHeaders); // An /sd download hands off to the chunked driver, one bounded slice per tick. Claimed and opened before the header, so a busy slot or a failed open answers 503/500, not a 200 with no body. if (isSdPath(path)) { diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 319ac01d6..626e2714a 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -1011,12 +1011,12 @@ bool fsWriteAtomic(const char* path, const char* data, size_t len) { return true; } -long fsSize(const char* path) { +int64_t fsSize(const char* path) { std::error_code ec; auto p = toFsPath(path); if (!std::filesystem::is_regular_file(p, ec)) return -1; const auto sz = std::filesystem::file_size(p, ec); - return ec ? -1 : static_cast(sz); + return ec ? -1 : static_cast(sz); } namespace { diff --git a/src/platform/esp32/platform_esp32_fs.cpp b/src/platform/esp32/platform_esp32_fs.cpp index af692c649..05b00aa4c 100644 --- a/src/platform/esp32/platform_esp32_fs.cpp +++ b/src/platform/esp32/platform_esp32_fs.cpp @@ -199,14 +199,14 @@ bool fsWriteAtomic(const char* path, const char* data, size_t len) { return true; } -long fsSize(const char* path) { +int64_t fsSize(const char* path) { if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardSize(path); } if (!fsMounted_) return -1; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return -1; struct stat st; if (::stat(full, &st) != 0 || S_ISDIR(st.st_mode)) return -1; - return static_cast(st.st_size); + return static_cast(st.st_size); } void fsList(const char* dir, FsListCb cb, void* user) { diff --git a/src/platform/esp32/platform_esp32_sdmmc.cpp b/src/platform/esp32/platform_esp32_sdmmc.cpp index ad6f9ae3a..f5b8758e1 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.cpp +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -264,13 +264,13 @@ int sdCardRead(const char* apiPath, char* buf, size_t maxLen) { return static_cast(br); } -long sdCardSize(const char* apiPath) { +int64_t sdCardSize(const char* apiPath) { if (!g_mounted) return -1; char full[128]; if (!sdTranslate(apiPath, full, sizeof(full))) return -1; FILINFO fno; if (f_stat(full, &fno) != FR_OK || (fno.fattrib & AM_DIR)) return -1; - return static_cast(fno.fsize); + return static_cast(fno.fsize); } /// The folder a write lands in, made first, as the write's contract promises. @@ -409,7 +409,7 @@ bool sdCardMkdir(const char*) { return false; } bool sdCardExists(const char*) { return false; } bool sdCardRemove(const char*) { return false; } int sdCardRead(const char*, char*, size_t) { return -1; } -long sdCardSize(const char*) { return -1; } +int64_t sdCardSize(const char*) { return -1; } bool sdCardWriteAtomic(const char*, const char*, size_t) { return false; } void sdCardList(const char*, FsListCb, void*) {} bool sdCardReadStreamOpen(void*&, const char*) { return false; } diff --git a/src/platform/esp32/platform_esp32_sdmmc.h b/src/platform/esp32/platform_esp32_sdmmc.h index c86e48dd2..93d1ac311 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.h +++ b/src/platform/esp32/platform_esp32_sdmmc.h @@ -17,8 +17,8 @@ bool sdCardExists(const char* apiPath); bool sdCardRemove(const char* apiPath); /// Read a whole file from the card; bytes read, or -1 on error, null-terminated on success. int sdCardRead(const char* apiPath, char* buf, size_t maxLen); -/// A card file's size in bytes, or -1 when it is missing or not a file. -long sdCardSize(const char* apiPath); +/// A card file's size in bytes, or -1 when missing; wide for a file past 2 GiB, which FAT32 (to 4 GiB-1) allows. +int64_t sdCardSize(const char* apiPath); /// Write a whole file atomically onto the card, through a temporary and a rename. bool sdCardWriteAtomic(const char* apiPath, const char* data, size_t len); /// List one level of a card directory, calling `cb` per child. diff --git a/src/platform/platform.h b/src/platform/platform.h index 38784ec5d..309c2e43c 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -281,8 +281,8 @@ bool fsExists(const char* path); bool fsRemove(const char* path); // file or empty dir /// Read a whole file; bytes read, or -1 on error, null-terminated on success. int fsRead(const char* path, char* buf, size_t maxLen); -/// A file's size in bytes, or -1 when it is missing or not a file. -long fsSize(const char* path); +/// A file's size in bytes, or -1 when missing; wide since a real card file can pass 2 GiB, more than a 32-bit long holds signed. +int64_t fsSize(const char* path); /// Write a whole file atomically, through a temporary and a rename, creating its parent folder. bool fsWriteAtomic(const char* path, const char* data, size_t len); /// Fill up to `cap` bytes and answer the count; 0 ends the stream. diff --git a/test/unit/core/unit_ControlModule.cpp b/test/unit/core/unit_ControlModule.cpp index abd682e22..375b6ba0f 100644 --- a/test/unit/core/unit_ControlModule.cpp +++ b/test/unit/core/unit_ControlModule.cpp @@ -846,7 +846,7 @@ TEST_CASE("ControlModule persists the look a preset applied") { d.fs->flush(); char path[160]; std::snprintf(path, sizeof(path), "/.config/%s.json", d.layers->typeName()); - const long size = mm::platform::fsSize(path); + const int64_t size = mm::platform::fsSize(path); REQUIRE(size > 0); std::string body(static_cast(size) + 1, '\0'); REQUIRE(mm::platform::fsRead(path, body.data(), body.size()) > 0); diff --git a/test/unit/core/unit_FileManagerModule.cpp b/test/unit/core/unit_FileManagerModule.cpp index 6e40295c5..769430d0e 100644 --- a/test/unit/core/unit_FileManagerModule.cpp +++ b/test/unit/core/unit_FileManagerModule.cpp @@ -79,6 +79,20 @@ TEST_CASE("A file written into a folder that does not exist yet creates the fold CHECK(platform::fsExists("/fresh/b/two.txt")); } +// Point 9 of the review: a 32-bit signed long overflows past 2 GiB-1, so a large real file used to report a negative fsSize and read as missing. +TEST_CASE("fsSize reports a file past 2 GiB correctly, not as negative") { + Rig r; + const std::string path = std::string(r.root) + "/big.bin"; + FILE* f = std::fopen(path.c_str(), "w"); + REQUIRE(f != nullptr); + std::fclose(f); + constexpr int64_t kPast2GiB = 3000000000LL; // > INT32_MAX, well under FAT32's 4 GiB-1 ceiling + std::error_code ec; + std::filesystem::resize_file(path, static_cast(kPast2GiB), ec); // sparse: instant, no real write + REQUIRE_FALSE(ec); + CHECK(platform::fsSize("/big.bin") == kPast2GiB); +} + TEST_CASE("FileManager: mkdir creates a dir at the target path; delete removes it") { Rig r; CHECK(mkdirOp("/newdir")); @@ -169,7 +183,7 @@ TEST_CASE("FileManager: fsWriteStream writes a multi-chunk NUL-containing payloa payload[2999] = 'Z'; SpanSrc src{payload.data(), payload.size()}; REQUIRE(platform::fsWriteStream("/blob.bin", &spanPull, &src)); - CHECK(platform::fsSize("/blob.bin") == static_cast(payload.size())); + CHECK(platform::fsSize("/blob.bin") == static_cast(payload.size())); // Read it back across a chunk boundary via the kept-open stream, the streamed-download contract. platform::FsReadStream rs{}; diff --git a/test/unit/core/unit_HttpServerModule_sdcard.cpp b/test/unit/core/unit_HttpServerModule_sdcard.cpp index c15928cfa..e76145a78 100644 --- a/test/unit/core/unit_HttpServerModule_sdcard.cpp +++ b/test/unit/core/unit_HttpServerModule_sdcard.cpp @@ -174,7 +174,7 @@ TEST_CASE("an SD upload larger than one tick's chunk streams across several tick const std::string reply = pumpUntil(http, client, 12); CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) == 0); - CHECK(mm::platform::fsSize("/sd/big.bin") == static_cast(payload.size())); + CHECK(mm::platform::fsSize("/sd/big.bin") == static_cast(payload.size())); std::vector back(payload.size()); mm::platform::FsReadStream rs{}; REQUIRE(mm::platform::fsReadStreamOpen(rs, "/sd/big.bin")); From 137dc3adfcb69b4b2c3b30b5d4dcac30584b4f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 12 Oct 2026 10:50:03 +0200 Subject: [PATCH 13/20] Correct a stale comment on the card's write-stream close PR #136 review item 10 (yours, lower priority): sdCardWriteStreamClose's own doc comment said the rename-or-discard on commit was "the caller's, in platform_esp32_fs.cpp" - the function does it itself (sdCommitTmp on commit, f_unlink to discard otherwise), and the caller's own inline comment at its call site already said so correctly ("owns its own rename/discard"). The two comments, describing the same function from either side of the call, contradicted each other; only the declaration's was wrong. Flash: esp32p4rev1-eth unchanged (comment-only, byte-identical image). desktop-linux unchanged. Account: src +1/-1 lines | duplicated lines 3738 -> 3738 (+0) | code findings 1630 -> 1630 (+0) Core: - platform_esp32_sdmmc.h: sdCardWriteStreamClose's doc comment now says what the function actually does, matching its caller's own already-correct comment instead of contradicting it. Checked the rest of the SD backend's "the caller's" phrasing for the same contradiction; found none. Checks (scoped to this diff's own trigger-table buckets - src/ and test/ only): - check_specs: PASS - check_prose: PASS - 0 new findings (one pre-existing, unrelated finding in docs/reference/hardware/gpio-usage.md, not touched by this diff) - check_docgen: PASS - 0 errors, 0 net new warnings (matches the committed baseline exactly) - check_code: PASS - 0 net new findings - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2439/2439 (no new case: a comment-only fix pins nothing new, the behavior it now correctly describes was already covered) - run_scenario: SKIP - comment-only change with no behavior affected, in a file (platform_esp32_sdmmc.h) never compiled into the desktop target scenarios run against - check_esp32_built: PASS - fresh after a real rebuild - collect_kpi: PASS on the account above; flash is byte-identical to the last build, confirming the change is comment-only - Real hardware: skipped deliberately - the rebuilt image is byte-for-byte identical to the one already flashed and verified live for item 9, so reflashing would verify nothing a byte-identical binary doesn't already prove. --- docs/reference/metrics/repo-health.json | 6 +++--- docs/reference/metrics/repo-health.md | 16 ++++++++-------- src/platform/esp32/platform_esp32_sdmmc.h | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index e8b47eeca..a23b3ce68 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,5 +1,5 @@ { - "commit": "66569ad5", + "commit": "d732957d", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, @@ -683,8 +683,8 @@ } }, "desktop-linux": { - "tick_us": 6, - "fps": 166666 + "tick_us": 7, + "fps": 142857 } }, "loc": { diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 5031a9a6c..c0b80ff80 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `66569ad5`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `d732957d`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -40,7 +40,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 33d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | | esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 2d | -| esp32p4rev1-eth | 2,275 KB (+128 B) โš  | 4,096 KB | 56% | yes | +| esp32p4rev1-eth | 2,275 KB | 4,096 KB | 56% | yes | | esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 12d** | | esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 4d | | esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 2d | @@ -55,11 +55,11 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Net | New | Removed | Grown | Shrunk | Other | Since | |---|---:|---:|---:|---:|---:|---:|---| -| esp32p4rev1-eth | +0.1 KB | +0.0 KB | +0.0 KB | +0.1 KB | -0.0 KB | +0.0 KB | `66569ad5` | +| esp32p4rev1-eth | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `d732957d` | Largest moves: -- **esp32p4rev1-eth**: `mm_light_step` -32 B, `mm::ControlModule::writeSlot(mm::ControlModule::Preset const` +30 B, `mm::moonlive::noteForkedFrom(char const*)` +30 B, `mm::FilesystemModule::readWholeFile(char const*)` +14 B, `mm::HttpServerModule::applyWledState(char const*)` +10 B +- **esp32p4rev1-eth**: none What this commit's flash went to, symbol by symbol against the measurement at `Since`. New is new functionality, removed and shrunk are what a cleanup saved, and other is what no symbol owns, such as merged strings and alignment. A rename reads as removed plus new, which the largest moves expose. @@ -67,10 +67,10 @@ What this commit's flash went to, symbol by symbol against the measurement at `S | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 6 ยตs (+1 ยตs) โš  | 166,666 (โˆ’33,334) โš  | +| desktop-linux | 7 ยตs (+1 ยตs) โš  | 142,857 (โˆ’23,809) โš  | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | -| esp32 | 1,714 ยตs (โˆ’2 ยตs) โœ“ | 583 (+1) โœ“ | +| esp32 | 1,714 ยตs | 583 | ### Scenario tick by target (p50 of each sample window) @@ -126,14 +126,14 @@ These build a bare pipeline with no optional modules, so a change here is a chan | light | 31,027 | 7,439 | 26.9 % | | platform | 18,695 | 3,782 | 22.6 % | | ui | 12,350 | 3,472 | 29.8 % | -| test | 65,274 (+14) โš  | 7,006 | 12.4 % | +| test | 65,274 | 7,006 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,427 (+1) โœ“ | +| unit cases | 2,427 | | scenarios | 30 | ## Complexity diff --git a/src/platform/esp32/platform_esp32_sdmmc.h b/src/platform/esp32/platform_esp32_sdmmc.h index 93d1ac311..fc8dab9c1 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.h +++ b/src/platform/esp32/platform_esp32_sdmmc.h @@ -35,7 +35,7 @@ void sdCardReadStreamClose(void* handle); bool sdCardWriteStreamOpen(void*& handle, const char* apiPath); /// Append one chunk to a card write handle; false on a write failure. bool sdCardWriteStreamWrite(void* handle, const char* buf, size_t len); -/// Flush and close a card write handle; the rename-or-discard on `commit` is the caller's, in platform_esp32_fs.cpp. +/// Flush and close a card write handle, renaming the temporary onto its real name on `commit`, discarding it otherwise. bool sdCardWriteStreamClose(void* handle, bool commit); } // namespace mm::platform From 577d09d264fc83bb76ba47fa3f877f9f5d23f37b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 12 Oct 2026 12:27:55 +0200 Subject: [PATCH 14/20] Consolidate the five /sd prefix checks into one platform::isSdPath PR #136 review item 11 (yours, lower priority): the "/sd" path-prefix check existed five times - ESP32's own isSdApiPath, desktop's own isSdApiPath plus a second pathIsSd wrapper over it, and HttpServerModule.cpp's own isSdPath, each a near-identical hand-rolled comparison; a fifth, fmIsSdPath, lives in app.js and stays there since JS can't share C++. ewowi: "One platform::isSdPath serves the C++ sites." Flash: esp32p4rev1-eth +16 B (2328962 -> 2328978). desktop-linux unchanged. Account: src +21/-38 lines | duplicated lines 3738 -> 3738 (+0) | code findings 1630 -> 1630 (+0) Core: - platform.h: new inline platform::isSdPath(path), accepting a leading slash or not ("sd", "/sd", "sd/foo", "/sd/foo") - the most permissive of the four shapes the old copies used, so every existing caller's actual input still matches. - platform_esp32_fs.cpp, platform_desktop.cpp: their own isSdApiPath (and desktop's extra pathIsSd wrapper) are gone; every call site now reaches the one shared platform::isSdPath directly (unqualified, since both files already sit in namespace mm::platform). - HttpServerModule.cpp: its own static isSdPath is gone; its two call sites now call platform::isSdPath. requestNamesSdPath, a deliberately different raw-request-line heuristic (sizes the Content-Length ceiling before the real path is even decoded), stays as its own thing - consolidating it in would blur a real distinction its own comment already explains. Checks (scoped to this diff's own trigger-table buckets - src/ and test/ only): - check_specs: PASS - check_prose: PASS - 0 new findings (one pre-existing, unrelated finding in docs/reference/hardware/gpio-usage.md, not touched by this diff; caught and fixed an em-dash in my own first draft of the new comment before this run) - check_docgen: PASS - 0 errors, 0 net new warnings (matches the committed baseline exactly) - check_code: PASS - 0 net new findings - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2439/2439 (no new case: a pure consolidation of four equivalent checks into one pins nothing new, every existing /sd-path test already exercises the merged function) - run_scenario: PASS - HttpServerModule (1/1, 1 pre-existing unrelated skip), FileManagerModule (1/1) - check_esp32_built: PASS - fresh after the real rebuild+flash - collect_kpi: PASS on the account above; the live ESP32 tick/FPS reading reflects whatever demo config is already on the bench board, not this diff - Real hardware (esp32p4rev1-eth, serial): rebuilt, reflashed, verified live - a flash-file and an SD-file round trip both came back byte-exact, the root listing still advertises "sd", no crash. --- docs/reference/metrics/code.md | 4 ++-- docs/reference/metrics/repo-health.json | 24 +++++++++++----------- docs/reference/metrics/repo-health.md | 22 ++++++++++---------- src/core/system/HttpServerModule.cpp | 10 ++------- src/platform/desktop/platform_desktop.cpp | 17 +++------------ src/platform/esp32/platform_esp32_fs.cpp | 25 ++++++++--------------- src/platform/platform.h | 7 +++++++ 7 files changed, 46 insertions(+), 63 deletions(-) diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index 422065d87..130a05fb4 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -60,7 +60,7 @@ The next function worth simplifying is at the top of each file's list. | File | Findings | Worst | |---|---:|---| | `src/core/system/HttpServerModule.cpp` | 45 | `HttpServerModule::handleConnection`, complex function 123 | -| `src/platform/desktop/platform_desktop.cpp` | 44 | `platform::getMacAddress, line 1384`, large stack frame 5240 | +| `src/platform/desktop/platform_desktop.cpp` | 44 | `platform::getMacAddress, line 1373`, large stack frame 5240 | | `src/ui/migrate.js` | 43 | `line 29: 'I2cBusModule'`, module type in the UI 1 | | `src/core/system/MqttModule.cpp` | 31 | `MqttModule::routePublish`, complex function 29 | | `src/ui/app.js` | 29 | `app.js`, large file 9076 | @@ -349,7 +349,7 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/platform_esp32_i2s.cpp` | 1 | `platform::audioMicInit`, complex function 11 | | `src/platform/esp32/platform_esp32_sdmmc.cpp` | 1 | `platform::sdMount`, complex function 22 | | `src/platform/esp32/platform_esp32_tasks.cpp` | 1 | `platform::taskSnapshot`, complex function 12 | -| `src/platform/platform.h` | 1 | `platform.h`, large file 1375 | +| `src/platform/platform.h` | 1 | `platform.h`, large file 1382 | | `src/ui/install-picker.js` | 1 | `install-picker.js`, large file 1048 | | `src/ui/preview3d.js` | 1 | `preview3d.js`, large file 1431 | | `test/js/installer-eth-only.test.mjs` | 1 | `lines 13-23, also in test/js/ui-mooncloud.test.mjs:6`, duplicated block 11 | diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index a23b3ce68..cfa030242 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "d732957d", + "commit": "137dc3ad", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, - "esp32p4rev1-eth": 2329424, + "esp32p4rev1-eth": 2329440, "esp32p4rev1-eth-wifi": 2358896, "esp32s3-n8r8": 2336304, "esp32s31": 2595200, @@ -38,9 +38,9 @@ }, "perf": { "esp32": { - "tick_us": 1714, - "fps": 583, - "heap_free": 33826947 + "tick_us": 1716, + "fps": 582, + "heap_free": 33826927 }, "scenario_matrix": { "Control_a_palette_preset_changes_only_the_palette": { @@ -683,21 +683,21 @@ } }, "desktop-linux": { - "tick_us": 7, - "fps": 142857 + "tick_us": 5, + "fps": 200000 } }, "loc": { - "core": 28733, + "core": 28727, "light": 31027, - "platform": 18695, + "platform": 18684, "ui": 12350, "test": 65274, "moondeck": 30600 }, "comments": { "core": { - "lines": 6249, + "lines": 6248, "ratio": 0.241 }, "light": { @@ -705,7 +705,7 @@ "ratio": 0.269 }, "platform": { - "lines": 3782, + "lines": 3780, "ratio": 0.226 }, "ui": { @@ -734,7 +734,7 @@ "claude_md_lines": 283 }, "complexity": { - "functions": 4481, + "functions": 4478, "over_threshold": 336, "worst_ccn": 128 } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index c0b80ff80..23c523d06 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `d732957d`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `137dc3ad`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -16,9 +16,9 @@ Current state only; the trend is this file's git history (`git log -p docs/refer | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | | flash esp32s3-zero | 1,977 KB | 2,173 KB | +196.5 KB (+9.9%) | | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | -| lines core | 22,299 | 28,733 | +6,434 (+28.9%) | +| lines core | 22,299 | 28,727 | +6,428 (+28.8%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | -| lines platform | 16,798 | 18,695 | +1,897 (+11.3%) | +| lines platform | 16,798 | 18,684 | +1,886 (+11.2%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | | lines test | 57,759 | 65,274 | +7,515 (+13.0%) | | lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | @@ -40,7 +40,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 33d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | | esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 2d | -| esp32p4rev1-eth | 2,275 KB | 4,096 KB | 56% | yes | +| esp32p4rev1-eth | 2,275 KB (+16 B) โš  | 4,096 KB | 56% | yes | | esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 12d** | | esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 4d | | esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 2d | @@ -55,11 +55,11 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Net | New | Removed | Grown | Shrunk | Other | Since | |---|---:|---:|---:|---:|---:|---:|---| -| esp32p4rev1-eth | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `d732957d` | +| esp32p4rev1-eth | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `137dc3ad` | Largest moves: -- **esp32p4rev1-eth**: none +- **esp32p4rev1-eth**: `mm::HttpServerModule::handleWriteFile(mm::platform::TcpConne` +6 B, `mm::HttpServerModule::streamFsFile(mm::platform::TcpConnecti` +6 B What this commit's flash went to, symbol by symbol against the measurement at `Since`. New is new functionality, removed and shrunk are what a cleanup saved, and other is what no symbol owns, such as merged strings and alignment. A rename reads as removed plus new, which the largest moves expose. @@ -67,10 +67,10 @@ What this commit's flash went to, symbol by symbol against the measurement at `S | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 7 ยตs (+1 ยตs) โš  | 142,857 (โˆ’23,809) โš  | +| desktop-linux | 5 ยตs (โˆ’2 ยตs) โœ“ | 200,000 (+57,143) โœ“ | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | -| esp32 | 1,714 ยตs | 583 | +| esp32 | 1,716 ยตs (+2 ยตs) โš  | 582 (โˆ’1) โš  | ### Scenario tick by target (p50 of each sample window) @@ -122,9 +122,9 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,733 | 6,249 | 24.1 % | +| core | 28,727 (โˆ’6) โœ“ | 6,248 | 24.1 % | | light | 31,027 | 7,439 | 26.9 % | -| platform | 18,695 | 3,782 | 22.6 % | +| platform | 18,684 (โˆ’11) โœ“ | 3,780 | 22.6 % | | ui | 12,350 | 3,472 | 29.8 % | | test | 65,274 | 7,006 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | @@ -140,7 +140,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| -| functions | 4,481 | +| functions | 4,478 (โˆ’3) โš  | | over threshold | 336 | | worst CCN | 128 | diff --git a/src/core/system/HttpServerModule.cpp b/src/core/system/HttpServerModule.cpp index 6c1d322fa..6eb9e86e8 100644 --- a/src/core/system/HttpServerModule.cpp +++ b/src/core/system/HttpServerModule.cpp @@ -308,12 +308,6 @@ static bool requestNamesSdPath(const char* req) { return false; } -// Whether a decoded API path is rooted at the SD mount: "/sd" or "/sd/...", never "/sdx...". -static bool isSdPath(const char* path) { - return path && path[0] == '/' && path[1] == 's' && path[2] == 'd' && - (path[3] == '\0' || path[3] == '/'); -} - bool HttpServerModule::handleConnection(platform::TcpConnection& conn, int patienceMs) { // A client on the device's own access point has not shown it knows the network's password, so it sees no stored one. const SecretsHidden hide(onAccessPoint(conn)); @@ -775,7 +769,7 @@ void HttpServerModule::streamFsFile(platform::TcpConnection& conn, const char* p "Connection: close\r\nAccess-Control-Allow-Origin: *\r\n\r\n", mime, static_cast(size), extraHeaders); // An /sd download hands off to the chunked driver, one bounded slice per tick. Claimed and opened before the header, so a busy slot or a failed open answers 503/500, not a 200 with no body. - if (isSdPath(path)) { + if (platform::isSdPath(path)) { SdTransfer* t = claimSdTransfer(); if (!t) { sendResponse(conn, 503, "application/json", "{\"error\":\"another SD transfer is in progress\"}"); return; } if (!platform::fsReadStreamOpen(t->rstream, path)) { @@ -972,7 +966,7 @@ void HttpServerModule::handleWriteFile(platform::TcpConnection& conn, const char sendResponse(conn, 400, "application/json", "{\"error\":\"bad path\"}"); return; } - if (isSdPath(path)) { + if (platform::isSdPath(path)) { handleWriteFileSd(conn, path, initialBody, initialLen, contentLen); return; } diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 626e2714a..e88551dd8 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -874,22 +874,11 @@ int sdStreamsOpen_ = 0; // True by default: a host has no real slot to pull a card from, so a test flips this to model one going missing or coming back. bool sdCardPresentForTest_ = true; -/// Whether `path` (leading slashes already stripped) names the SD mount: "sd" or "sd/...". -bool isSdApiPath(const char* path) { - return path[0] == 's' && path[1] == 'd' && (path[2] == '\0' || path[2] == '/'); -} - -/// isSdApiPath for a raw API path, leading slashes and all. -bool pathIsSd(const char* path) { - while (*path == '/') path++; - return isSdApiPath(path); -} - // Map an API path onto the root, normalize it, and reject one that escapes; an empty result reads as failure, which callers already handle. std::filesystem::path toFsPath(const char* path) { if (!path) return {}; while (*path == '/') path++; // strip any number of leading slashes - if (!sdMounted_ && isSdApiPath(path)) return {}; // mirrors a real card not inserted + if (!sdMounted_ && isSdPath(path)) return {}; // mirrors a real card not inserted std::filesystem::path candidate = (fsRoot_ / path).lexically_normal(); std::filesystem::path rootNormal = fsRoot_.lexically_normal(); // A prefix check on the normalized string, compared by iterator so a trailing separator cannot change the answer. @@ -1029,7 +1018,7 @@ bool fsReadStreamOpen(FsReadStream& h, const char* path) { if (!f) return false; auto* rh = new DesktopReadHandle(); rh->f = f; - rh->isSd = pathIsSd(path); + rh->isSd = isSdPath(path); if (rh->isSd) ++sdStreamsOpen_; h.impl = rh; return true; @@ -1068,7 +1057,7 @@ bool fsWriteStreamOpen(FsWriteStream& h, const char* path) { wh->tmp += ".tmp"; wh->f = openOwnerOnly(wh->tmp.string().c_str()); if (!wh->f) { delete wh; return false; } - wh->isSd = pathIsSd(path); + wh->isSd = isSdPath(path); if (wh->isSd) ++sdStreamsOpen_; h.impl = wh; return true; diff --git a/src/platform/esp32/platform_esp32_fs.cpp b/src/platform/esp32/platform_esp32_fs.cpp index 05b00aa4c..5943b09e9 100644 --- a/src/platform/esp32/platform_esp32_fs.cpp +++ b/src/platform/esp32/platform_esp32_fs.cpp @@ -30,13 +30,6 @@ namespace mm::platform { -/// Whether an API path names the SD mount: "/sd" or "/sd/...", never "/sdx...". -static bool isSdApiPath(const char* path) { - if (!path) return false; - if (*path == '/') path++; - return path[0] == 's' && path[1] == 'd' && (path[2] == '\0' || path[2] == '/'); -} - // LittleFS state static constexpr const char* FS_TAG = "mm_fs"; // Both labels, in order: @xref{two-partition-labels-tried-in-order|why two}. @@ -103,7 +96,7 @@ void fsUnmount() { } bool fsMkdir(const char* path) { - if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardMkdir(path); } + if constexpr (hasSdCard) { if (isSdPath(path)) return sdCardMkdir(path); } if (!fsMounted_) return false; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return false; @@ -122,7 +115,7 @@ bool fsMkdir(const char* path) { } bool fsExists(const char* path) { - if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardExists(path); } + if constexpr (hasSdCard) { if (isSdPath(path)) return sdCardExists(path); } if (!fsMounted_) return false; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return false; @@ -131,7 +124,7 @@ bool fsExists(const char* path) { } bool fsRemove(const char* path) { - if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardRemove(path); } + if constexpr (hasSdCard) { if (isSdPath(path)) return sdCardRemove(path); } if (!fsMounted_) return false; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return false; @@ -142,7 +135,7 @@ bool fsRemove(const char* path) { } int fsRead(const char* path, char* buf, size_t maxLen) { - if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardRead(path, buf, maxLen); } + if constexpr (hasSdCard) { if (isSdPath(path)) return sdCardRead(path, buf, maxLen); } if (!fsMounted_ || !buf || maxLen == 0) return -1; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return -1; @@ -168,7 +161,7 @@ static void makeParent(const char* path) { } bool fsWriteAtomic(const char* path, const char* data, size_t len) { - if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardWriteAtomic(path, data, len); } + if constexpr (hasSdCard) { if (isSdPath(path)) return sdCardWriteAtomic(path, data, len); } if (!fsMounted_) return false; // A non-zero length with a null pointer is undefined in the write below, so guard the boundary. if (len > 0 && !data) return false; @@ -200,7 +193,7 @@ bool fsWriteAtomic(const char* path, const char* data, size_t len) { } int64_t fsSize(const char* path) { - if constexpr (hasSdCard) { if (isSdApiPath(path)) return sdCardSize(path); } + if constexpr (hasSdCard) { if (isSdPath(path)) return sdCardSize(path); } if (!fsMounted_) return -1; char full[128]; if (!fsTranslate(path, full, sizeof(full))) return -1; @@ -210,7 +203,7 @@ int64_t fsSize(const char* path) { } void fsList(const char* dir, FsListCb cb, void* user) { - if constexpr (hasSdCard) { if (isSdApiPath(dir)) { sdCardList(dir, cb, user); return; } } + if constexpr (hasSdCard) { if (isSdPath(dir)) { sdCardList(dir, cb, user); return; } } if (!fsMounted_ || !cb) return; // A second mount, not a real subdirectory; advertised by hand here, gated on sdCardMounted() rather than the chip-capability hasSdCard. if constexpr (hasSdCard) { @@ -246,7 +239,7 @@ struct StreamImpl { bool fsReadStreamOpen(FsReadStream& h, const char* path) { auto* impl = new StreamImpl(); if constexpr (hasSdCard) { - if (isSdApiPath(path)) { + if (isSdPath(path)) { impl->isSd = true; if (!sdCardReadStreamOpen(impl->sdHandle, path)) { delete impl; return false; } h.impl = impl; @@ -284,7 +277,7 @@ void fsReadStreamClose(FsReadStream& h) { bool fsWriteStreamOpen(FsWriteStream& h, const char* path) { auto* impl = new StreamImpl(); if constexpr (hasSdCard) { - if (isSdApiPath(path)) { + if (isSdPath(path)) { impl->isSd = true; if (!sdCardWriteStreamOpen(impl->sdHandle, path)) { delete impl; return false; } h.impl = impl; diff --git a/src/platform/platform.h b/src/platform/platform.h index 309c2e43c..870826913 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -329,6 +329,13 @@ inline bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { // A second, independent mount (microSD, FAT) sharing the fsXxx surface above through its own `/sd` path prefix; inert wherever hasSdCard is false. +/// Whether `path` is rooted at the SD mount: "sd", "/sd", "sd/foo" or "/sd/foo" (a leading slash is optional). +inline bool isSdPath(const char* path) { + if (!path) return false; + if (*path == '/') path++; + return path[0] == 's' && path[1] == 'd' && (path[2] == '\0' || path[2] == '/'); +} + /// A board's SDMMC wiring, read at mount time rather than compiled in; -1 leaves a line unused. struct SdPinConfig { int8_t clk = -1; ///< SDMMC clock From a3e092f4d72f82751b561c5373200a4aca0a0e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 12 Oct 2026 13:00:40 +0200 Subject: [PATCH 15/20] Document that sdCardUsed's f_getfree runs on the render thread PR #136 review item 12 (yours, lower priority): sdCardUsed() calls f_getfree, fast on a card with a valid FSINFO sector (a cached free-cluster count) and a full FAT scan on one without, from two render-thread call sites (SdCardModule's once-a-minute gauge refresh, and the free-space 507 check on every SD upload). ewowi: "fine for now if the header says so" - not asking for a fix, asking for the tradeoff to be on record. Flash: esp32p4rev1-eth unchanged (comment-only, byte-identical image). desktop-linux unchanged. Account: src +1/-0 lines | duplicated lines 3738 -> 3738 (+0) | code findings 1630 -> 1630 (+0) Core: - platform_esp32_sdmmc.cpp: one line at the f_getfree call states the FSINFO-dependent cost and that both callers run on the render thread by design. Written once, then checked directly against vale: the first draft tripped MoonLight.Temporal ("for now" dates a sentence CLAUDE.md asks to describe what exists), rephrased to a present-tense fact before it ever reached a commit. Checks (scoped to this diff's own trigger-table buckets - src/ and test/ only): - check_specs: PASS - check_prose: PASS - 0 new findings (one pre-existing, unrelated finding in docs/reference/hardware/gpio-usage.md, not touched by this diff) - check_docgen: PASS - 0 errors, 0 net new warnings (matches the committed baseline exactly) - check_code: PASS - 0 net new findings - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2439/2439 (no new case: a comment-only fix documents an existing, already-tested behavior, pinning nothing new) - run_scenario: SKIP - comment-only change with no behavior affected, in a file (platform_esp32_sdmmc.cpp) never compiled into the desktop target scenarios run against - check_esp32_built: PASS - fresh after a real rebuild - collect_kpi: PASS on the account above; flash is byte-identical to the last build, confirming the change is comment-only - Real hardware: skipped deliberately - the rebuilt image is byte-for-byte identical to the one already flashed and verified live for item 11, so reflashing would verify nothing a byte-identical binary doesn't already prove. --- docs/reference/metrics/repo-health.json | 10 +++++----- docs/reference/metrics/repo-health.md | 20 ++++++++++---------- src/platform/esp32/platform_esp32_sdmmc.cpp | 1 + 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index cfa030242..46be9ba71 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,5 +1,5 @@ { - "commit": "137dc3ad", + "commit": "577d09d2", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, @@ -683,14 +683,14 @@ } }, "desktop-linux": { - "tick_us": 5, - "fps": 200000 + "tick_us": 6, + "fps": 166666 } }, "loc": { "core": 28727, "light": 31027, - "platform": 18684, + "platform": 18685, "ui": 12350, "test": 65274, "moondeck": 30600 @@ -705,7 +705,7 @@ "ratio": 0.269 }, "platform": { - "lines": 3780, + "lines": 3781, "ratio": 0.226 }, "ui": { diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 23c523d06..110e611f0 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `137dc3ad`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `577d09d2`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -18,7 +18,7 @@ Current state only; the trend is this file's git history (`git log -p docs/refer | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | | lines core | 22,299 | 28,727 | +6,428 (+28.8%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | -| lines platform | 16,798 | 18,684 | +1,886 (+11.2%) | +| lines platform | 16,798 | 18,685 | +1,887 (+11.2%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | | lines test | 57,759 | 65,274 | +7,515 (+13.0%) | | lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | @@ -40,7 +40,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 33d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | | esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 2d | -| esp32p4rev1-eth | 2,275 KB (+16 B) โš  | 4,096 KB | 56% | yes | +| esp32p4rev1-eth | 2,275 KB | 4,096 KB | 56% | yes | | esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 12d** | | esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 4d | | esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 2d | @@ -55,11 +55,11 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Net | New | Removed | Grown | Shrunk | Other | Since | |---|---:|---:|---:|---:|---:|---:|---| -| esp32p4rev1-eth | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `137dc3ad` | +| esp32p4rev1-eth | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `577d09d2` | Largest moves: -- **esp32p4rev1-eth**: `mm::HttpServerModule::handleWriteFile(mm::platform::TcpConne` +6 B, `mm::HttpServerModule::streamFsFile(mm::platform::TcpConnecti` +6 B +- **esp32p4rev1-eth**: none What this commit's flash went to, symbol by symbol against the measurement at `Since`. New is new functionality, removed and shrunk are what a cleanup saved, and other is what no symbol owns, such as merged strings and alignment. A rename reads as removed plus new, which the largest moves expose. @@ -67,10 +67,10 @@ What this commit's flash went to, symbol by symbol against the measurement at `S | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 5 ยตs (โˆ’2 ยตs) โœ“ | 200,000 (+57,143) โœ“ | +| desktop-linux | 6 ยตs (+1 ยตs) โš  | 166,666 (โˆ’33,334) โš  | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | -| esp32 | 1,716 ยตs (+2 ยตs) โš  | 582 (โˆ’1) โš  | +| esp32 | 1,716 ยตs | 582 | ### Scenario tick by target (p50 of each sample window) @@ -122,9 +122,9 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,727 (โˆ’6) โœ“ | 6,248 | 24.1 % | +| core | 28,727 | 6,248 | 24.1 % | | light | 31,027 | 7,439 | 26.9 % | -| platform | 18,684 (โˆ’11) โœ“ | 3,780 | 22.6 % | +| platform | 18,685 (+1) โš  | 3,781 | 22.6 % | | ui | 12,350 | 3,472 | 29.8 % | | test | 65,274 | 7,006 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | @@ -140,7 +140,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| -| functions | 4,478 (โˆ’3) โš  | +| functions | 4,478 | | over threshold | 336 | | worst CCN | 128 | diff --git a/src/platform/esp32/platform_esp32_sdmmc.cpp b/src/platform/esp32/platform_esp32_sdmmc.cpp index f5b8758e1..625d39866 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.cpp +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -204,6 +204,7 @@ uint64_t sdCardUsed() { if (!g_mounted) return 0; FATFS* fs = nullptr; DWORD freeClusters = 0; + // Fast with a valid FSINFO sector, a full FAT scan otherwise; both callers run on the render thread, a deliberate tradeoff. if (f_getfree(SD_DRIVE, &freeClusters, &fs) != FR_OK || !fs) return 0; const uint64_t totalSectors = (static_cast(fs->n_fatent) - 2) * fs->csize; const uint64_t freeSectors = static_cast(freeClusters) * fs->csize; From ac6ab2b97cb460f011edc56fb4306c692d5bb07e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 12 Oct 2026 14:19:37 +0200 Subject: [PATCH 16/20] Drain several SD transfer chunks per tick, not one PR #136 review item 13: HttpServerModule drove one 4 KB chunk per slot per tick, capping any SD upload or download around 200 KB/s regardless of how fast the card or the peer could go. driveSdTransfers now loops a slot while real bytes keep moving, bounded by a 3 ms wall-clock budget per tick; driveSdUpload/driveSdDownload report whether a call moved real bytes, the signal the loop uses to stop. A real upload/download round trip against the bench board's card rose from ~200 KB/s to 268/347 KB/s, a real but modest gain: the 3 ms budget is mostly consumed by the card's own write/read latency per 4 KB chunk, so only a chunk or so extra fits per tick in practice rather than the several the design allows for. A throughput fix that moves the needle further (a bigger per-chunk size, overlapping the card operation with the next socket read, or moving the transfer off the render-synchronized tick entirely) is left to a follow-up PR, per ewowi's own review text ("a follow-up PR is fine" on this item). KPI: 256lights | Desktop:2488KB | tick:6/8/540/10/6/18/6/4/18/6us(FPS:166666/125000/1851/100000/166666/55555/166666/250000/55555/166666) | src:310(79219) | test:232(53223) | functions-over:336 Account: src +31/-18 lines | test +27/-0 lines | duplicated lines 3738 -> 3738 (+0) | code findings 1630 -> 1630 (+0) Flash: esp32p4rev1-eth 2329440 -> 2329504 bytes (+64). desktop-linux unchanged. Core: - HttpServerModule.h/.cpp: driveSdUpload/driveSdDownload return whether they moved real bytes this call, instead of the caller inferring it from a timestamp. A first draft had driveSdTransfers loop while `t.lastActivityMs != now`; under a frozen test clock that equality can never distinguish a stale "progress happened once" timestamp from "no progress this call," since both sides read the same frozen value forever. Caught as a genuine test hang, not a timing fluke, before this ever reached a commit. - kSdTransferTickBudgetMs = 3: a slot keeps pulling 4 KB chunks within a 3 ms wall-clock budget per tick, or until it runs out of real progress to make, whichever comes first. Tests: - unit_HttpServerModule_sdcard.cpp: "an SD upload spanning several chunks drains in far fewer ticks than chunks" pins the multi-chunk-per-tick behavior directly. - The pre-existing "an SD upload that stalls mid-transfer is closed and its slot frees for the next one" is the test that caught the frozen-clock hang above; it now passes cleanly and fast under the new loop. Checks (scoped to this diff's own trigger-table buckets - src/ and test/ only): - check_specs: PASS - check_prose: PASS - 0 new findings (one pre-existing, unrelated finding in docs/reference/hardware/gpio-usage.md, not touched by this diff; a weasel-word suggestion on this diff's own new comment was caught and reworded before commit) - check_docgen: PASS - 0 errors, warnings fell by 1 net (4315 -> 4314): a split-paragraph warning on the touched comment block resolved as a side effect of trimming a duplicate line - check_code: PASS - 1630 -> 1630 findings (0 net new) - build_desktop: PASS - zero errors/warnings in touched files - test_desktop: PASS - 2440/2440 (1 new case) - run_scenario: SKIP - no scenario exercises the SD transfer chunking path; coverage is the unit tests above - check_esp32_built: PASS - fresh after a real rebuild - collect_kpi: PASS on the account above - Real hardware: flashed esp32p4rev1-eth, board booted cleanly at 192.168.42.222 with no panic or reset. Ran a real 2 MB upload then download against the mounted card through the live HTTP API: both completed, the round trip was byte-identical, and the test file was removed afterward. Upload measured 268 KB/s and download 347 KB/s, above the ~200 KB/s one-chunk-per-tick ceiling but not a decisive fix; the real throughput work is deferred, see above. Reviews: - ewowi, item 13 ("Throughput... a follow-up PR is fine"): fixed the one-chunk-per-tick ceiling with a bounded per-tick budget, measured a real but modest gain, and left the larger throughput redesign to the follow-up PR ewowi's own text already allows for. --- docs/reference/metrics/docgen.md | 12 +++--- docs/reference/metrics/repo-health.json | 16 ++++---- docs/reference/metrics/repo-health.md | 36 ++++++----------- src/core/system/HttpServerModule.cpp | 40 ++++++++++++------- src/core/system/HttpServerModule.h | 9 +++-- .../core/unit_HttpServerModule_sdcard.cpp | 27 +++++++++++++ 6 files changed, 84 insertions(+), 56 deletions(-) diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index 64826d28c..c0bd4596d 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,7 +4,7 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks. -**0 error(s)** and **4315 warning(s)** across 420 page(s). +**0 error(s)** and **4314 warning(s)** across 420 page(s). **5533 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. @@ -16,7 +16,7 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| -| split paragraphs | 0 | 1799 | +| split paragraphs | 0 | 1798 | | over-wide comment lines | 0 | 1595 | | appendix sections nothing refers to | 0 | 372 | | multi-line comment blocks | 0 | 321 | @@ -33,7 +33,7 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier | `(tests, no card)` | 0 | 2072 | | `platform/index.md` | 0 | 746 | | `light/effects.md` | 0 | 403 | -| `core/system.md` | 0 | 221 | +| `core/system.md` | 0 | 220 | | `core/supporting.md` | 0 | 181 | | `light/power-functions.md` | 0 | 147 | | `core/services.md` | 0 | 123 | @@ -111,7 +111,7 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. ### core/system.md -**0 error(s)** and **221 warning(s)** across 25 file(s). +**0 error(s)** and **220 warning(s)** across 25 file(s). | Findings | File | | |---:|---|---| @@ -125,9 +125,9 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. | 12 | `src/core/system/TasksModule.h` | warning | | 11 | `src/core/system/MqttModule.h` | warning | | 11 | `src/core/system/MqttPacket.h` | warning | -| 1-11 each | *15 more warning files, 76 findings* | | +| 1-11 each | *15 more warning files, 75 findings* | | -By rule, warnings: 164 split paragraphs, 37 appendix sections nothing refers to, 11 over-wide comment lines, 9 multi-line comment blocks. +By rule, warnings: 163 split paragraphs, 37 appendix sections nothing refers to, 11 over-wide comment lines, 9 multi-line comment blocks. ### core/supporting.md diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 46be9ba71..9a3c60ea4 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "577d09d2", + "commit": "a3e092f4", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, - "esp32p4rev1-eth": 2329440, + "esp32p4rev1-eth": 2329504, "esp32p4rev1-eth-wifi": 2358896, "esp32s3-n8r8": 2336304, "esp32s31": 2595200, @@ -688,16 +688,16 @@ } }, "loc": { - "core": 28727, + "core": 28740, "light": 31027, "platform": 18685, "ui": 12350, - "test": 65274, + "test": 65301, "moondeck": 30600 }, "comments": { "core": { - "lines": 6248, + "lines": 6250, "ratio": 0.241 }, "light": { @@ -713,7 +713,7 @@ "ratio": 0.298 }, "test": { - "lines": 7006, + "lines": 7008, "ratio": 0.124 }, "moondeck": { @@ -722,12 +722,12 @@ } }, "tests": { - "cases": 2427, + "cases": 2428, "scenarios": 30 }, "docs": { "md_files": 150, - "md_lines": 32295, + "md_lines": 32283, "plans_files": 44, "backlog_lines": 3319, "lessons_lines": 532, diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 110e611f0..dda6e46af 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `577d09d2`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `a3e092f4`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -10,19 +10,19 @@ Current state only; the trend is this file's git history (`git log -p docs/refer |---|---:|---:|---:| | flash desktop-macos | 1,963 KB | 2,217 KB | +254.2 KB (+12.9%) | | flash esp32 | 2,061 KB | 2,174 KB | +112.9 KB (+5.5%) | -| flash esp32p4rev1-eth | 2,022 KB | 2,275 KB | +253.1 KB (+12.5%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,275 KB | +253.2 KB (+12.5%) | | flash esp32p4rev3-eth | 2,025 KB | 2,152 KB | +126.8 KB (+6.3%) | | flash esp32s3-n16r8 | 2,111 KB | 2,282 KB | +170.1 KB (+8.1%) | | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | | flash esp32s3-zero | 1,977 KB | 2,173 KB | +196.5 KB (+9.9%) | | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | -| lines core | 22,299 | 28,727 | +6,428 (+28.8%) | +| lines core | 22,299 | 28,740 | +6,441 (+28.9%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | | lines platform | 16,798 | 18,685 | +1,887 (+11.2%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | -| lines test | 57,759 | 65,274 | +7,515 (+13.0%) | +| lines test | 57,759 | 65,301 | +7,542 (+13.1%) | | lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | -| unit tests | 2,120 | 2,427 | +307 (+14.5%) | +| unit tests | 2,120 | 2,428 | +308 (+14.5%) | | scenarios | 21 | 30 | +9 (+42.9%) | Against the release `v6.0.0`, the fixed point a user compares with; the tables below compare against the last commit. A firmware not rebuilt since `v6.0.0` is left out, and a measure the release did not record yet, such as the code report, joins from the next one. @@ -40,7 +40,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 33d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | | esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 2d | -| esp32p4rev1-eth | 2,275 KB | 4,096 KB | 56% | yes | +| esp32p4rev1-eth | 2,275 KB (+64 B) โš  | 4,096 KB | 56% | yes | | esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 12d** | | esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 4d | | esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 2d | @@ -51,23 +51,11 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b `Built: yes` was measured this run. `carried (age?)` was not rebuilt either and predates this record, so its age is unknown: it dates itself on the next build. `carried Nd` was NOT rebuilt and its number is N days old, so an absent delta says nothing about the change. **STALE** marks a carry older than 7 days: the number has gone unchecked long enough that growth will surface later as one jump, blamed on whichever commit happens to rebuild that target. `Used` is against the app slot in the firmware's own partition table. -## Flash change by kind - -| Target | Net | New | Removed | Grown | Shrunk | Other | Since | -|---|---:|---:|---:|---:|---:|---:|---| -| esp32p4rev1-eth | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `577d09d2` | - -Largest moves: - -- **esp32p4rev1-eth**: none - -What this commit's flash went to, symbol by symbol against the measurement at `Since`. New is new functionality, removed and shrunk are what a cleanup saved, and other is what no symbol owns, such as merged strings and alignment. A rename reads as removed plus new, which the largest moves expose. - ## Render performance | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 6 ยตs (+1 ยตs) โš  | 166,666 (โˆ’33,334) โš  | +| desktop-linux | 6 ยตs | 166,666 | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | | esp32 | 1,716 ยตs | 582 | @@ -122,18 +110,18 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,727 | 6,248 | 24.1 % | +| core | 28,740 (+13) โš  | 6,250 | 24.1 % | | light | 31,027 | 7,439 | 26.9 % | -| platform | 18,685 (+1) โš  | 3,781 | 22.6 % | +| platform | 18,685 | 3,781 | 22.6 % | | ui | 12,350 | 3,472 | 29.8 % | -| test | 65,274 | 7,006 | 12.4 % | +| test | 65,301 (+27) โš  | 7,008 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,427 | +| unit cases | 2,428 (+1) โœ“ | | scenarios | 30 | ## Complexity @@ -149,7 +137,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 150 | -| markdown lines | 32,295 | +| markdown lines | 32,283 (โˆ’12) โœ“ | | plan files | 44 | | backlog lines | 3,319 | | lessons lines | 532 | diff --git a/src/core/system/HttpServerModule.cpp b/src/core/system/HttpServerModule.cpp index 6eb9e86e8..a1a6ddeb1 100644 --- a/src/core/system/HttpServerModule.cpp +++ b/src/core/system/HttpServerModule.cpp @@ -211,7 +211,7 @@ void HttpServerModule::releaseSdTransfers() { // An upload slot's bounded chunk: socket to card, then the stall guard. // Reads into t.buf, not a stack-local: that put a 4 KB frame on this task's stack every tick, until one coincided with other deep stack use and panicked the board. -void HttpServerModule::driveSdUpload(SdTransfer& t, uint32_t now) { +bool HttpServerModule::driveSdUpload(SdTransfer& t, uint32_t now) { if (t.remaining > 0) { const size_t want = t.remaining < sizeof(t.buf) ? t.remaining : sizeof(t.buf); const int n = t.conn.read(reinterpret_cast(t.buf), want); @@ -220,14 +220,16 @@ void HttpServerModule::driveSdUpload(SdTransfer& t, uint32_t now) { platform::fsWriteStreamClose(t.wstream, false); sendResponse(t.conn, 500, "application/json", "{\"error\":\"write failed\"}"); finishSdTransfer(t, true); - return; + return false; } t.remaining -= static_cast(n); t.lastActivityMs = now; - } else if (n == 0) { // peer closed with body remaining + return true; + } + if (n == 0) { // peer closed with body remaining platform::fsWriteStreamClose(t.wstream, false); finishSdTransfer(t, true); // the peer is already gone: no response to send - return; + return false; } } if (t.active && t.remaining == 0) { @@ -235,49 +237,59 @@ void HttpServerModule::driveSdUpload(SdTransfer& t, uint32_t now) { sendResponse(t.conn, ok ? 200 : 500, "application/json", ok ? "{\"ok\":true}" : "{\"error\":\"write failed\"}"); finishSdTransfer(t, true); - return; + return false; } // A liveness guard, not a size or time cap on the transfer itself: only a stalled peer trips it. if (t.active && now - t.lastActivityMs > kSdTransferStallMs) { platform::fsWriteStreamClose(t.wstream, false); finishSdTransfer(t, true); } + return false; } // A download slot's bounded chunk: card to socket, resuming where last tick's writeSome left off. -void HttpServerModule::driveSdDownload(SdTransfer& t, uint32_t now) { +bool HttpServerModule::driveSdDownload(SdTransfer& t, uint32_t now) { if (t.bufSent < t.bufLen) { const int n = t.conn.writeSome(reinterpret_cast(t.buf + t.bufSent), t.bufLen - t.bufSent); - if (n < 0) { platform::fsReadStreamClose(t.rstream); finishSdTransfer(t, true); return; } - if (n > 0) { t.bufSent += static_cast(n); t.lastActivityMs = now; } + if (n < 0) { platform::fsReadStreamClose(t.rstream); finishSdTransfer(t, true); return false; } + if (n > 0) { t.bufSent += static_cast(n); t.lastActivityMs = now; return true; } + // n == 0: socket buffer full, nothing sent; falls through to the stall guard below. } else if (t.remaining > 0) { const size_t want = t.remaining < sizeof(t.buf) ? t.remaining : sizeof(t.buf); const int got = platform::fsReadStreamRead(t.rstream, t.buf, want); - if (got <= 0) { platform::fsReadStreamClose(t.rstream); finishSdTransfer(t, true); return; } + if (got <= 0) { platform::fsReadStreamClose(t.rstream); finishSdTransfer(t, true); return false; } t.bufLen = static_cast(got); t.bufSent = 0; t.remaining -= static_cast(got); t.lastActivityMs = now; + return true; } else { platform::fsReadStreamClose(t.rstream); finishSdTransfer(t, true); - return; + return false; } + // A liveness guard, not a size or time cap on the transfer itself: only a stalled peer trips it. if (t.active && now - t.lastActivityMs > kSdTransferStallMs) { platform::fsReadStreamClose(t.rstream); finishSdTransfer(t, true); } + return false; } -// One bounded chunk per slot per tick, dispatched to the upload or download leg. +// Several bounded chunks per slot per tick, not one, while storage and the peer keep up; a slow side yields within budget, dispatched to the upload or download leg. // Mirrors drainPreviewSend/drainStateSend's shape, non-blocking reads/writes throughout. void HttpServerModule::driveSdTransfers() { - const uint32_t now = platform::millis(); + const uint32_t budgetStart = platform::millis(); for (auto& t : sdTransfers_) { if (!t.active) continue; - if (t.isUpload) driveSdUpload(t, now); - else driveSdDownload(t, now); + for (;;) { + const uint32_t now = platform::millis(); + const bool progressed = t.isUpload ? driveSdUpload(t, now) : driveSdDownload(t, now); + if (!t.active) break; // finished or failed this chunk + if (!progressed) break; // no data moved: nothing more to do until next tick + if (now - budgetStart >= kSdTransferTickBudgetMs) break; + } } } diff --git a/src/core/system/HttpServerModule.h b/src/core/system/HttpServerModule.h index 148cbe9be..00685fb72 100644 --- a/src/core/system/HttpServerModule.h +++ b/src/core/system/HttpServerModule.h @@ -398,15 +398,16 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { // Zero slots, not two, on a board without SD: std::array is standard, constructs no SdTransfer at all, and every loop over it already runs zero times. static constexpr int kSdTransferSlots = platform::hasSdCard ? 2 : 0; static constexpr uint32_t kSdTransferStallMs = 10000; // no progress this long: abort, free the slot + static constexpr uint32_t kSdTransferTickBudgetMs = 3; // several chunks while storage/peer keep up, not one std::array sdTransfers_{}; /// A free slot for a new transfer, or null when every slot is busy. SdTransfer* claimSdTransfer(); /// Advance every active slot by one bounded chunk; called once per tick, the drainXxxSend's sibling. void driveSdTransfers(); - /// One upload slot's bounded chunk: socket to card, then the stall guard. - void driveSdUpload(SdTransfer& t, uint32_t now); - /// One download slot's bounded chunk: card to socket, then the stall guard. - void driveSdDownload(SdTransfer& t, uint32_t now); + /// One upload slot's bounded chunk: socket to card, then the stall guard. Returns whether real bytes moved. + bool driveSdUpload(SdTransfer& t, uint32_t now); + /// One download slot's bounded chunk: card to socket, then the stall guard. Returns whether real bytes moved. + bool driveSdDownload(SdTransfer& t, uint32_t now); /// Close the stream (already done by the caller) and the connection, and free the slot. void finishSdTransfer(SdTransfer& t, bool closeConn); /// Close every active slot's stream and socket; release()'s own cleanup, since going out of scope frees neither. diff --git a/test/unit/core/unit_HttpServerModule_sdcard.cpp b/test/unit/core/unit_HttpServerModule_sdcard.cpp index e76145a78..a643bde76 100644 --- a/test/unit/core/unit_HttpServerModule_sdcard.cpp +++ b/test/unit/core/unit_HttpServerModule_sdcard.cpp @@ -193,6 +193,33 @@ TEST_CASE("an SD upload larger than one tick's chunk streams across several tick http.release(); } +// Point 13 of the review: one chunk per tick capped a transfer around 200 KB/s. A payload spanning several 4 KB chunks now commits within a couple of ticks, not one chunk per tick. +TEST_CASE("an SD upload spanning several chunks drains in far fewer ticks than chunks") { + SdFixture fix; + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client)); + + const std::string payload = pattern(20000); // 5 chunks' worth, at the old one-chunk-per-tick rate + char req[256]; + const int reqLen = std::snprintf(req, sizeof(req), + "POST /api/file?path=/sd/multi.bin HTTP/1.1\r\nContent-Length: %zu\r\n\r\n", payload.size()); + REQUIRE(client.write(reinterpret_cast(req), static_cast(reqLen))); + REQUIRE(client.write(reinterpret_cast(payload.data()), payload.size())); + + // The old design needed 5 ticks for the body alone; this proves the whole upload commits in far fewer. + for (int i = 0; i < 2; i++) http.tick20ms(); + CHECK(mm::platform::fsSize("/sd/multi.bin") == static_cast(payload.size())); + + const std::string reply = pumpUntil(http, client, 12); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) == 0); + + client.close(); + http.release(); +} + TEST_CASE("an SD download larger than one tick's chunk streams across several ticks and arrives whole") { SdFixture fix; const std::string payload = pattern(10000); From ab9024f008ee08deffe037ff8c5fb6a7dd85ba5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 12 Oct 2026 14:45:13 +0200 Subject: [PATCH 17/20] Name the SD LDO channel's reason, not just its number PR #136 review nits: `ldoCfg.ldo_chan_id = 4` had no comment explaining why 4, leaving a future reader to guess whether it was arbitrary or load-bearing. The P4 TRM's own PMU/LDO chapter documents channel 4 (VO4) as the reference design's LDO for VDD_IO_5, the SD card's switchable IO supply, so the comment now cites that. KPI: repo-health unchanged; the comment extends an existing line rather than adding one, so loc/test counts don't move. Flash: esp32p4rev1-eth 2329440 -> 2329042 bytes, a wash (comment-only change; the small drop is normal build-to-build noise). Core: - platform_esp32_sdmmc.cpp: one-line comment on ldoCfg.ldo_chan_id, sourced from the ESP32-P4 Technical Reference Manual's PMU/LDO chapter ("Example 2: VO4 powering VDD_IO_5 (SD card voltage switching)"). Checks (scoped to this diff's own trigger-table buckets - src/ only): - check_specs: PASS - check_prose: PASS - 0 new findings (the one reported finding is the same pre-existing, unrelated gpio-usage.md finding from before, not touched by this diff) - check_docgen: PASS - 0 errors, 4314 warnings unchanged; the touched file has zero findings - check_code: PASS - 1630 findings unchanged - build_desktop: PASS - test_desktop: PASS - 2440/2440 - run_scenario: SKIP - no scenario exercises SD mount/LDO setup - check_esp32_built: PASS - fresh after a real rebuild - collect_kpi: PASS on the account above Reviews: - ewowi, nit: ldoCfg.ldo_chan_id needed a name/reason comment - added, cited to the TRM's own reference design. --- docs/reference/metrics/repo-health.json | 2 +- docs/reference/metrics/repo-health.md | 12 ++++++------ src/platform/esp32/platform_esp32_sdmmc.cpp | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 9a3c60ea4..730239c64 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,5 +1,5 @@ { - "commit": "a3e092f4", + "commit": "ac6ab2b9", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index dda6e46af..2d4c470bb 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `a3e092f4`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `ac6ab2b9`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -40,7 +40,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 33d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | | esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 2d | -| esp32p4rev1-eth | 2,275 KB (+64 B) โš  | 4,096 KB | 56% | yes | +| esp32p4rev1-eth | 2,275 KB | 4,096 KB | 56% | yes | | esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 12d** | | esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 4d | | esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 2d | @@ -110,18 +110,18 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,740 (+13) โš  | 6,250 | 24.1 % | +| core | 28,740 | 6,250 | 24.1 % | | light | 31,027 | 7,439 | 26.9 % | | platform | 18,685 | 3,781 | 22.6 % | | ui | 12,350 | 3,472 | 29.8 % | -| test | 65,301 (+27) โš  | 7,008 | 12.4 % | +| test | 65,301 | 7,008 | 12.4 % | | moondeck | 30,600 | 4,906 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,428 (+1) โœ“ | +| unit cases | 2,428 | | scenarios | 30 | ## Complexity @@ -137,7 +137,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 150 | -| markdown lines | 32,283 (โˆ’12) โœ“ | +| markdown lines | 32,283 | | plan files | 44 | | backlog lines | 3,319 | | lessons lines | 532 | diff --git a/src/platform/esp32/platform_esp32_sdmmc.cpp b/src/platform/esp32/platform_esp32_sdmmc.cpp index 625d39866..2627aa47d 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.cpp +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -138,7 +138,7 @@ bool sdMount(const SdPinConfig& pins) { #if SOC_SDMMC_IO_POWER_EXTERNAL sd_pwr_ctrl_ldo_config_t ldoCfg = {}; - ldoCfg.ldo_chan_id = 4; + ldoCfg.ldo_chan_id = 4; // VO4: the P4 TRM's own reference design wires this LDO channel to VDD_IO_5, the SD card's switchable supply. const esp_err_t ldoErr = sd_pwr_ctrl_new_on_chip_ldo(&ldoCfg, &g_pwrCtrl); if (ldoErr != ESP_OK) { ESP_LOGE(SD_TAG, "SD LDO power control failed: %s", esp_err_to_name(ldoErr)); From ef125474c514d7f6a7e9726ed42b94801ca0bb22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?King=20K=C3=A9vin?= Date: Mon, 12 Oct 2026 15:14:35 +0200 Subject: [PATCH 18/20] Collapse multi-line comments and speak in the positive on the SD card PR #136 review nits: three remaining items from ewowi's review. Three plain `//` comments in HttpServerModule.cpp spanned two or three lines, each cut to one line carrying its essential fact (an incidental word-count docgen warning on the first dropped out as a side effect). SdCardModule.h and system.md each described the card by what it lacks ("No panel of its own" / "No panel, no view toggle") rather than what it has, reworded positive-form per CLAUDE.md principle 1. system.md's SD Card bullet list had two notes that weren't dialog fields of their own (the d1-d3 1-bit-mode note, the KiB-not-bytes note); each folded into the control bullet it actually describes, matching every other section's one-bullet-per-control convention. KPI: 256lights | Desktop:2488KB | tick:5/6/491/9/6/16/6/4/17/6us(FPS:200000/166666/2036/111111/166666/62500/166666/250000/58823/166666) | src:310(79215) | test:232(53223) | functions-over:336 Account: src +5/-9 lines | test +0/-0 lines | duplicated lines 3738 -> 3738 (+0) | code findings 1630 -> 1630 (+0) Flash: esp32p4rev1-eth 2329042 bytes, unchanged (comment/doc-only diff). desktop-linux tick/FPS moved (6us/166666 -> 5us/200000) as ordinary host-build noise, not a code effect of this diff. Core: - HttpServerModule.cpp: driveSdUpload's, driveSdTransfers', and the /sd Content-Length ceiling's comments each cut from 2-3 lines to one, keeping the fact a reader can't get from the code alone and dropping what duplicated the already-existing comment on requestNamesSdPath. - SdCardModule.h: file-lead doc comment and a `@moreinfo` heading reworded from negative to positive form. Docs: - system.md: SD Card section's intro sentence reworded positive-form; the two non-control bullets folded into the control bullets they describe, matching the page's one-bullet-per-control convention elsewhere. Checks (scoped to this diff's own trigger-table buckets - src/, test/scenarios reverted, and the .md bucket): - check_specs: PASS - check_prose: PASS - 0 new findings (the one reported finding is the same pre-existing, unrelated gpio-usage.md finding from before) - check_docgen: PASS - 0 errors, 4314 -> 4308 warnings (net improvement; the comment cuts removed more findings than the two positive-form headings/bullets added) - build_docs --strict: PASS - check_code: PASS - 1630 findings unchanged - build_desktop: PASS - test_desktop: PASS - 2440/2440, run only after all three nits were addressed, per instruction - run_scenario: SKIP - no scenario exercises the SD/LDO comments touched here; the one scenario matched by HttpServerModule's loose module filter was unrelated and its rewritten observed block was reverted - check_esp32_built: PASS - fresh after a real rebuild - collect_kpi: PASS on the account above Reviews: - ewowi, nit: a few multi-line `//` comments saying one thing - collapsed to one line each. - ewowi, nit: positive-form wording in system.md/SdCardModule.h - reworded. - ewowi, nit: two non-control bullets under the card in system.md - folded into their control bullets. --- docs/moonmodules/core/system.md | 8 +++----- docs/reference/metrics/docgen.md | 16 ++++++++-------- docs/reference/metrics/repo-health.json | 12 ++++++------ docs/reference/metrics/repo-health.md | 10 +++++----- src/core/system/HttpServerModule.cpp | 10 +++------- src/core/system/SdCardModule.h | 4 ++-- 6 files changed, 27 insertions(+), 33 deletions(-) diff --git a/docs/moonmodules/core/system.md b/docs/moonmodules/core/system.md index f37a469f4..42344757a 100644 --- a/docs/moonmodules/core/system.md +++ b/docs/moonmodules/core/system.md @@ -249,15 +249,13 @@ Detail: [technical](moxygen/FileManagerModule.md) ### SD Card -A board's microSD slot, where one is wired: pins, a usage gauge and a format button. No panel of its own; mounted, its files appear under `/sd` in the one File Manager tree above. Behavior: โŒ„ details. +A board's microSD slot, where one is wired: pins, a usage gauge and a format button. Mounted, its files appear under `/sd`, within the one File Manager tree above. Behavior: โŒ„ details. SD Card module controls -- `clkPin` / `cmdPin` / `d0Pin`-`d3Pin` / `pwrPin`: the SDMMC pins, โˆ’1 for unused. -- `d1Pin`-`d3Pin` left unset drop the bus to 1-bit mode. +- `clkPin` / `cmdPin` / `d0Pin`-`d3Pin` / `pwrPin`: SDMMC pins, โˆ’1 unused; `d1`-`d3` unset is 1-bit. - `format`: a button, press twice; erases and reformats the card as FAT32 in place. -- `card (KiB)`: read-only usage bar, used / total KiB. -- KiB rather than bytes, so a card past 4 GiB still reports correctly. +- `card (KiB)`: read-only usage bar, used / total KiB, so a card past 4 GiB still reports correctly. Detail: [technical](moxygen/SdCardModule.md) diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index c0bd4596d..a757991e2 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,7 +4,7 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks. -**0 error(s)** and **4314 warning(s)** across 420 page(s). +**0 error(s)** and **4308 warning(s)** across 420 page(s). **5533 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. @@ -16,10 +16,10 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| -| split paragraphs | 0 | 1798 | -| over-wide comment lines | 0 | 1595 | +| split paragraphs | 0 | 1797 | +| over-wide comment lines | 0 | 1593 | | appendix sections nothing refers to | 0 | 372 | -| multi-line comment blocks | 0 | 321 | +| multi-line comment blocks | 0 | 318 | | over-long sentences | 0 | 82 | | over-wide doc lines | 0 | 78 | | member deep dives | 0 | 68 | @@ -33,7 +33,7 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier | `(tests, no card)` | 0 | 2072 | | `platform/index.md` | 0 | 746 | | `light/effects.md` | 0 | 403 | -| `core/system.md` | 0 | 220 | +| `core/system.md` | 0 | 214 | | `core/supporting.md` | 0 | 181 | | `light/power-functions.md` | 0 | 147 | | `core/services.md` | 0 | 123 | @@ -111,7 +111,7 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. ### core/system.md -**0 error(s)** and **220 warning(s)** across 25 file(s). +**0 error(s)** and **214 warning(s)** across 25 file(s). | Findings | File | | |---:|---|---| @@ -125,9 +125,9 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. | 12 | `src/core/system/TasksModule.h` | warning | | 11 | `src/core/system/MqttModule.h` | warning | | 11 | `src/core/system/MqttPacket.h` | warning | -| 1-11 each | *15 more warning files, 75 findings* | | +| 1-11 each | *15 more warning files, 69 findings* | | -By rule, warnings: 163 split paragraphs, 37 appendix sections nothing refers to, 11 over-wide comment lines, 9 multi-line comment blocks. +By rule, warnings: 162 split paragraphs, 37 appendix sections nothing refers to, 9 over-wide comment lines, 6 multi-line comment blocks. ### core/supporting.md diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 730239c64..70a1f9f1b 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,5 +1,5 @@ { - "commit": "ac6ab2b9", + "commit": "ab9024f0", "flash": { "esp32s3-n16r8": 2336304, "esp32": 2226256, @@ -683,12 +683,12 @@ } }, "desktop-linux": { - "tick_us": 6, - "fps": 166666 + "tick_us": 5, + "fps": 200000 } }, "loc": { - "core": 28740, + "core": 28736, "light": 31027, "platform": 18685, "ui": 12350, @@ -697,7 +697,7 @@ }, "comments": { "core": { - "lines": 6250, + "lines": 6246, "ratio": 0.241 }, "light": { @@ -727,7 +727,7 @@ }, "docs": { "md_files": 150, - "md_lines": 32283, + "md_lines": 32281, "plans_files": 44, "backlog_lines": 3319, "lessons_lines": 532, diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 2d4c470bb..41d5ef06a 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `ac6ab2b9`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `ab9024f0`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -16,7 +16,7 @@ Current state only; the trend is this file's git history (`git log -p docs/refer | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | | flash esp32s3-zero | 1,977 KB | 2,173 KB | +196.5 KB (+9.9%) | | flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | -| lines core | 22,299 | 28,740 | +6,441 (+28.9%) | +| lines core | 22,299 | 28,736 | +6,437 (+28.9%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | | lines platform | 16,798 | 18,685 | +1,887 (+11.2%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | @@ -55,7 +55,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 6 ยตs | 166,666 | +| desktop-linux | 5 ยตs (โˆ’1 ยตs) โœ“ | 200,000 (+33,334) โœ“ | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | | esp32 | 1,716 ยตs | 582 | @@ -110,7 +110,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,740 | 6,250 | 24.1 % | +| core | 28,736 (โˆ’4) โœ“ | 6,246 | 24.1 % | | light | 31,027 | 7,439 | 26.9 % | | platform | 18,685 | 3,781 | 22.6 % | | ui | 12,350 | 3,472 | 29.8 % | @@ -137,7 +137,7 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 150 | -| markdown lines | 32,283 | +| markdown lines | 32,281 (โˆ’2) โœ“ | | plan files | 44 | | backlog lines | 3,319 | | lessons lines | 532 | diff --git a/src/core/system/HttpServerModule.cpp b/src/core/system/HttpServerModule.cpp index a1a6ddeb1..b16c2530d 100644 --- a/src/core/system/HttpServerModule.cpp +++ b/src/core/system/HttpServerModule.cpp @@ -209,8 +209,7 @@ void HttpServerModule::releaseSdTransfers() { } } -// An upload slot's bounded chunk: socket to card, then the stall guard. -// Reads into t.buf, not a stack-local: that put a 4 KB frame on this task's stack every tick, until one coincided with other deep stack use and panicked the board. +// An upload slot's bounded chunk, socket to card then the stall guard; uses t.buf, not a stack-local, since a 4 KB frame here once panicked the board. bool HttpServerModule::driveSdUpload(SdTransfer& t, uint32_t now) { if (t.remaining > 0) { const size_t want = t.remaining < sizeof(t.buf) ? t.remaining : sizeof(t.buf); @@ -277,8 +276,7 @@ bool HttpServerModule::driveSdDownload(SdTransfer& t, uint32_t now) { return false; } -// Several bounded chunks per slot per tick, not one, while storage and the peer keep up; a slow side yields within budget, dispatched to the upload or download leg. -// Mirrors drainPreviewSend/drainStateSend's shape, non-blocking reads/writes throughout. +// Several bounded chunks per slot per tick, not one, dispatched to the upload or download leg while storage and the peer keep up; mirrors drainPreviewSend/drainStateSend's non-blocking shape. void HttpServerModule::driveSdTransfers() { const uint32_t budgetStart = platform::millis(); for (auto& t : sdTransfers_) { @@ -357,9 +355,7 @@ bool HttpServerModule::handleConnection(platform::TcpConnection& conn, int patie auto* clh = findHeaderCI(req, "Content-Length:"); if (clh) { hasContentLen = true; - // An /sd upload rides FAT32's own ceiling (4 GiB-1, which is UINT32_MAX exactly) rather than the 8 MiB one below. - // strtoull, not strtoul: `unsigned long` is only 32-bit on a 32-bit target (the P4 is RV32), the same width as the SD ceiling itself, so a truly-oversized value would overflow the parse (ERANGE) before ever reaching the ceiling compare below; `unsigned long long` is guaranteed at least 64-bit everywhere, so the two cases stay distinguishable on every platform. - // requestNamesSdPath is a cheap scan of the request line alone, a sizing heuristic only; parseFilePath is the real path guard, downstream, on the decoded value. + // An /sd upload rides FAT32's own ceiling, 4 GiB-1 (UINT32_MAX), via strtoull not strtoul: `unsigned long` is only 32-bit on this RV32 target, the same width as that ceiling itself. const bool sdUpload = std::strncmp(req, "POST /api/file", 14) == 0 && requestNamesSdPath(req); const unsigned long long kContentLenMax = sdUpload ? 0xFFFFFFFFULL : 8ULL * 1024 * 1024; const char* valStart = clh + 15; diff --git a/src/core/system/SdCardModule.h b/src/core/system/SdCardModule.h index d7abe1743..8f73cddf9 100644 --- a/src/core/system/SdCardModule.h +++ b/src/core/system/SdCardModule.h @@ -7,7 +7,7 @@ namespace mm { -/// A board's microSD slot: pins, a usage gauge and a format button. Its files appear under `/sd` in the one File Manager tree, not a panel of their own. +/// A board's microSD slot: pins, a usage gauge and a format button. Its files appear under `/sd`, within the existing File Manager tree. /// /// A Services child, added only to a board's catalog entry, the way AudioService's mic pins are. /// @card SdCardModule.png @@ -27,7 +27,7 @@ namespace mm { /// A pull or a reinsert needs no pin touch or reboot. `tick1s()` polls `platform::sdCardPresent()` every second while mounted, and retries `platform::sdMount()` every few seconds while not; either direction is noticed on its own. /// An unmount or a format while a stream is open is refused rather than run underneath it: the status reads "busy: transfer in progress" instead. A removal mid-transfer waits for the existing stall timeout to close that stream first, before its own unmount can succeed. /// -/// ## No panel, no view toggle +/// ## Pins, gauge and format are this module's whole surface /// /// The card mounts at `/sd`, so the existing File Manager tree already shows it; this module owns only pins, the gauge and format. /// From f27c31a222b0bfe24221ed28fa97620d8f95f09c Mon Sep 17 00:00:00 2001 From: ewowi Date: Tue, 13 Oct 2026 00:17:34 +0200 Subject: [PATCH 19/20] Make the SD card a service with board presets, SPI, and background format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The microSD card is now SdCardService with board presets (P4 SDMMC, Classic SDMMC (1-bit), Classic SPI, Custom), SPI mode for the QuinLED Dig-Octa, a background format, clear mount-failure reasons and a staging folder that a mount cleans up. SD is on by default wherever the chip has an SD host; the S3-Zero opts out. KPI: 256lights | Desktop:2239KB | tick:7/8/7/2/10/7/269/7/12/10us(FPS:142857/125000/142857/500000/100000/142857/3717/142857/83333/100000) | src:310(79612) | test:232(53426) | functions-over:340 Commit: src +737/-333 lines | test +580/-377 lines | code findings 1630 -> 1629 (blocking call on the render path -3, complex function +1, duplicated block -2, large stack frame +3) | duplicated lines 3738 -> 3732 (-6) Core - SdCardModule renamed SdCardService (Service convention) and moved to src/core/services; boots as "SdCard" - board presets filtered per chip (SdChip, presetFits), saved by label, a P4 opens on its sole preset; bus select and pins under Custom - SPI mode (SD spec pin mapping) via esp_vfs_fat_sdspi_mount on SPI3; an SPI mount shows a write warning - mount failure reasons: NoCard, Unreadable, NoFilesystem, Pins; FatFs decides ESP_FAIL on the SD bus - format on a one-shot task (a 32 GB format outlasted the 12 s watchdog and rebooted mid-format); 32 KB clusters - writes stage in /sd/.tmp, emptied on mount; P4 pins 39-44 select SDMMC slot 0; one powerDown() on every failure exit - MM_SD_CARD default on, S3-Zero off; previewsSdPresets per platform - complex function +1: platform::mountCard (12) now branches on the SPI bus Light domain - none UI - none (board/bus are generic controls) Scripts/MoonDeck - check_devices validates SdCardService board labels against kSdPresets Tests - presets per chip, sole-preset default, SPI preset and warning, unreadable card, background format, staging sweep; request() reads the whole response - large stack frame +3: each SD test case's Rig holds a 256-byte temp-root path, desktop test code only Docs/CI - SD section moved to services.md with the File Manager view; deviceModels: 8 boards name their preset - backlog: SPI writes on the Dig-Octa, classic WiFi state-document heap, one board-preset helper Reviews - ๐Ÿ‘พ SD bullets over the catalog's 100-char limit โ†’ done, detail moved to the details section - ๐Ÿ‘พ SPI bus failure left the slot powered โ†’ done, powerDown() on every exit - ๐Ÿ‘พ format_if_mount_failed comment wrong โ†’ done - ๐Ÿ‘พ LDO failure reads as NoCard โ†’ accepted, no reason fits better, commented - ๐Ÿ‘พ bus option count hard-coded โ†’ done, std::size - ๐Ÿ‘พ default-preset one-liner โ†’ done, spelled out - ๐Ÿ‘พ board select with Custom only โ†’ done, hidden - ๐Ÿ‘พ failed format gives no retry hint โ†’ done - ๐Ÿ‘พ preset code duplicates Ethernet's โ†’ deferred, backlogged as one board-preset helper - ๐Ÿ‘พ .tmp visible in the File Manager โ†’ not a defect, hidden unless show hidden Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 2 +- docs/assets/core/SdCardService-files.png | Bin 0 -> 26793 bytes docs/assets/core/SdCardService.png | Bin 0 -> 12733 bytes docs/moonmodules/core/services.md | 25 + docs/moonmodules/core/system.md | 20 - docs/reference/hardware/esp32-p4-eth.md | 2 +- docs/reference/metrics/code.md | 52 +-- docs/reference/metrics/docgen.md | 38 +- docs/reference/metrics/repo-health.json | 92 ++-- docs/reference/metrics/repo-health.md | 102 +++-- docs/work/future/backlog-core.md | 25 + esp32/main/CMakeLists.txt | 2 +- esp32/main/Kconfig.projbuild | 11 +- esp32/sdkconfig.defaults.esp32p4rev1-eth | 3 - esp32/sdkconfig.defaults.esp32s3-n16r8 | 2 - esp32/sdkconfig.defaults.esp32s3-zero | 3 + esp32/sdkconfig.defaults.esp32s31 | 2 - moondeck/check/check_devices.py | 25 +- mooninstaller/deviceModels.json | 69 ++- src/core/services/SdCardService.cpp | 232 ++++++++++ src/core/services/SdCardService.h | 149 ++++++ src/core/system/FilesystemModule.cpp | 6 +- src/core/system/HttpServerModule.cpp | 4 +- src/core/system/HttpServerModule.h | 2 +- src/core/system/SdCardModule.cpp | 137 ------ src/core/system/SdCardModule.h | 83 ---- src/module_types.cpp | 8 +- src/platform/desktop/platform_config.h | 2 + src/platform/desktop/platform_desktop.cpp | 53 ++- src/platform/esp32/platform_config.h | 9 +- src/platform/esp32/platform_esp32_sdmmc.cpp | 335 +++++++++++--- src/platform/esp32/platform_esp32_sdmmc.h | 3 +- src/platform/platform.h | 47 +- test/CMakeLists.txt | 2 +- test/js/file-manager-sd-upload.test.mjs | 2 +- ...lette_preset_changes_only_the_palette.json | 8 +- ...ment_adds_an_effect_with_its_controls.json | 6 +- ...cenario_Drivers_output_and_brightness.json | 4 +- ...o_Effects_pipeline_builds_and_renders.json | 16 +- .../scenario_Effects_swap_while_running.json | 8 +- ...cts_teardown_under_a_running_pipeline.json | 28 +- .../scenario_Layers_stack_and_blend_live.json | 34 +- ...nario_Layouts_resize_reallocates_live.json | 12 +- ...cenario_Modifiers_reshape_the_mapping.json | 28 +- ..._Palettes_a_script_drives_the_effects.json | 8 +- .../core/unit_HttpServerModule_sdcard.cpp | 115 +++-- test/unit/core/unit_ModuleFactory.cpp | 2 + test/unit/core/unit_SdCardModule.cpp | 254 ----------- test/unit/core/unit_SdCardService.cpp | 430 ++++++++++++++++++ 49 files changed, 1599 insertions(+), 903 deletions(-) create mode 100644 docs/assets/core/SdCardService-files.png create mode 100644 docs/assets/core/SdCardService.png create mode 100644 src/core/services/SdCardService.cpp create mode 100644 src/core/services/SdCardService.h delete mode 100644 src/core/system/SdCardModule.cpp delete mode 100644 src/core/system/SdCardModule.h delete mode 100644 test/unit/core/unit_SdCardModule.cpp create mode 100644 test/unit/core/unit_SdCardService.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f57abe37e..48490e23f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -140,7 +140,7 @@ add_library(mm_core STATIC src/core/system/HttpServerModule.cpp src/core/system/FilesystemModule.cpp src/core/system/FileManagerModule.cpp - src/core/system/SdCardModule.cpp + src/core/services/SdCardService.cpp src/core/system/MqttModule.cpp src/core/util/sha256.cpp src/core/module/Scheduler.cpp diff --git a/docs/assets/core/SdCardService-files.png b/docs/assets/core/SdCardService-files.png new file mode 100644 index 0000000000000000000000000000000000000000..0b948011130f520562bde53173e6def4ee36d34d GIT binary patch literal 26793 zcmb5WWmp_h_a_+Kp>cw{ySqEVHPApHxJ%>iZoz`Py9I~f1P|^6cX!|BKQsHx`^@al z?uY);RekTdRkzMLKdFvTQIbJH#7F$}=@W{atfbnfPZ0mWA7^+-@Hdu{iY%W#0Y1q| zifOoKo@Bv&!x~r_y!V`(yMaXjz<2T-YPNlYfwn1qVbbX_c+g^meWSv@et-fhIvYH)KrGQt$dKvy7Hv( zfuscmb$x{EWtB#bk|Y2pO)p87&VYI6^`gK%zsp zN~s82!NAwiOA-^1BxdR7&JAyGQ7kvvC%W)O{kxKmCc+`XyqwFlpJ6dW2VXcYIG8pq z*@51!&Shj04#91CN9m`K#h(@#1FO~gPBz{)1^v*(n;ydmL(b0qCA6~b2+(3@&_L#e z(-3%iD~2gS=J(q$-&wuL{ny)irO!fzGkr@^mR@QL02HY7;#;baN9}1iWc=h=MqwTf z=kr+G!f|FS#LQx~5Z#q}&8Um;+S1bDwRSWdEG$g;z?Y20DKj7{PX%vGDG8R-;fy-* zl;vDJGcEns+6T9cEXu%OeE1t0?4ExNsYi31nXwM(#6r&vdX+) z_FaaNo1kxBhLX~vG0!ZB?7FwLH&76FTaA7P4lPsI?~S~iBe0g1vb>q(?e?_@Q%n^8 z!N9%y#HsEoOK!BrY@SGpii+v z*JrjCx7Hrs=L#vw)6zQ8_eRr{y58ry-&^jkH-ruPBGyZV@>DC7H~8x=@;}+<<)^;WBBjs^SH{AR|LFr?#u-rl<>Qqnt?79t_$y|%gAWHkjPaT|GzWcFER z_?YkJ(o4`lPYCgsy*$CKb=dY< zQ2?OH!u!Kh+Lg79g3lCRD^w*J@`-;6yBOPm`U5Cvnu`fZkJ14lt2T8UV}vDd4vxzfBAQwhCg#gd zpXCp$#H8dMn08k&&`rc>=6tdE%g;>xH0^T)pMDC<-FZ07 zA}lZ?wj{Z(Q)Rb#{qRkq#N4FrFKBJ+vvmb>-O}6bX)&#`ATH5o*Gs1=>7{v{T(b2W zghmz8Hq|=Xs{O1@C;`E#=2M;C=g*=wE+aqHzS|Z3kYTogIX#>4M1}`-bAR8YZ+t)D z&k@~EDwJTz%0MBWzf~<$LFE%fopd`XWAHKp*X5D%5CG$`cs@I#5{cZ(a>HEdzO)}% zGHtN;q~9+|T7_^Ml!#H75~lS(=>ke`XP5`Uw3F2j7ki}R!9$q$t0dWnbkS*=`W=M@O9+*b52>9AUh(b*kkpnAK@VA|rp_1qQEK>EF!c zTs(`B!ixh223L#!+A15aLmiU$iEKof{XJDcZk5!B(iaMX*f_nd;vu08VffYc$VG(AB=8|RK&)Lc4+ z)3?cO_(ut{S|$-+Ye);ryb-~H(;35r8}pY;YmKieB*$`b*`pc<-jaYu4i-pW(0d&k z#&esoylSY3#oSb~k2AvkZnqeB-Z=VK9`+k7`r*i!Ep@*^Ur+ zn6Tt%!>)JoUhDokCep0AZC)j}Jm_>~WF%xWVcD0iqQeZDnz*@7q5_rdf;tF{J%( z*T(|QcvpiDIa`*&sZ;)jy>lp}aL?S@mYYuH?xsYSi_!x6xen?`E@{vIY$FQG+O}S07D%my+@H)i=W0h#lq#2BdFJ z*YZ5n7!Js62r(0B0Ffq6(`Fn~oN<$Ae~)_m`trs5cx>02zfY2-Mv)O=1&|JN%@YIR zk#3$G_&OpDWPH9g&3(u9@imeQ<7Qg^W#f8e!@rr;zE~n7WAAIbU+(b3>k9*uEY%T8 z5hIojuzf~+7BY<%8fXT$dmg7l8xQXxt`HK|ERLcalZldj+pB25Lb#WgMZLCWs#Yg_ zAGE9PZJPV8{u@^lv{*r-K``AclMxSaF;aCj_O>V(R2+$>uE+wllKPurIfbZ^PnUvZ zlycs0?_+R|t0s5F;z^}&m&&Vmiqgx^+!^?*thPES#9h*PIxsb1@6P5cyuu1-Cacym z)n>}+u`BK~&iGqdb%~1clfx9mizb9hA;i7HD@8m{ltYbLfXtQe!WR+)?4+yEjK1o+ zcIz$Mc&iY@yJ}Usw5jqi-ePukvGN6|s`M;oCPaMhg__rr38JJJ2#igg3m<5Oj)}v& z^ulOts$}sk;@f?ScE;5{#E_u>kiWy;7ZPQ}B;w+?t5~J3nsVF0cXbF=9 zAI~YTj+RS$IvyRK6J8hk zDpDpPOpsoYETvLbYi6?Y-!F1s$Bq4Wh#Xt2L%^*p#M+jo| zN^hn3Lv1?b$ttZspu4>nY0(Fua|V*$!t_m<`NZm!tCkwS`l(w<{YLS3$D|J^#|KX+ z3Px>(#1L$ddbxKKqJ5S{RNH)(R!LI$X_c}n(XcS9ah!_rv-6qjm*CM|X{lfwtmGEpyQe~mII|{0 z@{&igp)kY0>)t+4>!Z|yHa0f8RxuARp;G?YCzoHzz}!xIo6r1>hUe@0dQDcShqJSd zV0dM_+?ZGz@}+@S(F2fC;i4EwUMuoHxPaTD(!cQ2qMIRuuGQnSSIq{VKR~=34cFoP zRKna>X+wiKf{9v(ipFuV-R83I*(>*i?&aG0fCe(?!)) zo<(Pu;eA zQd@=1B#mx6JbEL5adKaQ8RT02*WP$vA%S0tunKv`zB9oOVm|FOT$VsK4oR}}^;Z2! z=2K017#qZpKs%xpf0>@M)2U)9$)6K$?&98Kx8s+OFQ~TZXa{THQw)oju9Uy5(K1`9 zY!D|e!!+W%yc_7ZH`salZQyOUHEGRb! zZQYp%kK)=-7b1jaP2@`Q=yCLGYqGAlO|Ovnl#8&vY!PWSY^ zP>F+H>L_3XV^B#TrX`;5SL>bqxLCZwd)t&oCB()5wydVPW1vVjjyCv z5n_y&^uv@hjXHjRrQ2c8_Z0kbT+#~EL&FEh8QqP}DSOc@QiUg6I+-iH)&oaNH#e!* zn293d1I1*+JxSdk4KS7vx9@l|mMmB*2<5Lj+DYT&AJs%pVQ)H!@JY%e=U+KiFgaTyw0!LpaNy>jFMm!6M%V{waGOG zon10<6LU$ykT$jY3jaL+y(=uEgDcr)%|OB;?OCFm0U>uSE-xPP_Ft}`zupDC|0qRi z`8U@$bMq82x2V@dA}O=@*1uAwGMn?*r^jz+SvtMkc*Mxl;UFm&XVX{>mo?y$lKVQ1 zyaKQ!CXGs5wc#{&u(2fcwuexx(*ZfNg_ls@&+dpIv)ya#i1?fBv5RK}SL6f{4IgXpmnaN~P>y7U=m zQP78*h?^nV(Tho2tE*eL_T~@Wy^ydYV_^*S^=e3WT~MlWwxWI3>WN8ti2?x9YRVDb zhODFW5TUlGnq`NHkvW8TRMqX?>+TXk1?qqcBfbiY+VDOUat=l@v2^Yev~5erc?@5nXXg(`{6 zDRIfO^D9Jxuw3A~>f`NRZU!0RSFX)&ky22^uPsm{qdDHwSv%a#=}LiSg-I{R+%r&q zSH{&MAM3n$KO~m4 zrX{(sJ~}M;LNzwp7{#h2^x9F_eD z+e9b|dw>ed}h{j@@n#mmp@BHbGr4gW9!^W3$h3g!iS_LEErF z{OxOJS?zJuT-I1bpFboGXoM!uf|0bb*W8JC))kamJFIy~@k2$K6y6SUz0%3HB)@W1 z9jDCdugQ<`%~2zDDR zc{X7agIhjRz}}CKvoO-rRXDK#Kl#4;%-C0O8~z!AD}pWX*h5l>SFWA>txJCiLXMP# z{19yd!}YK%iWoJ1<3*K5&)20fXZDVS;2MND=x?~FaLidk~usnP9C|8h%Ikp>h z+FcvyG|O7OjNUiQGj{76Yx0^Dq9OPW)`l&;t2B)(+b%$I)$;VP#? zhVSkL>iF!r&y_qmcedab!ra;KYx`e{1N%vLV+rPM*T+O<=^yMb1f4WqdxN~(MWx)3F?;W1Ix%Vfm2i%71{raVE!X5{C~wgYTIyk zNCWZ_8hx1Itw3T`0lYyebxKPH=O5G}Xg%|FF8Qv|`p2>o#b#&sr*eb&f{$^j@RJc2H8Gs)Q{@K+7B*K2%KJNkoArDG2k|Y^4_@s@&c^HV7CG_A6 z!(1wM#m=rtH2Tz$rjJA4kKvtA{9w|Da2Iy<*Kb=sD`PJ5gTJR3=6bN~Mh%krl|j~X-68~Bo^0#?O9{H7K^pDyx{a4ND&DPV7H{WcD=fy8lM z4PL6vE90!lEfLn-#Ooc8@fo6l$-}J4x)iCyg^+o&q&V_a#$-7Z7#G2U*|Ll#tArIX zg%-Gnpue29G(^mi)OgEg&FRjO>?lf*Vu4}&m=<+9r5YpTy_#f*OW8i!L#J{@{ZJPOIX?GUOQPtI&$7c9Z96{!a0pK`?4|p?i*W)lH0f zni*rAy!9vu)K*keWMn@MX!X5;?lxa6G&D*z=nH8}*3KNJ8zmX1EV0SaC(mk+P^df0 zXazRNQ%7dyVN&}z(kmtPMfvh548F0a5u%1k7dUzv;qfS{TQx0Pa7J#nkzwgF;UkW(M(9S#eg4i1MWF2e>aTiq1P*vgAyz#5YJfWoiM(h5;y|P>$pm8TN zrW=y#u@4h1jmJrz9d$>BE)xL<`Rdtx=%TInprm8V-S9_62O7P6+_;G@*{(s#kBkC0 zUwcZXNDUnS~$2*!{)HkM&piEV*P{7lr>m2gR)|073VBu+^FOYgPDK@5aV!c(V!^tLtEdS(iN z2Q_gJa+Uu!P(=m)SEyEni)GU{YyXA3zoiQD^5Zzp78a%Dm9sm+0^b^Qg9sk%T_oN~ zl*?z@V7y~{B=Ns6r$IkO^e6|}p&8jULMAPLb2>ob)`Y(y;3qN@7?Az#NdcfNEj5K6 zUnHXJuiTbKJ)LibvVQwf2(>e92c-)8wOFv2)Vm-TY4RFQaz$#HF2CBLoi1IA_V)=+ zfL72sV*pF3=%*6sv}}j$p&(ve+C=&h`Q#Gi)YQ~=Ta5f{ODqD|dADV$lelu0fA}>B zyUQn;)9l~F0~I(rq8U6BWT)~i@m7ChC;!~MM}Cr;wR%h*!$8`MoWih!lp&<(k*R{l zhcph4eFQ3bf58eK!l{_!sQ-i12idBPSt;x_*p|jfsVd_hXvDL~2%8`a|JLxwBs4f1 zl$7GMi4OtcrC|aRizu4?2ERWA%;S{nJ4|st(}_Nlx1&=P0oiTrbbj968ZI(IEmCZFHCismf70L6+`v}<#_Vf|z-Sc9Y zgJBvO%2PLNI~T%2krdFfrou*%D7Q(HtcGBORcPyaBrYw6<%SE=EXHulDT#VkE0(Ng z_)62NCi1sRIg9M|C6Qut!st2{Q!*$|IZkUP2cHfdiOjYsfyB4SfReMyc4}NrY1(v}L z-mw3yq;42LEOfqBI44+3Y+(uxE88*_uULgyK%QdEf3r-- zts&ldI@okb2(V^^Tn=7_lAn|N+1)>%PsYx* zUj?@h=_EED96qZq;Eih$Yop_1CuF2ipkMd)?zFZR0a0rlx8Yl4%}ud;+Dx4wUks8y z-{^+KFeEMg@l2E8J^kArr4YlhZurH$GmB%tL6-05-|PyapRPA%S&arsuX%@eu&MIr6W8iw5%LV3 zl1pf?Kaiy+|K!qD1buOrOAr?6)+kCk=0waG7~5ux1f+#vxo4lr%_14`?}plHv-pNc zh2B+RLPPrI5Hsau@b})F!x57XGpR5Hgm#IB1>H=Pnk{Y)=1yYA98^$qo+ex1LZ#&z zYUyRI+0<|F*M5Z-4_Go88E&!pwOoG{LuKAIo7_#eP#zv%{EPkj+d!o=Ux$s`c|ed% zxmf+XGoe88<@|}ESiy$JZVzkL{@YU-orFN6+$R^`mk^IzpDbPwqt8f#bI** zPW8pE+wp8=V~4?@aUV;{+-9+@DR7W1g}xAxY8Qlevunq!O`dQi)E%dk?y5Vk)e^|i z#1H?OalWm?{u;t3@Ab^e=e819jl1#WxRz3owlsj5^Ymh)%N;=4RN#u8D#ye%vE1(K9FVx1#LT@zp=sHFdT>4nI-yMo{ z`Oo)sG4omGezp{w;eAjJ+H?Km+)_s(ebI2A+x!ZqV6Be}fD!S+BNSm4O9)NU<15zsIvBewJ-KvT%$5#2G>J2BI=23MPp{gkoYVp|BWV?w zkKZ6uT$Wzncr<(bYL;XS&iSpL7#4U)iE*V}xrEfirHc03kNmVYO4F(;$(}t8@w%)9 zFFAhl4?`msUnPPgewmIhcNHnHBphisAyloQvx~T++xPfyA@yIDSNoernXq0L2}&$R z-L_=Wc>erfeJ88Th|uX~c=1N_X0T*;vh_m|cgP*rjXhyn57G?!8j|`gI;r$?UvTv>7`B~K>=M9WME^=!1xp0QG`?-^uU(=D37P{*D%gym!Q%2m)$u#8K zJsPpX0>P1^vCv^|6Na5p-keEozocxmG^=;(B^!Q=$ch)Q1al(jIK6N|ZxqFpoegBR zuzgZE+5Kw$XC!M)fJ)4y{#-@36ZMk}K7LbXDWq5f!A)x8iY+$*0lftENg&>cl(YP3 z*{V1NZ(hiNG~8D=On8QW9?(Bls?_K@mB;e(leOw78M~wFu}t;v1>GN}j zRlxS2e-U~nxEFasaHNq9kAt6iy>=C7VYQfb;zr>?){WV^gXdJyHG2XVko>p0MPSlg zwI;$Y`9KlFQwwjFG!nnRXa}jF2S6$EUwStyQN__OoGVR~wrPmS))gzCuMQDfL^ZW}JZ6IoF!x z?7ruKH2xorBw4+V0(^p6(cH*v{rWgTgcL@?3T$zz0x1I{2abv90Ohc|cW-(sePSsB zG=ex7Bxq$&(3C{yMO2<(T(BKkGXIY8_swDNY|0%YpYnpubm9fpY4BNG&{m6<&RFyt zlkw)IGVhB`jco!>fDRX;Be!Od2xh$Nbpl!h*yK2@I1NPF?F_)0x*4OB(!+sR`tib2ysm+;tFA{C4~SRY?~v?(Zkf=qeV_ z0V=I)YLQ?}+uZ7+m+R{aP4B>{0m3<#fAD|@182-8iUVC4d`2)|!G!~Mqyl!hKS9RK9DR6WvHo}7 zSe0D#|K(6?lVv4g1ds+G`v}RFN<;c#x-I_(#8OPaGQAddD;k`1Evpj+GYui^%f|GL z>ZVh*8Ld9&${%iBW?o@oRBM}R)p@FsaPWFbxIkatq=L9ya0(MgT zKWXR1UUq$*VR>od?RR3bWLOBM#`qYtN__bSBW=Qt>9VA@8y)Pfs(5X{0WS@o7kI#f zCI7;7V$OFS-sb7i^kD|>g9z3jz0l5modZs~oAbn$N`Pm@27~tZLkiwR!2jeO{*UWJ zMG9+n@d(WGw{H+&FhhhEOjgUE2?-7V%<-MoDnL3V0qfe~CCfu_$eA%t+Oy>G zSQrH2&CKHGapcd6Pt;bCgm(E{;eqiwFGMR z^Ub28W~~0FN(+{FTyiurI{Cc#aLL|jH3~-s(0L{&NHk1Jq1kl*i3^4pd<$Zo;h>i{ zbCIRh4U8O(RN3VC=~TW?eGi4uzZ>Q_b!uPphA^a07%nsp0TL4tujk8Q=7p>_6M`sv#SVEP~@f~FxjDr`HzBjUo=c4bu3D?h`@}8 zsTy&ZgsRf?+2i(2@B$}@OrFv>l#E%wRmU`hXobt;HjRx8k*FCbH6OoMfvCsC~kgEA@^-NhCo7CFe^H@WY@V6;)?Z&M6c)rt@13K5>}M| z2N$q#+r*_19eSnBt|0tuhGG-@d4A5OPGT#7&8_EYyS2jYR2duIodLItJR$sJm;B4K zEB3TsbKbKuwm(utlF&FVq{|q`C%`5{j{-(VO>Xz}Zr3~T8d% zr(`GWgQfujHIKWulj~lR_O;@)D36-9n~oeF!(=u_G&L#h3UM@2 z!AReCrk8ZewLQy;pDfU9V3FdtUFd+zDGPymL(AuUdB32l(+J$XQz7@*`~xkfErSf< zvr}#23vX89RL54I0oiG}4>lFsWnQkub;U8<_mvI-Cv4md^P)vJCOw~a@2J!q_knob zZ^>vXn#P@)*9K@?zcPxFLT??^C6=YdcmM1{9o6aR{lG&avec@NIn2VuF(`S6vDMcT z6gr;5wDoaCa`Pvkm%r6Ne*IAlB%NX}lVnbeM}-sM3o%PxDi}o$>%)fUg@njl(QNPg z7D(J*PJ=04^UsTem_5x_gk=Nb7c4I&RS_}WM%VM$4&3v2+U>!Fka=#jG}Xu341tjX ztYfHO%nz`TRfm6jxW=OnR*dBK{S&`_^sa$BeLTL?A!1gs59?E{ZuW#aqW@_4hq7nD zjr!~AEPAaa=huzr8y;Ck!_Ia~ag$~xxjtAI=)@;)Po*fcx{p+M;UELOjDp^vRkeHV z0pRfZf~;cl%Xx}}YXD`FGrT?vw1b;uzL&DMmuzOH#q)ZeSo0oCMXjc1*SJV8cysnT zsU=#K$8Y*~T>9;UrzUk1&F$%TP>t5DLRFsg=PbP(!&bzqvcsJIx-| zwTj$RlQXx6lcb-6pdSua;{u`S+)Sv+@{rJ?Vq!wwmkvQXr#riqfeNYD4fP7wNF&kG z1V3j#0uI_og$Ehs(ecxdeQRXVZ6glcomOj*NwUNO%Wd&~j^O;IhrMy`aUrG|1WT$FVu2$W<*$`N`Qi1<65fBV-Lr|E2$Mh*3{rlt~t7Ny~)M zVP93P+&82-wQ3ZohR7V`7xYPp?_{SEI`MrhJRS@ytF*%U2V`id`?(_HB4T2GFcf0# zHm=B(j8Y}ai>8{gBOFrR!X_>m(~U5u$WiJxMUHNYqkgDujnDz2XhhT2Q@QI{QhK)B z6?1ApU7Lm_MlC&)#(A4MgNX?fJZV}i`sB!;!wwe>KYm%M_bp- z{qmM&n#EL;vDRUIg+bf?buTQrru)UB+aT-L885G=yCGO;;N$qvJ?R*D#M4TMJMZ(n z;Gzb|TQpv3A0~?!xIcOmmyxWr(jSYoO4P{@U-5uQ?c>tRHUM0KETtU&pfoYBx8L2w zf>&daoP7Yh=D#+rsv#mi&r0pwa$#}Bc8m3BFUKU^9vP#r3zK4u81z_bvcC;X!@bAC zef8Xkmpk7jjkkYFZFHbK5-oH4-E69UPV;c?ey&>m=6hBaW}M&mQOo#uCnTpNo?POh zA_UXYyL_!9my9dASY_Ej$btCIyo3OYIIs_WnprE?GLciyi z>cwcmGi2M=^QAX?ZMnyD{0Ym|`8`klJF({>=x6{u@9E&04P+zUyuLuwa)*xy=iRHe zeI-!v`ENvl>lvS}TKPvxJxUUwnEEEpn>kxd^ep_a^$b?Z^(p19JOrCF97GEd@lQ?K zVQct_{yPtq37EFz)Ua{TfTwt|%LYe|qO4kO}0Uh7RFuB*#>!?B6pTdr;vJd_Vv~ufb^#WApNSbf3B4;!ccH zV^Yl74dXITtf6%%nr}k6%DAqW9oz3-*7z+rJXS>B*bbRXH~wAvMm{$We5}PxTF({s zK3oRPqCjPZ*))qqLugNW}T)nsm>LcxErf2rLOhdqL!T_3uhuDK4orT>u8*2O#MhlgN z*>OA>)F;h5ZjZJpLhqhDmX=*I?i=q%?jMm-|7-71;Zc`QF-IQbz(X_FypH|fREaB2 z{d{A|B)|sHu(WqgRc*e7Y0va_*14bU377^%ck?`Zjm8Mj_sGh1C%sOLmBoj?jlFkr z5?H-$WtcMQQ7WaeV&E*UW;?kbPe^1QB3f&b#Df-XK)jDr~4oSeCThPd&wotl#G0R z9SwZz*3RtDNe&%;_{PNq0iK^|37FA7O4uw(&t+}laIH!!P|HAT55(UpnB6C~i<@L> zimN{{#zaRK7AukOyV$l(Dqs%=5j%I_jIOVtvCd1T^f_Ot{b!;h6$?#sX<^Hl(t2zJ z*+0gD)@ah(HQ`hN3r$2=lQ>JNO#QEhqdrW@mM6ySjVnk?FZ8^2XOMG;6 zOk!Yo6Ly2oS;`~zS1V)OstGFWkJ69@M#V$@i#2Gi_L|sI00jwdviqH!;QiC>{Zd z0C-O5{=%#sXTD1|RRFPGr53Iab|{Qf4YiI>Xb4-n9q*XkW=FGj_4?B|8iU{SAw|VF zaen;3fTaIc_uV?reJt@*fi*$t=1v;Yj6o(!h=j{gjff96qC+cKLF;=rcmnGJpRStl zsdjiGqXKW9#=XesML?XlYjHTg_o8u&kq9KKfQpca%H|rAj$v7A_u21(l3JQ92 zuf|SZ%ncvGnMM|T-&TJLaHvw%sb+qmzieP_!2#=5jZ37=uKZcTF0dRycP{~cLhd_h z*p{JWt9AuTP6dbqko)RgUM)x?{nU z`5so~n^%}_nSY-V?#^Tm2M0N@+2W6(_55itlt|jG;73Yea-$9;p04o%e%bjmdvmL& zK8B-r8zL5&_Z={c_jE6sE94rACflwPP^{m@yKL=>hhpY80o`$Y8P^Gc03T0+C-5-- z_4@uqq-WO&&F;Nbq8+DV;S6{)YDiV|G*gC~7p*~lu(|<*>N>ll{5Yd!`*x) z$a$Zry@*Ic$b$#DBU91|cet@&qj!0IQb+Op4sISU929+x5GYCgMkgeu%06(8#wbw( zuPC%Yqvbj$ncaNmc{z(58>ifIr@HcLrKozN`7TP4{zBlP^=8A$cd>RpyF@<@geduu zCFoMt%CBUhk+~>&DxVI8o|OcjE)L8~gmX1U`at+CvZ7|q6IDj9_n~Vib#QT+0qXwH zgpd=`rCKO$C)W9j6~(o}9{iowC0caH-6B4BTlL8qcSM38Mi!0HEOZN2GD0P*{q;V& z=h)Q_)>Cr1ACudyYr%b+pJMJLCuTLxp^_4AbM+S(QR6VT9Pkx!z}N4nGMj63|Ddsa z>&I1KubP%0Sz;Bi3ejlP-|E_(xIva2rzai6i?XnFj~R+Yqf{cc)v7HL^3N08_2~2e zg7(>{-zJqj20!V;F|8bsug}X&;Ai|h&NGB;ZQmvaA{YxynXBwu|GMtZtC8n~p3Qy3 ziOMSFx~xmco%lyR`L<=g_~LY@po*Rszvy?sSy>|Fd!AQdPI|e;fw?%0A56eZy2KZv ztn}_*1u+Kb)UGTb2UWZ+*S*AzgWH1pfre+#DvS@L(ZT_#uT_2D&ANz(B{f`RtYT0y zvx$(_DL+Q-?bsMa1bOO|fExD40>(M5l&feLR>$ph75@o&rCIK1y$B`4o;5vh(&6Cu zSv$A)d7(uJ@Th%}P$Ug@0Nh!1FzsFlQ9PrZZP>o`Zuo(HTIEGKM<~S$>(8O1o5QBl z8T{wq4AH}?8se%7Y!)ST2&mgET;xr2;p*t)1+Gtsp9`0i*g6P^{tqtTzt`%nGLOx? zD~{wh>{&0f_QiLd2HVvGh-;A?gc3*QQ~6OGgp-M8kq4aB9S9%(zBKRV1GJ5U*h7we z#?1TqMb+0$THY|C_SlyW8>XGfv}VxL%y#$t$P5|6+e5V5-bdD&>yMA*k_#shEbSh!F|swY*HQFnM;3xQ&&A7`Y+lJ4FdbZ7o#rSzEHZ@! zyFlK?eje0ju9D{*1m#`!t@0=r75jC5gR!|gUOU{r&(;G!U`W2eT?POFW_E@)@XsIu z(wwM8+$KjLr^!o_IxP+|rl!T=_=M7pt6$T)aQH|)jxGg^T$71NAz8~a$ z;=4{?J-(*R9PV7XqrxQ{s-z_AgZMa_3kx$Tv-)k#kFH4sdF^EHnX5v?#g1B|bNuwJ zo94xuDFaEv<46Gq`T1AiH~mKdF3SU%(F`8nxRS6^m(GJ}wYQozGmOYSTG%jf*=bmE z;-koL;$<=4*~7+KvT_jMZ*BrtB6JhzE|AX)!T;~7z z5JF(dl75`%aH>3|bU~|eu`DGbil&TYXsAr^nG&A~x86s0_>FoPT9-W8Vs_-!Di*#B( zEu7i?AxD)v%AOVs`r#S$$EFU;6latYqe^#R*MThT!{n+PD4H9FddOv=Vjr1b#ho!{ zMaRtaYsm(uf{9(%`j-xixj8#B1r}nmywn$y7V(5qb%wCJb4?)d?T>lXf6+KpyaxCc zJa~wakw{UIx3C3sM;>)NhQGCkck}oak9R1O)$-!y2FMdKj4UFZc!{_(*2YyndI?r= z7x(`U#9E7XwAES~pY+3pPQKcCd~HqmXx|Zf0AlT*+rcF5up>p7>+x!U(JH56LLrpo z(tkN*U@eB>@FqzBwyEP*G?4!De-zJ%XSeS~n)5&Yo@FH8bUf1B?JL!kd*Fj6_irCQM#K!3F*$EhVGIcYWCs#cF*QHdv^bE_%UZVyw7uA z_f;?BlYQ3A{;1ff;G-GS+NP~yKCU<)!9P3Cxjv1$cD#4@5#pV%x;44rv9F=dhnX5w z+V&I0AVY7ajs0tk%QdWqtz5TfKrY}O2uQAmJ(auih*#+f>$0epGV|ps`?6mlSQB;xD7pJ}91N>`7l1cY0*obA`7X4gQn=f zL5jdxDW^UnPDvjriQW^d;X^dmRt~FM*@OX*6QL(AVHj_$*N(Yh;7%WWpsb|kvu7ld zTuEw-`rP-xqs`vWIxeK50ga|p~-$LxK(xdQ>Zkh{c3Id6j-f;d^4qd zoMC!uULy`Bnje^)Icp%K{uIL@M9 zq_+R^W#C>d#X7OW50fJ0E1uA4bkr){gf6#7joZX|9!zQiDCPN|_#kx4v;Rwo<4_9k z(#g{75+{Dt*vF2u{l({Z`D_ey)6I$z??Zgrj}H{_gzR97_c*32{oxjT+F_LD6Mgjz zk#sN}VX_v=gTX+l5HaKGbR>b_n;aP@nOp9p9R=qa4Tlxg?rsJ0!~|Uqlq*7JLjJ?} zafpcvb+_Vn>lF=Iw!zsn;SoD4$-l!GtBclR7V{0Vgw7XX2wcU`4~=O7H-5li_90t3 zE3SXTxAnBTvF(Lo_#JdD9_NBc4hSP_3iCfS+N(acX71nzen$QG>?aL-IjN}`8@9mu z$9KC=PI>H71SOOrI?QW=DGb%qEBPNHFQ#e$XL84Pppw=B|66`L{6RHVl42*W26Lo0AK;FZJvsn!P08U^V+e zSUY*@bI0w|!tMa%BuyNqrq^F_7bet&K%CDx-S~&%05F@7dQ%vGM^LfySej?HIpw?c zKQZ<_9b4i@qCwEFFq4s&OnVQieE>fNdT1Dh1OGFYvZLO=&1`_sE|IdHhW>~3IdJLA58$^qRbLGQE zudd1ND01;1$uJb*Xi{6QvY%aLoE=1)u6>!`rthS-C%K?KX{O}t5&Y?Ht zoY0{<%U)itUsWWTVrU|sVDblGe~EZ3A0F1XUUgjctZ=+{{U0qMM_U#bXYYHL+#?;Q&z*$oJF6D*I%l=)krLlT_Fxlz5mWS!E#x-%(CJ&_J21|L}>|2WPZsM zD1ZBeuWdJ1XWyBnv0G2)BEkV_XPBe{0?rc=F2WS|Aa=Yk*{@2h0Yhm*lWRjG5+u0zpRPYrh2c7+QLf-day2jG#Igc zt*lN>dZx_hMj}Q_*i6AE>`ToZ-wR}se+^dVZB)Ec59qO-X|_k|5Lk}LSZTn5qY6*H zo2_>k!{>;{pULD3x=`cu8RGa@PgHcg(siO z43@?TXy(UFYu~Uy?-BqKMS1ZjYsf&G&~Cv^nkgB2HISRtqpDLD_D-9gHa#ujd%7+$ z1)W!J$Fze6%;l)z1sBxEWEg~ag`#8n28P%kL}f)~;T(>Lt<1i`8?zhfGeYAvhT_Wz zmt3|;R{k`L0jh|c%_@EUHp;rGGF zI2}3arl%+H(9G*-w7C95BxTS^f7jFba`ljgIwvl1_cNPjVfBwz@0!fpgX#ds8Nro< zMOjqj#E;X%fIF9SpW$|1f(gjUf>*?sj386lpsc<+z1cbkp@1PZrFn7h-vAVWL}!!D znS}NFU2nz!Knqe*HUl1zGViNOA3uu&>SM|vMuJ3#^x5y+LRwZw?2P=oCm3WQE4at%@>PZ1z6QCE^dPXBcN|ul^h|U-puJx8DIb;FVFUq$b z=wYV{Z=yom@BG9z$r4Jgd?E%lc+xJ&*;f~D*#Ms~QKCFQ3~$Auo$Ceb z``q<-JvZWlYQBwTnap%Vu|cfl#-+H|j%K7D&!F%%GdcEUZupsgz4HaErhk8y)$h5L z!c~3=S#iA*YYx(9*QQP{(H_w=$QiuB=*-WunM_7wJzHyz@NOv5^tMLFz9o%6?CpCx z-{5B4^$a~&z=XNoRUzJH<0KY1Vow-aMQ@=mLzKt<99$vl+i;^*iXaj^PpbTOH%z{% zrTdn{OeIs@zhd(R_l5v=S*kqbjjdh?ZHSnhZuak(c|7)p#iWenfDU8^9z`{M#uYj1 z1jl{0M%o^ddW~lXBK5Kpv}6x$`Mx6$$K_M&_C3UYF_4!l>$hcj3=Gax{W1-rl8yqKqD8niqMR`7gKLrOfRzGY&+WVYG)#5lp`&C zHBNEUfcr6t%UJmMzzERfb-EYsCi&`_2-q1oVSjRTI1-qmCr&6Rkmnxpe*vOD4dUGZ z0jC>@7K>M=uT0J8GeN-r&-~H}#-shBJK{z)T!Q?9cX`NnRUqJ6Qf>~~VN^!21+e+` z23kI#ZTp@}Y`Lka;adwtJ`)#tTdE^>x2!EXIf1$J_ldWwJ>Kr3q?qf$2nh-ES1U^6 zSOl1~x~<0k*ZvvqAs{iOwQq}g# zm|`|H|9|5GKAzhZ+Z3m0`yC`|B5%%~$6i++U3_~bd0*kNL`Z2KB$&=* z+h5Xh`t1tcVcAnqaO! zWqZ!Gk#dL|R!97iEIBdEwpN%M2M*V^;;pg&F;Qwpqa;W8JuYj=!n*@jqx#|cFdLcAm4##_<{lo@gBW&R!RHiVd*7oi8zhnLQ#s}iH|DQ=RHlbpJs|#zaCu}% zrje=Y7(b8Slq`Z?XtV06wYge2OnN3g@?r$~6UO#MiYDb~(W4^wfIZr@)E4q#sP8Es z<)9|SouTJnVo7jvMmoxLhYwrpTO9`_TWZK1lnHp|pvUta(Blun2mb$lL@~l+l1iv1 z%0I~FTDLnoOz6CS4-0?eqL~Ig>Pt2D#>&wGGw*P&SQ~5^e%|g|W0mVG#?z&9fMHyL z-NUWb;i00}pkOUr<)tzVp884w|9~I^&CaOl+wF8gIvHNW!sNol`OXw7xspiQYunj; z$dm)qjYIOJwNmo-=d(PpP`Qy*kw^5eAFID*YB|xtE>|9GqKz}@njKDIlSG`y!duX) z@NBkFZh%w-h`3Du+O^HYMIWGOGCse3MUI^_cYTx(*RfZ2$Pj4=&>d0fk?>wI1Y|MP z(h|Q;|bWQwiTrO`1g-$D61{usgj`MSkHBdT5!%& zDL6|u-K^&eptDJ^mB;i&tV!dkN`!Rn;1Cx~8>Qi&V?^?bM~5_MkaALw z6DzizEPD{UT`fFd`WpF$>7x@Lho!Bcic>A)Ug>Mfx2U>e8+3gp*9=%*Lk#XudLVj+DoJD(WqsX|6;HkbHU+#jluzWKcpNP}C!tMsj8 zvGa%^;l3}^D3~R9L$#B*Ms^AR!M=--bqe&Q7vC?0ukR^4pd0T6i5Kni7+u{Og+5Kl zG8pwFM3NAFR#+2IWUNZ}?9^i^*Gqx_qhNK1E3ld)GH*g1f3I!@2F3-aUFpvl2NgX1 z#!Gw;*_F6anDW`1+rniS1Z+@Br#0^Y>amA*CSG36fX_H40@dBbs@w9@V4K#0q{k5}3^XN7oB0y;n z(^5p4W!e1$%eT@3+2np4$q0w=|FDh}Ejb}ZII*G6MQDh{r*~~+gq3(6;5pnOOH+>g|5xdbWX;6q zxVr8heU8E{WT#L^o*aGt8Oay^o_&X$p_?gLnVwqsroBsTcAc3R9if@^k~3A3+%1?g zkhp`2z$F@QT5u>FKkW`S2-eNp;#wuOMoLD#<6sg!_u)7@|*c#4Ckb=I$&B0w;lo*v%TV4;Sa-LhGOXU^@Zm~QT;yZ!coK_eQHR=0z}F|?3-1Bc*V7d8 zBLpC(jFA-yIg-oMpz;FiY@cwM?q_y7Ynx%kgmu;YWUX23kUcy_GWMwGL5ph~R1%fBK( zq!U{}zT?VKU9`e&8r6NeMv>n75yigKE9Pv*=WGFYv2BQ=;QnDeP4x%a@LJ^7RcSMm z)z~cy5bBBAZcw)ywjym}r9A(AoXW8XPzaBZJh_yEqA}&XtGBpVlKJ{RQF|si)!H zCj4&8Ndz`fn+h~Z3Bs3~3ur#>yEAn!4PRTj`{$FB(sUUO4U~RId($fASER?q#VPF6 z*pj+&aj?k$g6DcE=5UaF?lw09e`~?*Izx^TT_O;*(~|jiu3XK&K+f zehce?TAZKH^YCI+Wzh3TaPpenY|)OfcJME*b@#6Zdob!2`7q-jcT%i_L{>Q{21mAwq3Tf z+CTo7Ft2mno!K~rxoTNuYdMK>UZlrvUeCX)eU|4qJoG{6fN~aDrecK$?LN^|3&f^N zNN*r)5TX@#^IwaTdVmVwKHY7tbYmG=RltAsR1SLO|3fD?jnCnQRl`J@7#?OM%=BH2 zo!fY*XM^>lf0N>U?mH{_{*!B6Q|;Zt)8lkzsP5%e#m9>)w&PoHznK2PfJL?);~^5U#LT zeP`Wba`mMhGE%b9M!#YJa&KQ-<&>en9{n2NFyp+lhr!PBHS#YZgPwYA){wnHJw5>eP}OlzR8&+<{8)l4 zw_*8yF_t(>a|aOLZ=c1o4S-Jq<^9{6Ik-A?w+uE8<|zQw*yR}WIW}gIVPHSTi&o*7 zLZf@F9QE1DmuCh>+%>`rP$YW(IpGd{860%XG>WlMEnftcQ;|M^HR#!TF;VHZ>6nEqQDK^%in~_hXh4 z4?s^(6L2mF%_hecK}iEA476sBWUHSr-5xxc7MEm+KXPA3)vY=K=L$X5sT-bSV|qQM zJTmXcg2ezy*LhW7^^UrXLMQo;Byy$MI3raIRl=Gt-DVonF(;MTMk!(4PI}fYDT-JG z%_-5j+(p@#4?xm$X@UKkGw?9Oc&l;iPiwCgoC~wMz-%rlH{zU5Fz=A+@ON^( zREpvo%A{`pJgxL6;={S4U|~ArZXbM9GP)uihZ0mP;!s0z0GE3-w%<%7C)QR$IYAyu ziQd#uLpwlV%-?XkX_y*>nuD3)X6b%F!z+cOaf+20VEmF_k5P?>hZAqomeca12yMDT zt>1-A&QzfFBE;Le56U7%8gg{CO=ge#cQDY`W`b#C5ZjgzFBM*FfF*L%kDjlpGj?wr{*SG7PK8k;^0zIkR z)dM~bnnIQ{sc-3PO&p_+;?CgPI*6%_F!5UtlbXw5d zwPO$}{%gmv06f6#jD&)z`)tDng7&l&^hFCkJ)~?7b8Z#({j8Tz$C)O!UpBcL>NL94 zj3|p3#UElnF`V3fgUqOHO-18R2Vs4~XYz1etJL1)pdSA0>cR?60&z zc0}BydgPbos>%;Y{w>iD(?tldxP)A}C#!U_4do>!QE{Zu3`?AA0bH#vey&qek@eFR z^m`q>|1-MiazvR*MP7tqft6YKpO@z!9$!PP9Ydj&Ij)-#^cg zR7pYAK=s2D-g9B8#v-9RG*o?`qT{4^v!gp1>9ZO0toa=NlYgvATEBqab?lOMEbn6pLigM1+ zsq(uDGLC#_~h6Yv4n%Bt&29$0TvjC@|^=Jvd-age2 zQM(~C(u4P;jHw3h2M`yY^dlmTkFhrS7{5Z{ztiPi&A%_2H2Fsp_SARCym2+c9=3bL zA9|YTV89q3%tZlxNGo#}Vm+Y}GCCkuEp;j3Zys9rGp|LX>8t=E`m4kc*?;&U>IywU zrTAs=SA*^9d3vC2+e$>Em5~VV%r_fV;T-ZQK%N5)$x+MZH!U8zxu}<$5D%lo>*~Xb zwv&GjR9Z#zH{wrq?h__)jA-+l`EBK`Tl*;2jv>H=r4)x6-T}j-dVz+*ARPTaz(-WJ z5n$~^F6|=``jjQ?l}bIE$mj9$?8MSv9D~&Fg;fz85uzr8&8d(pH$Rst-vIjVi|Z8_ zkOh9g5Eq&my&HLUm(U!exqb7@_u2p?$1$=-zE4rCK|#SLzkiQ_IF7NerL3B(XBU4W zEg{x~*Fu40w?>AQi<%`TAU#RaCV=)>=ZE}U2+EwIb1oRf1Xt6`nR*B&f8F=)fC3(# zWbL<>mvB{vE}#64L>GI9>(@_vvYY}cN`97TzFhP0fIc_a#u#q=?W3S8(iQgikx089J@N-5wK`i z>Oq1sBbshSKobXy*P&6@9yjwE*T3EOZuK&Om+b}F8whI`KLTX?xBh&{fZy|HvKO08 z3Dq{qpvmg^Z-1;zkdfpp+mWqj0&74UC(sncE}0>_K@fc-2mSJLx6oq zpe_d);B7$(|-yf?OX-=l1)_`Mtca`gK$i_|$N8RWl`R&7pWzCD9 z{C7ru-l%jv=MC_^Rc@y)Be*8YBqW-2J&3#6%^^CVLXLgbxXa9Z5PQ+9tNMi=(k+$d zxLQ;mpUNg}I{y33Zg0jkCAcKE31hpydN-J8HlQ1SY%(V@@`bZlSO6?Uz) zMmYE3>@Tv;{w10k$5k($vD9*@AsuWEE)rbxXUcX!tIs(MV$niW{E>tSL~fs1J@|T= z7ZDDmPaGAUSqr}J<>u=l$qy(nXVMTW3BbF>CiJDZ;hVvF?=_0>>P(ai5kx>j^jD?f z6I8ZPKMalHwHFPZ6+8GP`kC#x0+;lm)2i=Jm^dO#oyw)}{{}iiN=vM4_6B0cTE?v6 zWdFXtt(0{adOy!cloSfNT~7OhYveK9&zrLXGBuX`12gV6CiOwQy^_zGz1gs@N7Q-e zL12rNI%p~N24Fy*M zO?pf0&&ZL)7zotrg>mFRPN{(|)7leHeVl)>|1R=>Z4k@EM3`n&@W zpUc)%V@j53ga5=bMyW*0Pjv#uwHYPsP+erJS?BF|rso0jS@oS)K8tzqj--$cek9%2 zleN5z{=mztvx`kh(OWb*%`#JkKcMf{oDOhkLQT_i!@Z1FFV{|q4pRlpW@D;h2Qnz_ zwiSiIvyilqcHVhwXNp|bt~l5|b3qx_{tzZR)pQ>+H3Kvn#bA%gX6x=Nn8 zGf}iL47jvrCTm@GHTL6W^gz&gm9uTVSF zpZzXH>G*5>R9uaNm|-=;)4@5wuLT_g)zU9f+VaJB_e;NFU5R+z#ha>?jlXYPBo9?D zzJx47cY9jRFnLlg&aWAeC^HCv{@o1t&h=tou6@w#SOT$1C+5wTucJje8yg9CnPWrLWHc2ab;V z?rt@r0!Qa?kJrEM?S4QBObB@P5t+lG6{m)?iAmffGshJ;Q!cw}PawR_SHE?LYlSTb z19lU;VNt{2xi^9Ahry#U2y!k8pPLrFSDpfBm)3BJRf$ zu5W{HTkV#S4~^>4uP%P;{yJXcq;GiNzj*x@e{Z}Rjmyb2Ox}W6ixlMeH9S`aqb`Vc z=lQ9gAq|^JrJnlqbZEn`Z0^K|A-BZKXJU(2ZI0l0-P1yDik}%~7)a@iZVnY4d=`6X z=#t_M)`sUHfplN~hW|vSVMUOVW%qNk|4P7_IDH9ok^&=>_vxh7`XgdtaYS_B8xrQ^ zp!Jo0o39g;T!+S>Pp?V9$p$sC&10GhN?;Qw?sAv%#R@Tv~;-wPM??B^}d4LI( zRudmIjA)c*?PMFg&n6MeR<^(ASZmr_51o6O6m~DdE1@u=0_l@Ne@L6X{hfQORt|Zj z$MZEBlr!9vb@HNGFg-0{{mxp B{OJGy literal 0 HcmV?d00001 diff --git a/docs/assets/core/SdCardService.png b/docs/assets/core/SdCardService.png new file mode 100644 index 0000000000000000000000000000000000000000..f5e8c1c4a6c9353881a5e70530649a15a488fb3f GIT binary patch literal 12733 zcmaL8V{m1`_bwbwY#S3}V*7*>8xz}3CgzDIwmF$NIkC-&jfrjBxaU`YRK0KAx?gtf z)wR2OqxS0cpd*wOrBM(G5g{NTP-MPKs6s$Mwt>&1;i16C(B9WY2ncit83};8XZBeR zoG#5`na_=bP>)m)5;mV%kI3?3O_s zsAAJO9}be4(0Jub{gna`$8CUxG&}`NrLy?Wb6wv4x`*Y)+rx_k`MGQcP@#=w8C@eCP zkTo~JHKkUcE>vK&PU|12bDx(5cZbY{mT_B&;iObK1^x!6jx=8N?XzP2XI$K#WEc)L zMAb6oS0`ox@IU|u=%JMq8!|!{0j5JLcp&+({T46n_acemVABpvaF2&8K{!>J|8yRJ zR%XV40SH3vgWCrv|2wGmkcf8>RcVkYPyL?4NlCpPZkoefvasK9$o{X8> z8%F?u74al~{3aD%*s2J)#SEiB9`x`XlLK9;j%dgd0MO%pTd(MrPy`ca7MB8!+gQfspfNJ;7K?bT>6%Q z;?Ej~GSzidx8cl-jn4zL9!nNCSD{zob^`T%cJ^OAf_HH=_H~LQ5K%ctv1u}*UHQF~ z6>^%n*~eGqdg)Rk*E&{i%DsWe2x7i}tRv|wrfB^{_1up7IcPbF6a>(+8LG=EgV9+U zZ+zmW?(Y}HTHC6wZ>v4D?Sl_s6W8PASc)!+ete!^c2%%$IjwZBe7dIrQ!qsOm5AH~ zJ;LeIOiha?30^tRn7ef6R6njKKiPG4hdUGT*)8P9&8&#MKYJ?;CE;iQw%tE&#fI$t zyY8TKR+>AywyJeM*Kbqjc8XnzJuF+tzo&0kAiQ0SYAWp|qK6HmFcp2Pu+kWf1omf| zyDVN9b&4Z{I>yLNDTDX=3)D0n1UHtVJ*-F(dzP_twMbr&j_s*1E&dM7%e8*-&aqgW zZ_vnkXwEv*$-Ym!(M8K!(Fw`EFjLL}gXf&f5bkG9`;pO_lh) zfYhN;N@JmnnAt%h-J*7$&C#r9NunD9G;TO#86A@(>zvwWG${A`^z&8 zq^I@OzoZQs3Ys$zPp+Tu4Cl4EiQNENp8nI{C^?z9>pOIf(RpD)XzY#mhZ2*CrvW|$ z3VO&kbk;h$NPoN8xbVwWtB%*T%+AAqkzCc|@AfSLYXgN}URkQp z=EFtU_EAJ)jB2@?7ePy(iW+LQ@&!+E(ju_C7=n)~Dp=LYIXYffQ(Id~ika1kw#LD* zY7-#Jal+b`(u07Nv7C)BK36jl)v&J;fY$OdvvrSv3VV!6L7kK-HMI}w0iM5u{LMMf z<$kXXOV+n@c_JCjmZ!%oTl;IZQ)X0x4g2$lB{z@mcA8>xWO;A9={*>%2bl-6_d~*^ zR&-}ALF!c$$vM385(TvEhGH@}vdlltw1HAMX(!dGRo~nuZCOvmp&~T@I9PRfL=h2) zZb@%gm7VX|YoGn~HoY%!-ZZTJR4Pov{= z`oavqpzm78yd@`tWB3?sA`b&Y{oEBfJ30|3CMVJ;GPMk%TzKY#hX(tE?d>PN@pq?| zGwu?cftLlZ?(jKbmsRrXqE*;`x1Y{?EA63 zU(SNtwz>>Jzd!k0ouk#eu-+oNPwJiAnbfm7CbkO|6LG~ksk3~`A=_@>I)q?)8?+}OTq=Ooe^#&B9;%t_;eSL91i zrMjx1pfD@_iyJI)x(`K=KkbzMa=;zau|21OP5mHj-JF4%#vH^He@y0Gv;%LTuA*O) z(;8Af*H}q60t+KiWK-S``oGN5(nwJ${Xgzhj}hWQc%m{gjSx za=zwp9aRh(K`sdrv3p9WZ8SDmaZO4*z>2%RMuaJ|Z_N5UJjMI5(dhXmcYMt;i)C@f z-`?g&)bv}nz$$`_lq?m;h`#^c<_4!*5&eFiyl(pec5S8CF9) zcq<5i#bGa-wX@~WeaO{abx7vR)@*&n-iT8F$GwM_3isuyj;TbGzQm$fGz2x%uuRoW>ROgMauG(SDw&e4ow@0&50W)B4U>1q3sOJqU+8_|WUYPuB!t6CY*GUGU zy1Ms52H3Nj#rxM?O%>R~XLwxe4=YFMApANw`G_KlSRERvZksMEtN#KAxTm3-47wR8 z^l~!c%V@56U$^qm=6Kc7mH454I2>q<@o}w8SJsZGL*EeK4}FZv2I$`=2zzIVd)v2+ z+9V>)rQo#1UvwtQH78gk{mtXGI{EaRLQP#F9rvV!`NXu(j;B{^zskO`roL07VVP&e z`?CSad!~a!Z9F|rY&K6+Q2-qtwXb8^ihoGa$6_XtQyY25Jyh>%rYgmdz^kvw%Ke8D zDB}pxD_aBDu+!`pr|CGWpzhF))otJJdGPD3PU-pdIk$$_JQGM>YcWji@^w7D)%c~(ZWT`7ARq=l7Nj#v zQENIKs))~x=5Mg&_vtqCsi5eO|K{+t(_3`DgtxklNb8iky*$#C!b=J)mzx>Vk_1MVpysZ~1j;pjx`GYy4NE$~k)GOCkJro70S!ltOvGkt3 zO~KBS`!AQjScoPXLmRbz=?0T8qbw48wgWa226EJc$p5qu4As&+hWw_b?VedAn_$ZO zxczcGM$yjB?gEuw)M~yAE=~Fjo4ZCfJ`k0UVq22VOps~;`o7a}YfXoLunZ}IB;H9m z+Ip18IP5ArV}&*9JKPLogfP}oS4!v$zR?1Ow=Ug(RNb_%h zU1}Zs_Z{tu)BVR$Y8ZLmReM`L+Z*Gdn5^P_Xq6}lkaf&n;3Wj?m=tmw`3+glEP zG#h5VZ^~?2ix|E@zIhiwC91oyI5jBgt#f@fA;ut1-TPuaH3S z^_9Y`p_)b%bhJ|rJDe__EL!~lEsj=*ZjyiQn8U~q)q2xn1YiDvsmg!d`$u$hN^jNh zpPsd=HsHwEj%&i)q)?-Lx;qVC-kHiKVT1 zf*xd^xZ`Ar_zNVj;UwwpeByW-xPahy4o{3VSYC9llB}kb$e!HqgDwn5X0SQU`ndXF z$m*=NDABcdZ&cl=4f`EB7dWye>m63INd5iqt8RdUdxg07n;W|HFPtjAn}3Nq0L`wY z*VppDstA-3D6&;Nnmm~>rpgToqOD!RZ_H<XNcWpc6;m9sm` zdNtkG)|AZ}8?D4!Ya%d=e_&a+W|IfUlPoiC>O9XoF1~Fw+Xg&E;V9jeX}S;C9f!WA z-*bR8hxHy)@V+dw@`h5;HnoN+Ps(H2wdYQ$mn5csOXTRgS2x?h9o=2m2cOnz%Svn;PXS=>`6s99GI3Tduor82p;8 zZ;c>nW>6_SPJ?1h#ojoNYs9Z>FoUE;PTm=-Lo^Et-rvYPX*Ym9Tn!OmqHG6)w z3V84CBd{HPO1?Ht>OtA8{WSA%`SPjsKU_f8NyCrkYAWlW=>x5)%}~zc0(xkuKy7Wm z=SA;n`tUKzO{{@ZS{<3Sdg=DcaFOycZ}jPQn|*zr;G|EU_BxhB4eR^N^mOvYV!cQ))uVcYOlfA)ru%6rcd8XV= zx!vkep?_Dxio+~^@vWcC-!kKw+o=AKcZnIb80w)0uElMkti9mmnmh6!<%AnS5Ao~D zbL+CKQ4$^_ZN2_JGN@tA$B6y;5b)oaoK~6nlEn#i<~@4MI_0|$1$B93yX|7?~XmEoE&mkgu+(@B@NhTYY zH^TZ9gKWZUSyB5|*hi~Vb=9=$?Z#-kCk%r&2^n@6(Mx-iFmG89{Axy`QutZRg)58A zAL@}ju_&+7V)Hf)$*~I^ot9=S^Ws{XB95E}8eCK_RQAR)gAa1yi)W2~OPDXAT$e+q z6h~H;_hh}GK1=uwIl2!a7V|4JnV2t^Tpasq)ZB%{cLs(4GhYuC-=)>v<;@L}{2)NI>edEgLkpFoxy4z#u7aHXND_bwduAkxw_&s|h&MZ;mDtms`VH z;EPqqyPf!%lXe?HsL0qk+<2S)rviuSkMwj<0`}=wb1yw}$jvf^lAPpy*Cy1*4YZg) z>Vym|uf-X`@m1gD{}7cwt*~zh1R_Qj-b^_w`WzbfxX$c1kywXG(9BS{k zeK$nMz{t%OT(hV*?DTHzc=Ol8hH%;Eq%5L?wX8vd6ikt8m2Y!e4CzZ8wfK{9$IuJe z6*r#y)H6aoH~>fORkwe{1e@!nh)&KPnw~k_G?jgRo8OOh6wTKM+qm!RCuvEY^XOOh zH!CoDwt?Jo|M@LpqKe4=M(llwAbJkq?tje5gVbIqpWt~cqxQ{E%l}W~Q|(>ph`a*A za1o*zTfiBfA5I;E-p- zb7FI|x^V&V@aA}~Bv;taqCae6-A1Bfq3i7iP5AX}Y-L4f*0RPOOH$4gci&|?7%Ku6 zHZv9GL`-n)X@&1%+yHmaWlsXOvI7Gv9VtB26fL^zZW(zg4DlzPPml^iT}o#hj>6#h zv%KfmZ(0~Wr!m}s8JOOjiE)3$mx=I6_gCxz=P41#%FV3wQ8ydGuMrn?Y4mCib`XCu zpOdj0JjbLJqg0fi`NoQB0KHz5%Ra8nWPWZ4R{JG+{?6x_P6au9u2Pzddr^3Jhz`$V}`rBcyK zIjs7;-Gz40l4X!$VL<)q_H2w7^c3xzm-~IX$V@EPBNauY02k6+ggR@9P`?uAf126R z5_LI4E4ox!S9AX8?1IOLAI(FQZ&Z}3w_R!Bzha7M^?bvocs9(Eo;doe)L{b_G21V; z-J0cUAK)wJx@sf7d-QH~FOOOE3hQKu?!Yy5)QoYe}d2}qN#-S!fB_+Q9ma)69dK`J*u9Q8Ilgx(MfVoDE z&>sTx3|e=&8f~0iR+}@W6mL2cZu@yzn_SpFbQwmQ*%az6Txtb?5#qMDAGfPf|T~rVq_4PnBAv>G`1Iky&3?ZOUv7F;P0u5v@-`;5X!Y6Zsn*hm@0@p61oWAiwt&OrTxGN)rV8_Bp{bG&&t&l~ph12`4^qX!I;CUb-2_5pPB_Nml z{a0S~R$FUT`-Vl-f*dG$q!2Yc=`QeM#C-6lS5=k0JOSzMC9PhAvc_uaePx+#bR0sk zn28PqFs-#Gu_p4KxO+dpwM(@8O0!pX97L6HPR zPf^lKZHWHb{3xnlY~U4ZJ%f45u+h7k3#fjBb_KfSsGIBs*Cq%hJ^p zXrxRq4V6-8Jj^XUpn$2??P|s8E{5i%pkDYXj@)D>KYjjNneA*I^D?uywJc0_1XF8x zhe-1!+e@46a45it?rIzvrRUtjAWs*)8YNqEJVu7R9wJH=|LKW|I&~{?2or#f;(c9M zQs$^anU2r~!fKpskxlzM ziNv#3mEQZJZa1diiDj-=xu3Mky^T#dg<0WD!#mR;yaieuGlo?0!4<6JJCd(6Vr#N{ zK)q&``0?by^10AEZX0cyYf0R#Dx+BNThb0?HI5cxGWps0;I%awge0TDg{7-95tX-Qa6Y z66$=Ufy1AyoS}pnggI5(6=%nTFGqw_xYl>-?A?ndkyQHem4G7#vb}LmPtK{CLXbfX z@wte3$mt?}t6ba{V!p#vC`35R=BW_bdyne(>6Vqv;+$$tXho#=9L0L8tCF1^rv%#j zn{UwmSYAX_)Hf!ku;$FHEDDUh$+wW=l9EUpUdN4W!?&G@o}R$j*}8)R+*qth2=ewT zL3g`I*&-%p<}U~aU2lBBz36y&Jm#Z?iU)^6(2LYV?eMd4^ISk`X-P?#ATEomTU5>` zU<9nUq_@;#_I2CKL0;z@47Mzz%_HeZSf7Zd{p$yoYrw}!l9A+r0VAKw%~y3&erM7U zc2jv(eQ~+8jMtb|)Xux{{_5@;n9(nTDJt8f&xPU$xt6XBQVu=LY8>U0& zTD|XeoQq$=P_hg?x1ZAtKOo;|7#S;|ppcclo~}G1A`q%O9}tr%$~sL389aL3Ki&q} zO#g<2gmgY`yps9d#U1AQyu|Prgpx2aFm%0~1x-(2|et@!!_A(!cOuoJc&mA*> zN)ZsR?r&%MdCcn?G!5fE?qL|{Z*ih(iPFSHl_4;za;nZ%hRx3tpfFe&e%CG7w2Q0N zK)z`(JqAvGPoj4lqkZS)_oO*`uf^i zT?-~LNrTdLm_Ruu0Q4v`{D_4WFMkLa%9c|X)C@PL{T2gN51n$&=fUOkZ!iRVrHvHp zxy2hr0NZwIItYl_E zufe#(!M0+5YqIB#&nE34%vi~jY_DM*9+8+%c@1k=O3so2WVmFkOf*L6n)@J)=V5aO z;99kEbaH|h8#|Yv+#Vs`X`mqQfqNLkdag&r9T)bXazjY5Rb~PkCf_#$tk|_Qr%J3u zKH}3Y&!@va-C{K33-b-?*?&D0Ls9?4S}S4Z%Hdr$1ItMN-!QUg{3$R2pWXfBmn7s1 zYmuJ9g=wX`oPz6eP*j!+<0M#G5A3b*h7BvMxjQWGE_=L5^6uza7KNl#3)IK?A1)xo zxmZG}(vGRDpT2E4I8we!($d!`J+UJmnHzRD9itEr0FGIY#DGI3(oA38*Li6>ITz;~^vIw4NxqtP%S^QobM;qk*?KAisBYt0XEG-uv~k zqF)?2>QHgUTHPY*=UA?(7SgCuR$kYgwwH#2ubZ~9%ufU3<(66*)Nl^o>FTap^MAc+ zHJ$RK*lGWKTit1=ZrLz;(><%Kk#|JM8D$3$i>u z?aN^{m74VyIk}?IS`kmo{~nou8I?f1a%5AsJ7>WTiNX1Fj4W;xjDSSdh~q1YFUe6@ zm0itz5Ef@i7pabOT_eQFWrK*7_jOGq=}^Y?TeoeI?TtxFn-oL^eF%_(y6FY89K)GdB+*l%?_|IrvagvH?vrB9EK`A|5C5> zgM%>U!LV_VYeOWNFn44x+epr(bqn8^!_!uqP+xzPO#2YX-3eX#G;`MqT{$Kr>ZNqq z`?~UVSpQ0$88DyCY#BA9r@x9$fct%plTIHXvtO>@qtDDNT>LHHYisHGEXu2f4MQ{Z zr~SGsG4fR1XxmNsOMTjnQeHP=Q(>B8P>_h|`M}%YP*e|FH@&AX_Nc z(A5{NrZW8yl(tvbKQw1TeTpe~>Mp9g{Udp@4e>V(o<)JpYXisEGuE;b^--u8r$FE_wSR>!Av!2$_|TeKeL7y>_76A2-fAp1e0q~dScwf>>{SpR2}z! z>*r`k%fD5rW z+{rKPn5sN`1_?4>ZvSqd`R<9YlnAl%t3SWH+R8ktcq2d#Et0M|DuF1;GjnJ<4Sy(_yc2$OzsKon)`EQrKiX6(-T)?zn+X3T8fX7L z)VncHue@VQi>H3&8xBfnR&0Pv(HkdtUYrcQC?0-dR&X}oQf+`||0QY(woLu6Vd?+l zUi!ZTFB;54R73Q8H@c_&KorQ2jpkLkuf~64WD;4MJ1w2ewDY^;V#(oWd7hq+tX`UQ z64(Vb%i@!AfB*9yG0$;qD~}&*Q;%kbjJTSybp&bA;~3abAEUhd%zWuv#yTZzPSsd+ zuT-zV9wW{V-^_H5wf+%xvGE~!6m04T4*-^Xg{7JZz8WpGMz?PxA>hU~tBaQ6;60?ey*X|qR z`M1Y#fy33s23)7=o36&-(?24z*VoUQ{sDg0+UVBLAjcWLKi@uNRRz3He9T7L1m{g9 zwkDDJ@t&?LEgFt~(_wWLTPY1mI&?t*lCRU!e0?e#N~w@wN@Px&t;O4vew!={=WdR-(ubNmgc zKNZ+Xb;QS{3yU+D+qj9AF{SK-68<<$Ezgv)I}>HE~!-}_

)Yti+=5iv8rK|V-tJ+$?l4Tiq>n z&?bsy;D0U5D89c!sTaVjeV7Of2iK3Uzm>8(XU*#o*o>u>3@@~y4ac8q`?s-G3^b(I zXghff^j;n|HO0FJ-s!Wxan~2!$ogBmm=w0i8Osd*GG?PogLiR!dO3RVOGDwmC#x2j zGss}$uPtaJZNPk6Q#*8JImC$?GLqgY5N9Yq<<|g4fPZ!E&*@}N#~dvmYTm)d`$3X( z0dh8O;#`1|RU{N6W~KTPo{bc06yHzg<_&vE5+Of&({WZ6W-h`7JX;T-c&pXoz=M<& z?GOo{Ge(CJ{^7~Q>%cHX%L4$mSCu;hge}?m8u#-YQfS($#<%%WfK7lg#tlwsAM?fF z0bwr-1_U9B^yeU}xvwjj-)mn^+g;%Y>$zT5?J zo!&wb^SYZa>XfdFSdFVHEyMGGskbgKI6wNZC?U_JOfunbL}q`<6Y)X?!YX__n9_6% zmJQD1(RMVF1g48YiurCGM6FKcWmt3SZoy1zxe(xx!Zht2+Pe4UscY+GmV&Yi7eG~S3p#sEr4H>ca9DB`e^f{atEzki5 zd)9(H@jQo&sNT2zoNgSL_@FHa>5f5!E7bY;su?heSV6Fxd_RuwGw9)Bb8oK~0sh4e z1rNI^x?zKo@}xY7XUi2|%*lN%qBPcK>@bjLS3$_IJJ2@qiP%V~#8&&W&uX~=zLTH+ z9g~|mxlo7qV~qj}Hv5$Iv|#3E1vn=1mF+~WxzHU?X`g*~3(393idLL=%hMedn>_4g zd^SAa$2sd{rRknrS01<4=xSksn=@*E^z<+?_Sb2O05K2l`N#+(1ATSq+GI+{&RYTV zqMmN8gNMow9A{~=0|hb;aU3=7-wP%y_*yasST)oRE zEe9(HJ@wMDeH*)nBh>i3fX+X~RWsXeLA+8S_+f)mKs_K0@EDE|aSHqm9Z_{seL&cS zu!-MyV);RM9X{Flb0t?*+{_Zm%A1)#VZ+l&drp{>$HZgFa zvSRv`cFN!jR|xXTE>g++aWI#Mw$S-n(Oc^rJGdwVFmI2~t(TtnK;>fKr6W|MAo+GZ%KkFU7z}PnmTr^xEbeuZzua1d%Agdj6&-2f>OGF{MC@Wj#nL=B? z=R+9&GQ~s#SG-Z9485e_bpQ77PCFDM12y)hMCOZXhQ4)IrrW7-bm7;(y-S5amv8|Zn+4}uVrUdv7Ii(g{2W{fX5by zK1s)nXF>A(da^2#27r`E;J#!8PNk547nwOP+M|Gby-Un+dIPq?c@ZgRbQIjg5=n%7 z$#G>|(KqIE@D#%?wAZpY(6`ov}%b)1zf*yRB)e9urPqp#T`TZ6r@j0 zaaY-#IeBdY1$jXC%Pn4^Kp>?RyRN`N)UQ|cFl;Y<)2OjQ_sZ}$UfxEJ)HL@7!jzB% zw{fE8`=dYgE9a!#E^zZJ@eU=@{MOVI9-pS1oTwll?c znO{0OIt*Tu)G4RC59e?23G9E~EHngYHIi-5o)0wE)*C{ZP5 G1pGfEI?r$b literal 0 HcmV?d00001 diff --git a/docs/moonmodules/core/services.md b/docs/moonmodules/core/services.md index 51b72dc8b..112766537 100644 --- a/docs/moonmodules/core/services.md +++ b/docs/moonmodules/core/services.md @@ -162,6 +162,23 @@ A script runs on the 50 Hz poll rather than the render tick, so a heavy one cost Detail: [technical](moxygen/MoonLiveService.md) + + +### SD Card + +A board's microSD slot, where one is wired: its wiring, a usage gauge and a format button. Mounted, its files appear under `/sd`, within the one [File Manager](system.md#file-manager) tree. Behavior: โŒ„ details. + +SD Card module controls +The card's files in the File Manager, under sd + +- `board`: the slot's wiring, from the presets this chip can wire; `Custom` sets bus and pins. +- `bus`: under `Custom`, the SD bus or the SD spec's SPI mode. +- `clkPin` โ€ฆ `pwrPin`: the pins under `Custom`; โˆ’1 unused, `d1`-`d3` unset is 1-bit. +- `format`: a button, press twice; erases and reformats the card as FAT32 in place. +- `card (KiB)`: read-only usage bar, used / total KiB, so a card past 4 GiB still reports correctly. + +Detail: [technical](moxygen/SdCardService.md) + ## Analog, details The travel maps into the control's own range: a Select with five options takes 0..4, a bool takes on and off. A row pointed at a pad does nothing, since there is no sensible reading of a pedal held at 40% of a preset. Live `raw` and `value` readouts let a pedal be calibrated by watching it move, and reversed bounds mean inverted rather than being an error, since calibrating by moving to each end sets whichever was reached first. Without a `deadband` a resting pedal rewrites its target fifty times a second forever. Scripts reach the same hardware with `adcRead(pin)` and `adcMax()`, which is the path for a sensor whose mapping is a condition rather than a range. @@ -324,3 +341,11 @@ One key binds to one row. Learning a key that another row already holds moves th The status line reports setup state ("set pin to receive" / "ready"), the learn prompt, a binding ("learned 0x..."), what a press did or why it did not, and an unbound code ("received 0x... (unassigned)"). + +## SD Card, details + +The presets: `P4 SDMMC` is the slot every P4 board with one wires, and a P4 opens on it. `Classic SDMMC (1-bit)` is the Olimex ESP32-Gateway's, and `Classic SPI` the QuinLED Dig-Octa's. SPI mode maps `clkPin` to SCK, `cmdPin` to MOSI, `d0Pin` to MISO and `d3Pin` to CS. Over SPI, reads hold but a write over about 50 KB can stop the card until a power cycle, so a card mounted that way says so on its status. + +Mounts live, in `prepare()` rather than `setup()`: editing a pin unmounts and remounts on the new wiring, no reboot needed, the same contract the [I2C bus](system.md#i2c-bus)'s `sda`/`scl` have. A pulled card is noticed within a second. A missing or unreadable card shows its own status instead of retrying, since a mount attempt on an empty slot stalls the render thread: insert the card, then switch the module off and on, edit a pin, or reboot. + +The card is FAT32 only (ESP-IDF's FatFs ships with exFAT compiled out), with the SD Association's 32 KB clusters. A card without a FAT filesystem, fresh or exFAT, says so, and `format` makes one in place rather than requiring a second device. A format takes seconds on a large card, so it runs in the background: the status reads "formatting" and the card refuses transfers until it finishes. Once mounted, its content is reached through the [File Manager](system.md#file-manager) exactly like the internal flash, under an `sd` folder; this module never duplicates that tree. diff --git a/docs/moonmodules/core/system.md b/docs/moonmodules/core/system.md index 42344757a..61379ad18 100644 --- a/docs/moonmodules/core/system.md +++ b/docs/moonmodules/core/system.md @@ -245,20 +245,6 @@ Known renames from [MIGRATING.md](../../reference/MIGRATING.md) apply before upl Detail: [technical](moxygen/FileManagerModule.md) - - -### SD Card - -A board's microSD slot, where one is wired: pins, a usage gauge and a format button. Mounted, its files appear under `/sd`, within the one File Manager tree above. Behavior: โŒ„ details. - -SD Card module controls - -- `clkPin` / `cmdPin` / `d0Pin`-`d3Pin` / `pwrPin`: SDMMC pins, โˆ’1 unused; `d1`-`d3` unset is 1-bit. -- `format`: a button, press twice; erases and reformats the card as FAT32 in place. -- `card (KiB)`: read-only usage bar, used / total KiB, so a card past 4 GiB still reports correctly. - -Detail: [technical](moxygen/SdCardModule.md) - ### I2C bus @@ -497,9 +483,3 @@ The panel is a lazy folder **tree** (each folder loads its children on first exp - Create / delete are HTTP calls (`POST` / `DELETE /api/dir?path=`), not controls, the path rides the request, so nothing is stored on the device per op. Last-modified dates (needs an NTP time source + LittleFS mtime), binary/large + folder upload, folder-as-zip download, and `.ml` syntax highlighting are backlogged ([backlog-core ยง File Manager follow-ups](../../work/future/backlog-core.md#file-manager-follow-ups)). - -## SD Card, details - -Mounts live, in `prepare()` rather than `setup()`: editing a pin unmounts and remounts on the new wiring, no reboot needed, the same contract I2C bus's `sda`/`scl` have below. A missing or unreadable card shows its own status instead of retrying every tick; reinsert it and touch a pin field (or reboot) to try again. - -The card is FAT32 only (ESP-IDF's FatFs ships with exFAT compiled out); `format` reformats one to FAT32 in place rather than requiring a second device. Once mounted, its content is reached through the File Manager above exactly like the internal flash, under an `sd` folder; this module never duplicates that tree. diff --git a/docs/reference/hardware/esp32-p4-eth.md b/docs/reference/hardware/esp32-p4-eth.md index 1882d278f..855645cab 100644 --- a/docs/reference/hardware/esp32-p4-eth.md +++ b/docs/reference/hardware/esp32-p4-eth.md @@ -42,7 +42,7 @@ The board uses the `P4 RMII` preset, shared with the Waveshare P4-NANO: MDC 31, ## microSD slot -Wired on the board and mounted by [SdCardModule](../../moonmodules/core/system.md#sd-card): +Wired on the board and mounted by [SdCardService](../../moonmodules/core/services.md#sd-card): | Signal | GPIO | |---|---| diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index 130a05fb4..7aab1ee68 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,19 +4,19 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows. -**1630 finding(s)** across 322 file(s). 2.85% of all lines are duplicated, 3738 lines. +**1632 finding(s)** across 322 file(s). 2.83% of all lines are duplicated, 3732 lines. ## By rule | Rule | Findings | |---|---:| -| duplicated block | 770 | -| complex function | 289 | -| blocking call on the render path | 195 | +| duplicated block | 768 | +| complex function | 293 | +| blocking call on the render path | 192 | | deeply nested | 113 | | module type in the UI | 71 | | long function | 49 | -| large stack frame | 34 | +| large stack frame | 37 | | long parameter list | 34 | | raw allocation in light | 28 | | large file | 25 | @@ -44,13 +44,13 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta | Area | Findings | |---|---:| -| `test` | 576 | +| `test` | 574 | | `light` | 421 | -| `core` | 353 | -| `platform` | 174 | +| `core` | 359 | +| `platform` | 171 | | `ui` | 74 | | `moondeck` | 24 | -| `src` | 6 | +| `src` | 7 | | `mooninstaller` | 2 | ## By file @@ -59,23 +59,23 @@ The next function worth simplifying is at the top of each file's list. | File | Findings | Worst | |---|---:|---| -| `src/core/system/HttpServerModule.cpp` | 45 | `HttpServerModule::handleConnection`, complex function 123 | -| `src/platform/desktop/platform_desktop.cpp` | 44 | `platform::getMacAddress, line 1373`, large stack frame 5240 | +| `src/core/system/HttpServerModule.cpp` | 48 | `HttpServerModule::handleConnection`, complex function 123 | | `src/ui/migrate.js` | 43 | `line 29: 'I2cBusModule'`, module type in the UI 1 | +| `src/platform/desktop/platform_desktop.cpp` | 41 | `platform::spawnEncoderProcess`, complex function 58 | | `src/core/system/MqttModule.cpp` | 31 | `MqttModule::routePublish`, complex function 29 | | `src/ui/app.js` | 29 | `app.js`, large file 9076 | | `src/core/moonlive/MoonLiveCompiler.cpp` | 27 | `moonlive::Parser::parseCall`, complex function 59 | | `src/light/drivers/ParallelLedDriver.h` | 27 | `ParallelLedDriver::reinit`, complex function 36 | | `src/light/drivers/PreviewDriver.h` | 27 | `PreviewDriver::buildCoordTable`, complex function 37 | | `test/unit/core/unit_FilesystemModule_persistence.cpp` | 24 | `lines 181-194, also in test/unit/core/unit_FilesystemModule_persistence.cpp:146`, duplicated block 14 | -| `src/light/drivers/RtspDriver.h` | 23 | `RtspDriver::serveControl, line 185`, large stack frame 1608 | +| `src/light/drivers/RtspDriver.h` | 23 | `RtspDriver::serveControl, line 185`, large stack frame 1680 | | `test/unit/light/unit_I80Peripheral.cpp` | 23 | `lines 151-170, also in test/unit/light/unit_ParlioPeripheral.cpp:214`, duplicated block 20 | | `test/unit/light/unit_PanelCardDriver.cpp` | 22 | `lines 22-39, also in test/unit/light/unit_NdiDriver.cpp:29`, duplicated block 18 | | `test/unit/light/unit_RainbowEffect.cpp` | 22 | `lines 69-85, also in test/unit/light/unit_ParticlesEffect.cpp:32`, duplicated block 17 | | `src/light/powerfunctions/draw.h` | 21 | `draw::scroll`, complex function 28 | | `src/core/system/NetworkModule.h` | 20 | `NetworkModule in 25 files`, name spread 25 | | `test/unit/light/unit_ParallelLedDriver_ring.cpp` | 20 | `lines 486-503, also in test/unit/light/unit_ParallelLedDriver_ring.cpp:432`, duplicated block 18 | -| `src/light/drivers/HueDriver.h` | 19 | `HueDriver::pollPairing, line 297`, large stack frame 1112 | +| `src/light/drivers/HueDriver.h` | 19 | `HueDriver::pollPairing, line 297`, large stack frame 1184 | | `src/platform/esp32/platform_esp32.cpp` | 19 | `platform::httpRequest`, complex function 30 | | `test/unit/core/unit_HttpServerModule_apply.cpp` | 19 | `lines 536-547, also in test/unit/core/unit_HttpServerModule_apply.cpp:122`, duplicated block 12 | | `src/platform/esp32/platform_esp32_moon_i80.cpp` | 18 | `platform::createRingState`, complex function 32 | @@ -129,9 +129,8 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_PlasmaEffect.cpp` | 7 | `lines 24-39, also in test/unit/light/unit_MetaballsEffect.cpp:22`, duplicated block 16 | | `src/core/moonlive/moonlive_lower.h` | 6 | `moonlive::lowerWith`, complex function 82 | | `src/core/system/FirmwareUpdateModule.h` | 6 | `FirmwareUpdateModule in 13 files`, name spread 13 | -| `src/core/util/JsonSink.h` | 6 | `JsonSink::writeJsonString`, deeply nested 8 | | `src/light/drivers/FixtureProfilesModule.h` | 6 | `lines 245-252, also in src/core/system/WiFiModule.h:352`, duplicated block 8 | -| `src/light/drivers/NetworkSendDriver.h` | 6 | `NetworkSendDriver::tick, line 190`, large stack frame 1640 | +| `src/light/drivers/NetworkSendDriver.h` | 6 | `NetworkSendDriver::tick, line 190`, large stack frame 1760 | | `src/light/layers/BlendMap.h` | 6 | `blendMap`, complex function 35 | | `src/platform/desktop/moonlive_asm_x86_64.cpp` | 6 | `lines 557-564, also in src/platform/desktop/moonlive_asm_x86_64.cpp:515`, duplicated block 8 | | `src/platform/esp32/platform_esp32_improv.cpp` | 6 | `platform::improvTask`, complex function 22 | @@ -147,6 +146,7 @@ The next function worth simplifying is at the top of each file's list. | `src/core/services/AnalogService.h` | 5 | `lines 105-123, also in src/core/services/InfraredService.h:117`, duplicated block 19 | | `src/core/services/AudioBands.h` | 5 | `magnitudesToBands`, complex function 19 | | `src/core/util/InputMapping.h` | 5 | `setInputActionField`, complex function 22 | +| `src/core/util/JsonSink.h` | 5 | `JsonSink::writeJsonString`, deeply nested 8 | | `src/core/util/noise.h` | 5 | `lines 304-310, also in src/core/util/noise.h:288`, duplicated block 7 | | `src/light/drivers/PinList.h` | 5 | `assignCounts`, complex function 30 | | `src/light/effects/FluidEffect.h` | 5 | `FluidEffect::data`, deeply nested 5 | @@ -155,9 +155,7 @@ The next function worth simplifying is at the top of each file's list. | `src/light/layouts/CarLightsLayout.h` | 5 | `lines 49-65, also in src/light/layouts/TorontoBarGourdsLayout.h:52`, duplicated block 17 | | `src/light/layouts/RingLayout.h` | 5 | `lines 85-100, also in src/light/layouts/CarLightsLayout.h:95`, duplicated block 16 | | `src/light/layouts/TorontoBarGourdsLayout.h` | 5 | `lines 52-68, also in src/light/layouts/CarLightsLayout.h:49`, duplicated block 17 | -| `src/platform/desktop/main_desktop.cpp` | 5 | `main`, deeply nested 5 | | `src/platform/esp32/platform_esp32_h264.cpp` | 5 | `platform::hlsSegment`, complex function 26 | -| `test/unit/core/unit_HttpServerModule_sdcard.cpp` | 5 | `lines 23-32, also in test/unit/core/unit_HttpServerModule_connections.cpp:18`, duplicated block 10 | | `test/unit/light/unit_Drivers_rendersplit.cpp` | 5 | `(static local variable) in tick`, blocking call on the render path 1 | | `test/unit/light/unit_FireEffect.cpp` | 5 | `lines 29-42, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 14 | | `test/unit/light/unit_FreqSawsEffect.cpp` | 5 | `lines 19-29, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 11 | @@ -168,6 +166,7 @@ The next function worth simplifying is at the top of each file's list. | `src/core/moonlive/MoonLive.cpp` | 4 | `moonlive::MoonLive::ensureArena`, complex function 21 | | `src/core/moonlive/MoonLiveScriptFile.h` | 4 | `moonlive::compileScriptFile`, complex function 20 | | `src/core/services/ButtonService.h` | 4 | `lines 96-118, also in src/core/services/InfraredService.h:113`, duplicated block 23 | +| `src/core/services/SdCardService.cpp` | 4 | `SdCardService::prepare`, complex function 11 | | `src/core/system/EthernetModule.h` | 4 | `EthernetModule::defineControls`, complex function 24 | | `src/core/system/FilesystemModule.cpp` | 4 | `FilesystemModule::removeTree`, complex function 13 | | `src/core/system/ImprovProvisioningModule.h` | 4 | `NetworkModule::setTxPowerSetting in tick1s`, blocking call on the render path 1 | @@ -182,6 +181,8 @@ The next function worth simplifying is at the top of each file's list. | `src/light/layers/MappingLUT.h` | 4 | `MappingLUT::allocateDestinations`, complex function 12 | | `src/light/powerfunctions/particles.h` | 4 | `particles::Pool::collide`, complex function 16 | | `src/light/util/RtspSession.h` | 4 | `rtsp::parseTransport`, complex function 28 | +| `src/main.cpp` | 4 | `mm_main`, complex function 27 | +| `src/platform/desktop/main_desktop.cpp` | 4 | `main`, deeply nested 5 | | `src/platform/esp32/moonlive_asm_riscv.cpp` | 4 | `lines 297-303, also in src/platform/esp32/moonlive_asm_xtensa.cpp:481`, duplicated block 7 | | `test/js/ui-mooncloud.test.mjs` | 4 | `lines 6-17, also in test/js/installer-s31-webflash.test.mjs:11`, duplicated block 12 | | `test/unit/light/unit_BlurzEffect.cpp` | 4 | `lines 137-155, also in test/unit/light/unit_GEQEffect.cpp:159`, duplicated block 19 | @@ -207,9 +208,9 @@ The next function worth simplifying is at the top of each file's list. | `src/light/effects/PongEffect.h` | 3 | `PongEffect::render`, complex function 15 | | `src/light/effects/SolidEffect.h` | 3 | `SolidEffect::static_cast`, complex function 36 | | `src/light/layouts/GridLayout.h` | 3 | `GridLayout in 86 files`, name spread 86 | -| `src/main.cpp` | 3 | `mm_main`, complex function 27 | | `src/module_types.cpp` | 3 | `registerModuleTypes`, long function 150 | | `src/platform/desktop/moonlive_asm_arm64.cpp` | 3 | `lines 35-40, also in src/platform/esp32/moonlive_asm_riscv.cpp:34`, duplicated block 6 | +| `test/unit/core/unit_HttpServerModule_sdcard.cpp` | 3 | `lines 24-33, also in test/unit/core/unit_HttpServerModule_connections.cpp:18`, duplicated block 10 | | `test/unit/core/unit_NetworkModule_ethernet.cpp` | 3 | `lines 276-285, also in test/unit/core/unit_NetworkModule_ethernet.cpp:193`, duplicated block 10 | | `test/unit/light/unit_AuroraEffect.cpp` | 3 | `lines 40-48, also in test/unit/light/unit_PolarLut_equivalence.cpp:40`, duplicated block 9 | | `test/unit/light/unit_MoonLiveLayout.cpp` | 3 | `lines 685-935, also in test/unit/light/unit_MoonLiveLayout.cpp:403`, duplicated block 251 | @@ -230,8 +231,9 @@ The next function worth simplifying is at the top of each file's list. | `src/core/services/MidiService.h` | 2 | `MidiService::serveShare in tick20ms`, blocking call on the render path 1 | | `src/core/system/DevicePlugin.h` | 2 | `lines 94-100, also in src/core/system/DevicePlugin.h:75`, duplicated block 7 | | `src/core/system/HttpServerModule.h` | 2 | `HttpServerModule in 17 files`, name spread 17 | +| `src/core/system/I2cBusModule.h` | 2 | `I2cBusModule::scan`, complex function 18 | | `src/core/system/MqttPacket.h` | 2 | `buildMqttConnect`, complex function 31 | -| `src/core/system/Sha1.h` | 2 | `sha1, line 21`, large stack frame 920 | +| `src/core/system/Sha1.h` | 2 | `sha1, line 21`, large stack frame 944 | | `src/core/util/ModuleFactory.h` | 2 | `ModuleFactory::create`, deeply nested 4 | | `src/core/util/PinList.h` | 2 | `parsePinList`, complex function 15 | | `src/light/drivers/DriverBase.h` | 2 | `line 167: if (!wire_) wire_ = static_cast(platform::alloc(by`, raw allocation in light 1 | @@ -260,6 +262,7 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/moonlive_asm_xtensa.cpp` | 2 | `lines 481-487, also in src/platform/esp32/moonlive_asm_riscv.cpp:297`, duplicated block 7 | | `src/platform/esp32/moonlive_emit_riscv.cpp` | 2 | `lines 52-57, also in src/platform/esp32/moonlive_emit_riscv.cpp:25`, duplicated block 6 | | `src/platform/esp32/platform_esp32_fs.cpp` | 2 | `platform::fsList`, complex function 17 | +| `src/platform/esp32/platform_esp32_sdmmc.cpp` | 2 | `platform::usablePins`, complex function 13 | | `test/js/ui-midi-bridge.test.mjs` | 2 | `lines 4-16, also in test/js/ui-gamepad-bridge.test.mjs:4`, duplicated block 13 | | `test/unit/core/unit_AudioService_sync.cpp` | 2 | `lines 313-322, also in test/unit/core/unit_AudioService_sync.cpp:269`, duplicated block 10 | | `test/unit/core/unit_DevicesModule_hue.cpp` | 2 | `lines 63-69, also in test/unit/core/unit_DevicesModule_hue.cpp:42`, duplicated block 7 | @@ -305,8 +308,6 @@ The next function worth simplifying is at the top of each file's list. | `src/core/services/Services.h` | 1 | `Services in 13 files`, name spread 13 | | `src/core/system/FileManagerModule.cpp` | 1 | `platform::filesystemUsed in tick1s`, blocking call on the render path 1 | | `src/core/system/FilesystemModule.h` | 1 | `FilesystemModule in 25 files`, name spread 25 | -| `src/core/system/I2cBusModule.h` | 1 | `I2cBusModule::scan`, complex function 18 | -| `src/core/system/SdCardModule.cpp` | 1 | `SdCardModule::pollCard in tick1s`, blocking call on the render path 1 | | `src/core/util/Addressing.h` | 1 | `sendAddressed`, complex function 11 | | `src/core/util/CaptivePortal.h` | 1 | `captive::dnsReply`, complex function 21 | | `src/core/util/ImprovFrame.h` | 1 | `ImprovFrameParser::feed`, complex function 22 | @@ -343,13 +344,12 @@ The next function worth simplifying is at the top of each file's list. | `src/light/powerfunctions/fluid.h` | 1 | `Fluid::resize`, complex function 14 | | `src/light/util/E131Packet.h` | 1 | `parseE131Packet`, complex function 17 | | `src/platform/desktop/moonlive_asm_host.h` | 1 | `lines 36-43, also in src/platform/esp32/moonlive_asm_riscv.h:36`, duplicated block 8 | -| `src/platform/desktop/platform_config.h` | 1 | `lines 71-85, also in src/platform/esp32/platform_config.h:241`, duplicated block 15 | +| `src/platform/desktop/platform_config.h` | 1 | `lines 71-85, also in src/platform/esp32/platform_config.h:240`, duplicated block 15 | | `src/platform/esp32/moonlive_asm_xtensa.h` | 1 | `lines 79-96, also in src/platform/esp32/moonlive_asm_riscv.h:74`, duplicated block 18 | -| `src/platform/esp32/platform_config.h` | 1 | `lines 241-255, also in src/platform/desktop/platform_config.h:71`, duplicated block 15 | +| `src/platform/esp32/platform_config.h` | 1 | `lines 240-254, also in src/platform/desktop/platform_config.h:71`, duplicated block 15 | | `src/platform/esp32/platform_esp32_i2s.cpp` | 1 | `platform::audioMicInit`, complex function 11 | -| `src/platform/esp32/platform_esp32_sdmmc.cpp` | 1 | `platform::sdMount`, complex function 22 | | `src/platform/esp32/platform_esp32_tasks.cpp` | 1 | `platform::taskSnapshot`, complex function 12 | -| `src/platform/platform.h` | 1 | `platform.h`, large file 1382 | +| `src/platform/platform.h` | 1 | `platform.h`, large file 1401 | | `src/ui/install-picker.js` | 1 | `install-picker.js`, large file 1048 | | `src/ui/preview3d.js` | 1 | `preview3d.js`, large file 1431 | | `test/js/installer-eth-only.test.mjs` | 1 | `lines 13-23, also in test/js/ui-mooncloud.test.mjs:6`, duplicated block 11 | @@ -365,7 +365,7 @@ The next function worth simplifying is at the top of each file's list. | `test/scenario_runner.cpp` | 1 | `scenario_runner.cpp`, large file 1075 | | `test/unit/core/unit_ControlModule.cpp` | 1 | `unit_ControlModule.cpp`, large file 2152 | | `test/unit/core/unit_Control_apply_absent_key.cpp` | 1 | `lines 92-103, also in test/unit/core/unit_HttpServerModule_apply.cpp:40`, duplicated block 12 | -| `test/unit/core/unit_HttpServerModule_connections.cpp` | 1 | `lines 18-27, also in test/unit/core/unit_HttpServerModule_sdcard.cpp:23`, duplicated block 10 | +| `test/unit/core/unit_HttpServerModule_connections.cpp` | 1 | `lines 18-27, also in test/unit/core/unit_HttpServerModule_sdcard.cpp:24`, duplicated block 10 | | `test/unit/core/unit_moonlive_compiler.cpp` | 1 | `unit_moonlive_compiler.cpp`, large file 1865 | | `test/unit/core/unit_moonlive_fill.cpp` | 1 | `unit_moonlive_fill.cpp`, large file 1380 | | `test/unit/light/unit_BreakoutEffect.cpp` | 1 | `lines 18-27, also in test/unit/light/unit_TetrixEffect.cpp:20`, duplicated block 10 | diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index a757991e2..8915536b1 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,9 +4,9 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks. -**0 error(s)** and **4308 warning(s)** across 420 page(s). +**0 error(s)** and **4289 warning(s)** across 415 page(s). -**5533 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. +**5531 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. An error is in a file that generates a documentation page, a header or a catalog page, so the finding is a defect in what gets published and it fails the gate. A warning is worth fixing without being worth stopping a commit for: any finding in an implementation file, which publishes nothing, and the rules still staged as counts in a header too, the over-wide line and the appendix section nothing refers to. Both are counted here, because a warning nobody sees is a warning nobody fixes. @@ -16,13 +16,13 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| -| split paragraphs | 0 | 1797 | -| over-wide comment lines | 0 | 1593 | -| appendix sections nothing refers to | 0 | 372 | -| multi-line comment blocks | 0 | 318 | +| split paragraphs | 0 | 1791 | +| over-wide comment lines | 0 | 1585 | +| appendix sections nothing refers to | 0 | 369 | +| multi-line comment blocks | 0 | 317 | | over-long sentences | 0 | 82 | | over-wide doc lines | 0 | 78 | -| member deep dives | 0 | 68 | +| member deep dives | 0 | 67 | ## By documentation area @@ -30,10 +30,10 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier | Summary page | Errors | Warnings | |---|---:|---:| -| `(tests, no card)` | 0 | 2072 | -| `platform/index.md` | 0 | 746 | +| `(tests, no card)` | 0 | 2065 | +| `platform/index.md` | 0 | 745 | | `light/effects.md` | 0 | 403 | -| `core/system.md` | 0 | 214 | +| `core/system.md` | 0 | 203 | | `core/supporting.md` | 0 | 181 | | `light/power-functions.md` | 0 | 147 | | `core/services.md` | 0 | 123 | @@ -51,7 +51,7 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul ### (tests, no card) -**0 error(s)** and **2072 warning(s)** across 160 file(s). +**0 error(s)** and **2065 warning(s)** across 159 file(s). | Findings | File | | |---:|---|---| @@ -65,13 +65,13 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul | 48 | `test/unit/light/unit_Effects_gridsweep.cpp` | warning | | 45 | `test/unit/core/unit_moonlive_codegen_xtensa.cpp` | warning | | 42 | `test/unit/core/unit_FilesystemModule_persistence.cpp` | warning | -| 1-42 each | *150 more warning files, 1306 findings* | | +| 1-42 each | *149 more warning files, 1299 findings* | | -By rule, warnings: 1364 over-wide comment lines, 273 split paragraphs, 198 multi-line comment blocks, 82 over-long sentences, 78 over-wide doc lines, 68 member deep dives, 9 appendix sections nothing refers to. +By rule, warnings: 1361 over-wide comment lines, 271 split paragraphs, 197 multi-line comment blocks, 82 over-long sentences, 78 over-wide doc lines, 67 member deep dives, 9 appendix sections nothing refers to. ### platform/index.md -**0 error(s)** and **746 warning(s)** across 33 file(s). +**0 error(s)** and **745 warning(s)** across 32 file(s). | Findings | File | | |---:|---|---| @@ -85,9 +85,9 @@ By rule, warnings: 1364 over-wide comment lines, 273 split paragraphs, 198 multi | 32 | `src/platform/esp32/moonlive_asm_riscv.cpp` | warning | | 23 | `src/platform/esp32/platform_esp32_i2s.cpp` | warning | | 20 | `src/platform/esp32/platform_esp32_improv.cpp` | warning | -| 1-19 each | *23 more warning files, 149 findings* | | +| 1-19 each | *22 more warning files, 148 findings* | | -By rule, warnings: 399 split paragraphs, 219 over-wide comment lines, 114 multi-line comment blocks, 14 appendix sections nothing refers to. +By rule, warnings: 398 split paragraphs, 219 over-wide comment lines, 114 multi-line comment blocks, 14 appendix sections nothing refers to. ### light/effects.md @@ -111,7 +111,7 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. ### core/system.md -**0 error(s)** and **214 warning(s)** across 25 file(s). +**0 error(s)** and **203 warning(s)** across 22 file(s). | Findings | File | | |---:|---|---| @@ -125,9 +125,9 @@ By rule, warnings: 322 split paragraphs, 81 appendix sections nothing refers to. | 12 | `src/core/system/TasksModule.h` | warning | | 11 | `src/core/system/MqttModule.h` | warning | | 11 | `src/core/system/MqttPacket.h` | warning | -| 1-11 each | *15 more warning files, 69 findings* | | +| 1-11 each | *12 more warning files, 58 findings* | | -By rule, warnings: 162 split paragraphs, 37 appendix sections nothing refers to, 9 over-wide comment lines, 6 multi-line comment blocks. +By rule, warnings: 159 split paragraphs, 34 appendix sections nothing refers to, 6 multi-line comment blocks, 4 over-wide comment lines. ### core/supporting.md diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 70a1f9f1b..adaca04d8 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,37 +1,37 @@ { - "commit": "ab9024f0", + "commit": "ef125474", "flash": { - "esp32s3-n16r8": 2336304, - "esp32": 2226256, - "esp32p4rev1-eth": 2329504, - "esp32p4rev1-eth-wifi": 2358896, + "esp32s3-n16r8": 2431936, + "esp32": 2353056, + "esp32p4rev1-eth": 2364160, + "esp32p4rev1-eth-wifi": 2523840, "esp32s3-n8r8": 2336304, - "esp32s31": 2595200, - "esp32-16mb": 2060368, + "esp32s31": 2694736, + "esp32-16mb": 2353248, "esp32-eth": 1681040, "esp32-wrover": 1843760, "qemu": 1383648, - "esp32p4rev3-eth": 2203712, + "esp32p4rev3-eth": 2231792, "esp32s3-zero": 2225360, "esp32-pico": 2107168, - "desktop-macos": 2270440, + "desktop-macos": 2293080, "desktop-windows": 1925120, "esp32c3": 2335072, "desktop-linux": 2548000 }, "measured": { - "esp32p4rev1-eth": "2026-10-12", - "esp32s31": "2026-10-10", - "esp32": "2026-10-09", + "esp32p4rev1-eth": "2026-10-13", + "esp32s31": "2026-10-13", + "esp32": "2026-10-13", "esp32-pico": "2026-09-09", - "esp32s3-n16r8": "2026-10-10", + "esp32s3-n16r8": "2026-10-13", "esp32s3-n8r8": "2026-10-10", "esp32s3-zero": "2026-10-12", - "esp32-16mb": "2026-09-09", - "esp32p4rev1-eth-wifi": "2026-09-30", + "esp32-16mb": "2026-10-13", + "esp32p4rev1-eth-wifi": "2026-10-13", "esp32-eth": "2026-09-11", - "esp32p4rev3-eth": "2026-10-08", - "desktop-macos": "2026-10-10", + "esp32p4rev3-eth": "2026-10-13", + "desktop-macos": "2026-10-13", "desktop-windows": "2026-09-30", "esp32c3": "2026-10-10", "desktop-linux": "2026-10-12" @@ -45,10 +45,10 @@ "scenario_matrix": { "Control_a_palette_preset_changes_only_the_palette": { "desktop-macos": { - "p50": 7, + "p50": 8, "p95": 324, - "n": 30, - "last": "2026-10-10" + "n": 31, + "last": "2026-10-12" }, "esp32s3-n16r8": { "p50": 2073, @@ -113,8 +113,8 @@ "desktop-macos": { "p50": 4, "p95": 387, - "n": 29, - "last": "2026-10-10" + "n": 30, + "last": "2026-10-12" }, "esp32s3-n16r8": { "p50": 2505, @@ -490,7 +490,7 @@ "p50": 7, "p95": 15, "n": 32, - "last": "2026-10-10" + "last": "2026-10-12" }, "desktop-windows": { "p50": 4, @@ -516,7 +516,7 @@ "p50": 7, "p95": 10, "n": 32, - "last": "2026-10-10" + "last": "2026-10-12" }, "desktop-windows": { "p50": 4, @@ -530,7 +530,7 @@ "p50": 1, "p95": 2, "n": 32, - "last": "2026-10-10" + "last": "2026-10-12" }, "desktop-windows": { "p50": 4, @@ -556,7 +556,7 @@ "p50": 7, "p95": 18, "n": 32, - "last": "2026-10-10" + "last": "2026-10-12" }, "esp32s3-n16r8": { "p50": 9080, @@ -582,7 +582,7 @@ "p50": 267, "p95": 355, "n": 32, - "last": "2026-10-10" + "last": "2026-10-12" } }, "Layouts_resize_reallocates_live": { @@ -590,7 +590,7 @@ "p50": 10, "p95": 19, "n": 32, - "last": "2026-10-10" + "last": "2026-10-13" }, "desktop-windows": { "p50": 93, @@ -616,7 +616,7 @@ "p50": 7, "p95": 11, "n": 32, - "last": "2026-10-10" + "last": "2026-10-13" }, "esp32s3-n16r8": { "p50": 10350, @@ -642,7 +642,7 @@ "p50": 7, "p95": 74, "n": 32, - "last": "2026-10-10" + "last": "2026-10-13" } } }, @@ -654,13 +654,13 @@ "p50": 7, "p95": 10, "n": 32, - "last": "2026-10-10" + "last": "2026-10-12" }, "Layouts_resize_reallocates_live": { "p50": 10, "p95": 19, "n": 32, - "last": "2026-10-10" + "last": "2026-10-13" } } }, @@ -688,16 +688,16 @@ } }, "loc": { - "core": 28736, + "core": 28880, "light": 31027, - "platform": 18685, + "platform": 18938, "ui": 12350, - "test": 65301, - "moondeck": 30600 + "test": 65504, + "moondeck": 30611 }, "comments": { "core": { - "lines": 6246, + "lines": 6273, "ratio": 0.241 }, "light": { @@ -705,37 +705,37 @@ "ratio": 0.269 }, "platform": { - "lines": 3781, - "ratio": 0.226 + "lines": 3819, + "ratio": 0.225 }, "ui": { "lines": 3472, "ratio": 0.298 }, "test": { - "lines": 7008, + "lines": 7020, "ratio": 0.124 }, "moondeck": { - "lines": 4906, + "lines": 4907, "ratio": 0.183 } }, "tests": { - "cases": 2428, + "cases": 2438, "scenarios": 30 }, "docs": { "md_files": 150, - "md_lines": 32281, + "md_lines": 32355, "plans_files": 44, - "backlog_lines": 3319, + "backlog_lines": 3344, "lessons_lines": 532, "claude_md_lines": 283 }, "complexity": { - "functions": 4478, - "over_threshold": 336, + "functions": 4515, + "over_threshold": 340, "worst_ccn": 128 } } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 41d5ef06a..929a8e584 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `ab9024f0`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `ef125474`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,21 +8,25 @@ Current state only; the trend is this file's git history (`git log -p docs/refer | Metric | v6.0.0 | Now | Change | |---|---:|---:|---:| -| flash desktop-macos | 1,963 KB | 2,217 KB | +254.2 KB (+12.9%) | -| flash esp32 | 2,061 KB | 2,174 KB | +112.9 KB (+5.5%) | -| flash esp32p4rev1-eth | 2,022 KB | 2,275 KB | +253.2 KB (+12.5%) | -| flash esp32p4rev3-eth | 2,025 KB | 2,152 KB | +126.8 KB (+6.3%) | -| flash esp32s3-n16r8 | 2,111 KB | 2,282 KB | +170.1 KB (+8.1%) | +| flash desktop-macos | 1,963 KB | 2,239 KB | +276.3 KB (+14.1%) | +| flash esp32 | 2,061 KB | 2,298 KB | +236.7 KB (+11.5%) | +| flash esp32-16mb | 2,012 KB | 2,298 KB | +286.0 KB (+14.2%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,309 KB | +287.0 KB (+14.2%) | +| flash esp32p4rev1-eth-wifi | 2,304 KB | 2,465 KB | +161.1 KB (+7.0%) | +| flash esp32p4rev3-eth | 2,025 KB | 2,179 KB | +154.2 KB (+7.6%) | +| flash esp32s3-n16r8 | 2,111 KB | 2,375 KB | +263.5 KB (+12.5%) | | flash esp32s3-n8r8 | 2,038 KB | 2,282 KB | +243.3 KB (+11.9%) | | flash esp32s3-zero | 1,977 KB | 2,173 KB | +196.5 KB (+9.9%) | -| flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | -| lines core | 22,299 | 28,736 | +6,437 (+28.9%) | +| flash esp32s31 | 2,395 KB | 2,632 KB | +236.2 KB (+9.9%) | +| tick Effects_pipeline_builds_and_renders (desktop-macos) | 7 ยตs | 7 ยตs | +0 (+0.0%) | +| tick Layouts_resize_reallocates_live (desktop-macos) | 12 ยตs | 10 ยตs | -2 (-16.7%) | +| lines core | 22,299 | 28,880 | +6,581 (+29.5%) | | lines light | 31,054 | 31,027 | -27 (-0.1%) | -| lines platform | 16,798 | 18,685 | +1,887 (+11.2%) | +| lines platform | 16,798 | 18,938 | +2,140 (+12.7%) | | lines ui | 11,394 | 12,350 | +956 (+8.4%) | -| lines test | 57,759 | 65,301 | +7,542 (+13.1%) | -| lines moondeck | 29,325 | 30,600 | +1,275 (+4.3%) | -| unit tests | 2,120 | 2,428 | +308 (+14.5%) | +| lines test | 57,759 | 65,504 | +7,745 (+13.4%) | +| lines moondeck | 29,325 | 30,611 | +1,286 (+4.4%) | +| unit tests | 2,120 | 2,438 | +318 (+15.0%) | | scenarios | 21 | 30 | +9 (+42.9%) | Against the release `v6.0.0`, the fixed point a user compares with; the tables below compare against the last commit. A firmware not rebuilt since `v6.0.0` is left out, and a measure the release did not record yet, such as the code report, joins from the next one. @@ -31,31 +35,53 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop-linux | 2,488 KB | - | - | yes | -| desktop-macos | 2,217 KB | - | - | carried 2d | -| desktop-windows | 1,880 KB | - | - | **STALE 12d** | -| esp32 | 2,174 KB | 2,496 KB | 87% | carried 3d | -| esp32-16mb | 2,012 KB | 4,096 KB | 49% | **STALE 33d** | -| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 31d** | -| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 33d** | +| desktop-linux | 2,488 KB | - | - | carried 1d | +| desktop-macos | 2,239 KB (+22 KB) โš  | - | - | yes | +| desktop-windows | 1,880 KB | - | - | **STALE 13d** | +| esp32 | 2,298 KB (+124 KB) โš  | 2,496 KB | 92% | yes | +| esp32-16mb | 2,298 KB (+286 KB) โš  | 4,096 KB | 56% | yes | +| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 32d** | +| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 34d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | -| esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 2d | -| esp32p4rev1-eth | 2,275 KB | 4,096 KB | 56% | yes | -| esp32p4rev1-eth-wifi | 2,304 KB | 4,096 KB | 56% | **STALE 12d** | -| esp32p4rev3-eth | 2,152 KB | 4,096 KB | 53% | carried 4d | -| esp32s3-n16r8 | 2,282 KB | 4,096 KB | 56% | carried 2d | -| esp32s3-n8r8 | 2,282 KB | 3,072 KB | 74% | carried 2d | -| esp32s3-zero | 2,173 KB | 2,496 KB | 87% | carried 0d | -| esp32s31 | 2,534 KB | 4,096 KB | 62% | carried 2d | +| esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 3d | +| esp32p4rev1-eth | 2,309 KB (+34 KB) โš  | 4,096 KB | 56% | yes | +| esp32p4rev1-eth-wifi | 2,465 KB (+161 KB) โš  | 4,096 KB | 60% | yes | +| esp32p4rev3-eth | 2,179 KB (+27 KB) โš  | 4,096 KB | 53% | yes | +| esp32s3-n16r8 | 2,375 KB (+93 KB) โš  | 4,096 KB | 58% | yes | +| esp32s3-n8r8 | 2,282 KB | 3,072 KB | 74% | carried 3d | +| esp32s3-zero | 2,173 KB | 2,496 KB | 87% | carried 1d | +| esp32s31 | 2,632 KB (+97 KB) โš  | 4,096 KB | 64% | yes | | qemu | 1,351 KB | - | - | carried (age?) | `Built: yes` was measured this run. `carried (age?)` was not rebuilt either and predates this record, so its age is unknown: it dates itself on the next build. `carried Nd` was NOT rebuilt and its number is N days old, so an absent delta says nothing about the change. **STALE** marks a carry older than 7 days: the number has gone unchecked long enough that growth will surface later as one jump, blamed on whichever commit happens to rebuild that target. `Used` is against the app slot in the firmware's own partition table. +## Flash change by kind + +| Target | Net | New | Removed | Grown | Shrunk | Other | Since | +|---|---:|---:|---:|---:|---:|---:|---| +| esp32 | +118.4 KB | +90.0 KB | -13.9 KB | +28.4 KB | -8.1 KB | +22.0 KB | `ef125474` | +| esp32p4rev1-eth | +134.3 KB | +113.4 KB | -13.5 KB | +27.6 KB | -11.6 KB | +18.3 KB | `ef125474` | +| esp32p4rev1-eth-wifi | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `ef125474` | +| esp32p4rev3-eth | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `ef125474` | +| esp32s3-n16r8 | +96.5 KB | +73.2 KB | -12.2 KB | +27.6 KB | -8.2 KB | +16.0 KB | `ef125474` | +| esp32s31 | +97.2 KB | +79.3 KB | -0.5 KB | +8.2 KB | -0.4 KB | +10.6 KB | `ef125474` | + +Largest moves: + +- **esp32**: `mm::blendSeparable(mm::BlendOp, mm::Buffer const&, mm::Buffe` -4,254 B, `mm::moonlive::(anonymous namespace)::Parser::parseCall(unsig` +3,423 B, `mm::moonlive::compileSource(char const*, mm::moonlive::Built` +2,879 B, `mm::moonlive::(anonymous namespace)::Parser::parsePrimary()` +2,636 B, `f_mkfs` +2,321 B +- **esp32p4rev1-eth**: `mm::moonlive::(anonymous namespace)::Parser::parseCall(unsig` +3,400 B, `spicommon_bus_initialize_io` +3,112 B, `sd_host_sdmmc_controller_add_slot` +2,676 B, `mm::moonlive::(anonymous namespace)::Parser::parsePrimary()` +2,588 B, `mm::moonlive::compileSource(char const*, mm::moonlive::Built` +2,470 B +- **esp32p4rev1-eth-wifi**: none +- **esp32p4rev3-eth**: none +- **esp32s3-n16r8**: `mm::moonlive::(anonymous namespace)::Parser::parseCall(unsig` +3,479 B, `mm::moonlive::compileSource(char const*, mm::moonlive::Built` +2,915 B, `mm::moonlive::(anonymous namespace)::Parser::parsePrimary()` +2,668 B, `f_mkfs` +2,345 B, `sd_host_sdmmc_controller_add_slot` +2,040 B +- **esp32s31**: `mm::SdCardModule::defineControls()` +3,496 B, `f_mkfs` +2,388 B, `sd_host_sdmmc_controller_add_slot` +2,286 B, `sd_host_slot_sdmmc_do_transaction` +2,140 B, `sd_host_create_sdmmc_controller` +1,354 B + +What this commit's flash went to, symbol by symbol against the measurement at `Since`. New is new functionality, removed and shrunk are what a cleanup saved, and other is what no symbol owns, such as merged strings and alignment. A rename reads as removed plus new, which the largest moves expose. + ## Render performance | Target | Tick | FPS | |---|---:|---:| -| desktop-linux | 5 ยตs (โˆ’1 ยตs) โœ“ | 200,000 (+33,334) โœ“ | +| desktop-linux | 5 ยตs | 200,000 | | desktop-macos | 7 ยตs | 142,857 | | desktop-windows | 4 ยตs | 250,000 | | esp32 | 1,716 ยตs | 582 | @@ -65,7 +91,7 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | desktop-docker-arm64 | esp32p4rev1-eth-wifi | |---|---|---|---|---|---|---|---|---|---|---| | Aurora_fps | 1,549 | 6,015 ? | 217,295 ? | 188,782 ? | 83,632 ? | 87,613 ? | - | - | 7,532 ? | 1,942,284 ? | -| Control_a_palette_preset_changes_only_the_palette | 7 | - | - | 2,073 | - | - | - | - | - | - | +| Control_a_palette_preset_changes_only_the_palette | 8 (+1) โš  | - | - | 2,073 | - | - | - | - | - | - | | Drivers_output_and_brightness | 7 | 4 ? | - | 8,690 ? | - | - | - | - | 218 ? | - | | Effects_pipeline_builds_and_renders | 7 | 4 ? | - | - | - | - | - | - | - | - | | Effects_swap_while_running | 1 | 4 ? | - | 10,000 ? | - | - | - | - | 287 ? | - | @@ -110,26 +136,26 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 28,736 (โˆ’4) โœ“ | 6,246 | 24.1 % | +| core | 28,880 (+144) โš  | 6,273 | 24.1 % | | light | 31,027 | 7,439 | 26.9 % | -| platform | 18,685 | 3,781 | 22.6 % | +| platform | 18,938 (+253) โš  | 3,819 | 22.5 % (โˆ’0.1 %) โœ“ | | ui | 12,350 | 3,472 | 29.8 % | -| test | 65,301 | 7,008 | 12.4 % | -| moondeck | 30,600 | 4,906 | 18.3 % | +| test | 65,504 (+203) โš  | 7,020 | 12.4 % | +| moondeck | 30,611 (+11) โš  | 4,907 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,428 | +| unit cases | 2,438 (+10) โœ“ | | scenarios | 30 | ## Complexity | Metric | Value | |---|---:| -| functions | 4,478 | -| over threshold | 336 | +| functions | 4,515 (+37) โœ“ | +| over threshold | 340 (+4) โš  | | worst CCN | 128 | ## Documentation @@ -137,9 +163,9 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| | markdown files | 150 | -| markdown lines | 32,281 (โˆ’2) โœ“ | +| markdown lines | 32,355 (+74) โš  | | plan files | 44 | -| backlog lines | 3,319 | +| backlog lines | 3,344 (+25) โš  | | lessons lines | 532 | | CLAUDE.md lines | 283 | diff --git a/docs/work/future/backlog-core.md b/docs/work/future/backlog-core.md index 5f284174a..1e3497ca9 100644 --- a/docs/work/future/backlog-core.md +++ b/docs/work/future/backlog-core.md @@ -482,6 +482,31 @@ Both surfaced while bringing up MoonBase Ethernet on the migrated Olimex; neithe ## ESP32 performance and memory +### One board-preset helper for Ethernet and the SD card (2026-10-12) + +EthernetModule and SdCardService each carry the same board-preset machinery: the per-chip filtered option list, selection by label, the applied tracker, applying a preset when the selection moves, and reading the preset back off the pins. +A `core/module/BoardPresets.h` helper would own the options, index, count, selection and applied state, with the module supplying its table and two functions: write a preset into the pins, and match the pins against one. +Do it before a third preset table appears (a display's or a microphone's wiring), so that one becomes a table rather than a third copy. + +### SD card writes over SPI stop the card on the Dig-Octa (2026-10-12) + +On the QuinLED Dig-Octa's SPI slot (SCK 14, MOSI 15, MISO 36, CS 16), a write past about 50 to 200 KB fails mid-transfer, and the card then answers no command until its power is cut. +Reads hold: a 5 MB file reads back byte-exact at 184 KB/s. +The same no-brand 32 GB card writes 5 MB cleanly on the native SD bus of the P4 and of the Olimex ESP32-Gateway. +Ruled out: the clock (20, 10 and 4 MHz fail alike), 30 ms between writes, and the wiring, which Quindor's schematic shows with 10 kOhm pull-ups, its own LDO and decoupling. Turning the LED driver off moved the limit a little without removing it. +Next: the same 5 MB test with an A-brand card, since SPI mode is the part of the spec newer cards treat as secondary. ESPixelStick, the one other firmware driving this slot, throttles its writes into 7 KB blocks with a 30 ms gap and pauses LED output during an upload (forkineye/ESPixelStick issue 802 and `src/FileMgr.cpp`). +Until it is solved, a card mounted over SPI shows a warning status. + +### A classic ESP32 on WiFi stalls while the state document builds (2026-10-12) + +A full state fetch serializes the whole tree into one buffer, 49 KB on the Olimex ESP32-Gateway with an SD card, LEDs and both network interfaces. +On WiFi a classic has about 80 KB free at rest, so a browser's fetch or resync drops the largest free block to 4 to 14 KB, and new connections stall until it is freed: the page stops answering and the LEDs pause. +On Ethernet the same board keeps 121 KB free and a 108 KB block, and never stalls. +The fix is to stream the document: write each module's JSON straight to the socket as it is produced, so the peak is one module rather than the tree. +The same applies to the websocket resync, which sends the same document. +Measured with this PR's SD card against `main`: 1.4 KB more static DRAM, 4 to 7 KB more heap while a card is mounted, so the stall exists on `main` too. +Watch IRAM on the classic as well: the SD and SPI drivers' interrupt handlers raised it from 98 KB to 107 KB of about 128 KB. + ### Size estimates for unbuilt features (reference) Estimates, not measurements, so they live here rather than in [performance.md](../../reference/performance.md) which carries measured numbers only. (The 4 MB flash-budget investigation these once fed is resolved: MoonBase's single-app-slot layout grew the classic app slot to 2496 KB, see architecture.md ยง MoonBase.) diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index 80cd2856c..2118e71e1 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -12,7 +12,7 @@ idf_component_register( "../../src/core/system/HttpServerModule.cpp" "../../src/core/system/FilesystemModule.cpp" "../../src/core/system/FileManagerModule.cpp" - "../../src/core/system/SdCardModule.cpp" + "../../src/core/services/SdCardService.cpp" "../../src/core/system/MqttModule.cpp" "../../src/core/module/Scheduler.cpp" "../../src/core/module/StateDocument.cpp" diff --git a/esp32/main/Kconfig.projbuild b/esp32/main/Kconfig.projbuild index 7b1868819..39b380c19 100644 --- a/esp32/main/Kconfig.projbuild +++ b/esp32/main/Kconfig.projbuild @@ -47,12 +47,11 @@ menu "MoonLight" (n8r8, n16r8); the 4 MB S3-Zero leaves it off. config MM_SD_CARD - bool "microSD card support (SDMMC)" - default n + bool "microSD card support (SDMMC or SPI)" + default y help - Compiles the SDMMC/FatFs SD card backend. Set by the images with flash - to spare (every P4 variant, S31, the 16 MB S3); classic ESP32's SD host - only works on its fixed pins (2, 4, 12, 13, 14, 15) anyway, so it stays - off there too. + Compiles the FatFs SD card backend, on every chip with an SD host. An + image whose boards have no slot and whose flash is tight sets it off, + as the S3-Zero does. endmenu diff --git a/esp32/sdkconfig.defaults.esp32p4rev1-eth b/esp32/sdkconfig.defaults.esp32p4rev1-eth index a5502bee7..13f6027ca 100644 --- a/esp32/sdkconfig.defaults.esp32p4rev1-eth +++ b/esp32/sdkconfig.defaults.esp32p4rev1-eth @@ -61,9 +61,6 @@ CONFIG_ETH_DMA_TX_BUFFER_NUM=10 CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=n CONFIG_HEAP_HAS_EXEC_HEAP=y -# 16 MB of flash to spare: the SD card backend. Every P4 image (rev1/rev3, -# eth/eth-wifi) layers on this file, so this one line covers them all. -CONFIG_MM_SD_CARD=y # esp-dsp's OPTIMIZED P4 assembly kernels: the default, and what we ship. The line below forces # the portable C kernels instead and is currently OFF. diff --git a/esp32/sdkconfig.defaults.esp32s3-n16r8 b/esp32/sdkconfig.defaults.esp32s3-n16r8 index 78531231b..a7544789c 100644 --- a/esp32/sdkconfig.defaults.esp32s3-n16r8 +++ b/esp32/sdkconfig.defaults.esp32s3-n16r8 @@ -26,5 +26,3 @@ CONFIG_HEAP_HAS_EXEC_HEAP=y # A MIDI desk on the board's own USB port: the MIDI service's USB source. CONFIG_MM_USB_MIDI_HOST=y -# 16 MB of flash to spare: the SD card backend. -CONFIG_MM_SD_CARD=y diff --git a/esp32/sdkconfig.defaults.esp32s3-zero b/esp32/sdkconfig.defaults.esp32s3-zero index 703b37769..038cdb799 100644 --- a/esp32/sdkconfig.defaults.esp32s3-zero +++ b/esp32/sdkconfig.defaults.esp32s3-zero @@ -29,3 +29,6 @@ CONFIG_SPIRAM_MODE_QUAD=y # hardware W^X wall. CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=n CONFIG_HEAP_HAS_EXEC_HEAP=y + +# No SD card backend: none of the S3-Zero boards has a slot, and 4 MB of flash is nearly full. +CONFIG_MM_SD_CARD=n diff --git a/esp32/sdkconfig.defaults.esp32s31 b/esp32/sdkconfig.defaults.esp32s31 index 3a8215a85..0e3b4ab9a 100644 --- a/esp32/sdkconfig.defaults.esp32s31 +++ b/esp32/sdkconfig.defaults.esp32s31 @@ -74,5 +74,3 @@ CONFIG_ETH_TRANSMIT_MUTEX=y CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=n CONFIG_HEAP_HAS_EXEC_HEAP=y -# 16 MB of flash to spare: the SD card backend. -CONFIG_MM_SD_CARD=y diff --git a/moondeck/check/check_devices.py b/moondeck/check/check_devices.py index 8c017839b..c4ba00efd 100644 --- a/moondeck/check/check_devices.py +++ b/moondeck/check/check_devices.py @@ -32,6 +32,7 @@ MODULE_TYPES_CPP = ROOT / "src" / "module_types.cpp" MAIN_CPP = ROOT / "src" / "main.cpp" ETHERNET_MODULE = ROOT / "src" / "core" / "system" / "EthernetModule.h" +SD_CARD_MODULE = ROOT / "src" / "core" / "services" / "SdCardService.h" PLATFORM_CONFIG = ROOT / "src" / "platform" / "esp32" / "platform_config.h" DOCS = ROOT / "docs" @@ -54,17 +55,17 @@ FLASH_BAUDS = {115200, 230400, 460800, 921600} -def eth_preset_labels(): - """The Ethernet preset labels from EthernetModule.h's kEthPresets table. +def preset_labels(header, table): + """The preset labels of a module header's board-preset table, such as EthernetModule.h's kEthPresets. Read from the firmware rather than restated here, so a preset renamed in one place and not the other fails this check instead of silently leaving a catalog entry pointing at nothing. """ - text = ETHERNET_MODULE.read_text(encoding="utf-8") - table = re.search(r"kEthPresets\[\]\s*=\s*\{(.*?)\n \};", text, re.S) - if not table: + text = header.read_text(encoding="utf-8") + block = re.search(table + r"\[\]\s*=\s*\{(.*?)\n \};", text, re.S) + if not block: return set() - return set(re.findall(r'^\s*\{"([^"]+)"', table.group(1), re.M)) + return set(re.findall(r'^\s*\{"([^"]+)"', block.group(1), re.M)) def eth_preset_drift(): @@ -137,13 +138,16 @@ def top_level_names(): def main(): errors = [] - eth_presets = eth_preset_labels() + eth_presets = preset_labels(ETHERNET_MODULE, "kEthPresets") + sd_presets = preset_labels(SD_CARD_MODULE, "kSdPresets") for label, field, was, now in eth_preset_drift(): errors.append(f"EthernetModule.h kEthPresets {label!r}: {field} is {now}, but " f"platform_config.h's ethConfigDefault says {was}: the preset restates the " f"chip default, so the two must agree or a provisioned board reseeds to Custom") if not eth_presets: errors.append("EthernetModule.h: could not read the kEthPresets table, so the ethBoard check cannot run") + if not sd_presets: + errors.append("SdCardService.h: could not read the kSdPresets table, so the SD board check cannot run") try: catalog = json.loads(CATALOG.read_text(encoding="utf-8")) @@ -343,6 +347,13 @@ def _gpio(v): # inheriting the default would drive an LED pin as an Ethernet reset.) Only the pins that # are genuine BOARD WIRING are required: MDC/MDIO may stay at the IDF default (omit or # -1) on RMII, since that's a real standard, not a board-specific value. + # The same rule for the SD slot's wiring, whose map lives in SdCardService.h. + if mtype == "SdCardService" and "board" in controls: + board = controls["board"] + if not isinstance(board, str): + errors.append(f"{where}: SdCardService board must be the preset LABEL as a string, got {board!r}") + elif board not in sd_presets: + errors.append(f"{where}: SdCardService board {board!r} is not a preset in SdCardService.h (known: {sorted(sd_presets)})") if mtype == "EthernetModule": # PRESENCE, not truthiness: a JSON `null` reads as None like a missing key, and the # installer treats a present-but-null value as a named preset (null !== "Custom"). diff --git a/mooninstaller/deviceModels.json b/mooninstaller/deviceModels.json index f1120a347..ee52a0aa5 100644 --- a/mooninstaller/deviceModels.json +++ b/mooninstaller/deviceModels.json @@ -13,11 +13,10 @@ "LEDs", "WiFi", "Ethernet", - "Buttons" - ], - "planned": [ + "Buttons", "SD card" ], + "planned": [], "state": { "System": { "deviceModel": "Olimex ESP32-Gateway Rev G" @@ -47,6 +46,10 @@ "kind": "toggle" } ] + }, + "SdCard": { + "type": "SdCardService", + "board": "Classic SDMMC (1-bit)" } } } @@ -473,11 +476,11 @@ "WiFi", "Ethernet", "Buttons", - "Relay" + "Relay", + "SD card" ], "planned": [ - "Temperature", - "SD card" + "Temperature" ], "state": { "System": { @@ -514,6 +517,10 @@ "activeLow": true } ] + }, + "SdCard": { + "type": "SdCardService", + "board": "Classic SPI" } } }, @@ -887,7 +894,8 @@ "supported": [ "LEDs", "Ethernet", - "Audio" + "Audio", + "SD card" ], "planned": [ "Audio codec", @@ -908,6 +916,10 @@ "sdPin": 33, "mclkPin": 36, "gain": 100 + }, + "SdCard": { + "type": "SdCardService", + "board": "P4 SDMMC" } }, "Drivers": { @@ -954,11 +966,11 @@ "supported": [ "LEDs", "WiFi", - "Ethernet" + "Ethernet", + "SD card" ], "planned": [ - "Button", - "SD card" + "Button" ], "state": { "System": { @@ -993,6 +1005,12 @@ "Layer": { "type": "Layer" } + }, + "Services": { + "SdCard": { + "type": "SdCardService", + "board": "Classic SDMMC (1-bit)" + } } }, "flashBaud": 460800 @@ -1125,7 +1143,8 @@ "LEDs", "WiFi", "Ethernet", - "Audio" + "Audio", + "SD card" ], "planned": [ "Speaker", @@ -1166,6 +1185,10 @@ "mclkPin": 13, "codec": "ES8311", "codecAddr": "0x18" + }, + "SdCard": { + "type": "SdCardService", + "board": "P4 SDMMC" } } } @@ -1226,14 +1249,8 @@ "codecAddr": "0x18" }, "SdCard": { - "type": "SdCardModule", - "clkPin": 43, - "cmdPin": 44, - "d0Pin": 39, - "d1Pin": 40, - "d2Pin": 41, - "d3Pin": 42, - "pwrPin": 45 + "type": "SdCardService", + "board": "P4 SDMMC" } } } @@ -1249,7 +1266,8 @@ "supported": [ "LEDs", "Ethernet", - "Audio" + "Audio", + "SD card" ], "planned": [ "Speaker", @@ -1282,6 +1300,10 @@ "mclkPin": 13, "codec": "ES8311", "codecAddr": "0x18" + }, + "SdCard": { + "type": "SdCardService", + "board": "P4 SDMMC" } } } @@ -1298,7 +1320,8 @@ "supported": [ "LEDs", "Ethernet", - "Audio" + "Audio", + "SD card" ], "planned": [ "WiFi", @@ -1325,6 +1348,10 @@ "sdPin": 33, "mclkPin": 36, "gain": 100 + }, + "SdCard": { + "type": "SdCardService", + "board": "P4 SDMMC" } }, "Drivers": { diff --git a/src/core/services/SdCardService.cpp b/src/core/services/SdCardService.cpp new file mode 100644 index 000000000..a351dce93 --- /dev/null +++ b/src/core/services/SdCardService.cpp @@ -0,0 +1,232 @@ +/// @defgroup sd_card_impl SD Card implementation +/// The microSD slot's pins, mount and format button; the files it holds are browsed through the File Manager's existing routes, not a surface of this module's own. Public surface and class layout live in SdCardService.h. +/// @{ +#include "core/services/SdCardService.h" + +#include "platform/platform.h" // sdMount/sdUnmount/sdFormat/sdCardUsed/sdCardTotal, hasSdCard + +#include // strcmp +#include // size + +namespace mm { + +namespace { +// In platform::SdBus order, since the control stores the index. +constexpr const char* kBusOptions[] = {"SDMMC", "SPI"}; +} // namespace + +void SdCardService::defineControls() { + buildPresetOptions(); + // A selection that MOVED writes its map, since a restore fires no onControlChanged; otherwise an un-chosen Custom is read back off the pins. + if (presetSel_ != presetApplied_) applyPreset(); + else if (presetIsCustom()) seedPresetFromPins(); + presetApplied_ = presetSel_; + controls_.addSelect("board", presetSel_, presetOptions_, presetCount_); + controls_.setPersistLabel(controls_.count() - 1); // by label: the list is filtered per build, so an index would name a different board + controls_.setHidden(controls_.count() - 1, presetCount_ == 1); // Custom alone is no choice + // Hidden under a preset and still bound, so the values the preset wrote drive the mount and persist. + const bool custom = presetIsCustom(); + controls_.addSelect("bus", bus_, kBusOptions, static_cast(std::size(kBusOptions))); + controls_.setHidden(controls_.count() - 1, !custom); + const bool spi = bus_ == static_cast(platform::SdBus::Spi); + const auto addPinRow = [&](const char* name, int8_t& pin, bool inSpi) { + controls_.addPin(name, pin); + controls_.setHidden(controls_.count() - 1, !custom || (spi && !inSpi)); + }; + addPinRow("clkPin", clkPin_, true); + addPinRow("cmdPin", cmdPin_, true); + addPinRow("d0Pin", d0Pin_, true); + addPinRow("d1Pin", d1Pin_, false); // SPI mode carries data on D0 and D3 alone + addPinRow("d2Pin", d2Pin_, false); + addPinRow("d3Pin", d3Pin_, true); + addPinRow("pwrPin", pwrPin_, true); + + // Neither is hidden: there is no panel of this module's own, so the generic card is the only place either shows. Press-twice confirmed generically, the same contract every other confirm-gated button already has. + controls_.addButton("format", /*confirm=*/true); + // Usage gauge, bound only once mounted: an absent/unreadable card has nothing to show; KiB is in the name since the shared Progress control's own label assumes bytes. + if (mounted_ && totalKiB_ > 0) controls_.addProgress("card (KiB)", usedKiB_, totalKiB_, /*bytes=*/false); + MoonModule::defineControls(); +} + +bool SdCardService::affectsPrepare(const char* name) const { + return std::strcmp(name, "board") == 0 || std::strcmp(name, "bus") == 0 || std::strcmp(name, "clkPin") == 0 || std::strcmp(name, "cmdPin") == 0 || + std::strcmp(name, "d0Pin") == 0 || std::strcmp(name, "d1Pin") == 0 || + std::strcmp(name, "d2Pin") == 0 || std::strcmp(name, "d3Pin") == 0 || + std::strcmp(name, "pwrPin") == 0; +} + +namespace { +// The one way a card inserted later mounts, without a timed retry on the render thread. +constexpr const char* kRetryHint = "card removed: reinsert it, then switch this module off and on"; +constexpr const char* kFormatting = "formatting: the card is back when it finishes"; + +// What a failed mount says, by the reason the platform recorded, with the one step that fixes it. +const char* mountProblem() { + switch (platform::sdMountError()) { + case platform::SdMountError::Pins: return "mount failed: a pin cannot drive the card, check the pins"; + case platform::SdMountError::Unreadable: return "the card answers but does not read: try another card"; + case platform::SdMountError::NoFilesystem: return "the card has no FAT filesystem: press format to make one"; + default: return "no card answered: insert one, then switch this module off and on"; + } +} + +bool samePins(const platform::SdPinConfig& a, const platform::SdPinConfig& b) { + return a.clk == b.clk && a.cmd == b.cmd && a.d0 == b.d0 && + a.d1 == b.d1 && a.d2 == b.d2 && a.d3 == b.d3 && a.pwr == b.pwr && a.bus == b.bus; +} +} // namespace + +void SdCardService::prepare() { + // A running format owns the card until it publishes its result, which tick1s picks up. + if (platform::sdFormatState() == platform::SdFormatState::Running) { formatting_ = true; setStatus(kFormatting, Severity::Status); return; } + platform::SdPinConfig pins = pinConfig(); + // Most prepares concern another module, so pins already tried are left alone: a remount tears down open streams, and a retry on an empty slot stalls rendering. + if (tried_ && samePins(pins, triedPins_)) return; + + const bool wasMounted = mounted_; + if (mounted_) { + if (!platform::sdUnmount()) { setStatus("busy: transfer in progress", Severity::Warning); return; } + mounted_ = false; + } + + tried_ = true; + triedPins_ = pins; + mountOn(pins); + // defineControls() saw the old mounted_ in its gauge gate, so one rebuild adds or drops the gauge now that the mount state moved. + if (mounted_ != wasMounted) rebuildControls(); +} + +void SdCardService::mountOn(const platform::SdPinConfig& pins) { + if (pins.clk < 0 || pins.cmd < 0 || pins.d0 < 0) { + // Unset is this module's idle state (nothing is wrong yet, nothing was attempted), not a warning. + setStatus("set the SD card pins first", Severity::Status); + return; + } + mounted_ = platform::sdMount(pins); + if (mounted_) { refreshGauge(); showMounted(); } + else setStatus(mountProblem(), Severity::Error); +} + +void SdCardService::release() { + if (mounted_ && platform::sdUnmount()) mounted_ = false; + tried_ = false; // switching the module back on tries the slot again, which is how a card inserted later mounts + MoonModule::release(); +} + +void SdCardService::onControlChanged(const char* controlName) { + if (std::strcmp(controlName, "format") != 0 || formatting_) return; + // A card without a filesystem is the one unmounted card a format applies to: it formats and mounts it. + const bool unformatted = !mounted_ && platform::sdMountError() == platform::SdMountError::NoFilesystem; + if (!mounted_ && !unformatted) { setStatus("mount a card before formatting", Severity::Warning); markDirty(); return; } + if (platform::sdCardBusy()) { setStatus("busy: transfer in progress", Severity::Warning); markDirty(); return; } + if (!platform::sdFormat()) { setStatus("format failed", Severity::Error); markDirty(); return; } + formatting_ = true; + setStatus(kFormatting, Severity::Status); + if (mounted_) { mounted_ = false; rebuildControls(); } // the card is unmounted while it formats, so the gauge goes + finishFormat(); // a backend that formats within the call is done already + markDirty(); +} + +void SdCardService::finishFormat() { + const platform::SdFormatState state = platform::sdFormatState(); + if (state == platform::SdFormatState::Running) return; + formatting_ = false; + mounted_ = platform::sdCardMounted(); + if (state == platform::SdFormatState::Succeeded) { refreshGauge(); showMounted(); } + else setStatus("format failed: switch this module off and on to try the card again", Severity::Error); + rebuildControls(); + markDirty(); +} + +void SdCardService::tick1s() MM_NONBLOCKING { + if (formatting_) finishFormat(); + else pollCard(); +} + +void SdCardService::pollCard() { + if (!mounted_) return; + // A cheap status probe, once a second: cheap enough to poll, unlike a full mount. A card pulled mid-transfer leaves its stream to the existing stall-timeout/I/O-failure cleanup, so sdUnmount's busy guard is retried here rather than bypassed. + if (!platform::sdCardPresent()) { + if (!platform::sdUnmount()) return; + mounted_ = false; + setStatus(kRetryHint, Severity::Warning); + rebuildControls(); + return; + } + // Once a minute, not once a second: the same reasoning as FileManagerModule's own scan. + if (++secondsSinceScan_ < 60) return; + secondsSinceScan_ = 0; + refreshGauge(); +} + +void SdCardService::showMounted() { + if (bus_ == static_cast(platform::SdBus::Spi)) setStatus("SPI: writes over 50 KB can stop the card until a power cycle", Severity::Warning); + else clearStatus(); +} + +void SdCardService::refreshGauge() { + totalKiB_ = static_cast(platform::sdCardTotal() / 1024); + usedKiB_ = static_cast(platform::sdCardUsed() / 1024); +} + +void SdCardService::buildPresetOptions() { + const char* current = presetSel_ < presetCount_ ? presetOptions_[presetSel_] : nullptr; + presetCount_ = 0; + uint8_t sel = 0; + for (uint8_t i = 0; i < kSdPresetCount; i++) { + if (!presetFits(kSdPresets[i].chip)) continue; + // The default: Custom, unless this chip has exactly one real preset, which is then its board. + const bool isDefault = opensOnSolePreset() ? !kSdPresets[i].editable : kSdPresets[i].editable; + if (isDefault) sel = presetCount_; + presetOptions_[presetCount_] = kSdPresets[i].label; + presetIndex_[presetCount_++] = i; + } + for (uint8_t k = 0; current && k < presetCount_; k++) + if (std::strcmp(presetOptions_[k], current) == 0) sel = k; + presetSel_ = sel; +} + +bool SdCardService::presetIsCustom() const { + return presetSel_ >= presetCount_ || kSdPresets[presetIndex_[presetSel_]].editable; +} + +void SdCardService::applyPreset() { + if (presetIsCustom()) return; + const SdPreset& p = kSdPresets[presetIndex_[presetSel_]]; + bus_ = static_cast(p.bus); + clkPin_ = p.clk; cmdPin_ = p.cmd; d0Pin_ = p.d0; d1Pin_ = p.d1; d2Pin_ = p.d2; d3Pin_ = p.d3; pwrPin_ = p.pwr; +} + +platform::SdPinConfig SdCardService::pinsOf(const SdPreset& p) { + platform::SdPinConfig pins{}; + pins.clk = p.clk; pins.cmd = p.cmd; pins.d0 = p.d0; pins.d1 = p.d1; pins.d2 = p.d2; pins.d3 = p.d3; pins.pwr = p.pwr; + pins.bus = p.bus; + return pins; +} + +void SdCardService::seedPresetFromPins() { + const platform::SdPinConfig pins = pinConfig(); + for (uint8_t k = 0; k < presetCount_; k++) { + const SdPreset& p = kSdPresets[presetIndex_[k]]; + if (!p.editable && samePins(pinsOf(p), pins)) { presetSel_ = k; return; } + } +} + +platform::SdPinConfig SdCardService::pinConfig() const { + platform::SdPinConfig pins{}; + pins.clk = clkPin_; + pins.cmd = cmdPin_; + pins.d0 = d0Pin_; + pins.d1 = d1Pin_; + pins.d2 = d2Pin_; + pins.d3 = d3Pin_; + pins.pwr = pwrPin_; + pins.bus = static_cast(bus_); + return pins; +} + +// mkdir/delete/read/write are the File Manager's existing HTTP endpoints, against paths under /sd; this module holds no op state and persists nothing itself. + +} // namespace mm + +/// @} diff --git a/src/core/services/SdCardService.h b/src/core/services/SdCardService.h new file mode 100644 index 000000000..827687dbc --- /dev/null +++ b/src/core/services/SdCardService.h @@ -0,0 +1,149 @@ +#pragma once + +#include "core/module/MoonModule.h" +#include "platform/platform.h" // SdPinConfig + +#include + +namespace mm { + +/// A board's microSD slot: pins, a usage gauge and a format button. Its files appear under `/sd`, within the existing File Manager tree. +/// +/// A Services child, added only to a board's catalog entry, the way AudioService's mic pins are. +/// @card SdCardService.png +/// +/// @moreinfo +/// +/// ## The pins are runtime configuration, like AudioService's mic +/// +/// The pins default to -1, unset; `d1Pin`-`d3Pin` are optional, and leaving them unset runs the bus 1-bit instead of 4-bit. `bus` picks SPI mode instead, with the SD spec's own pin mapping: `clkPin` is SCK, `cmdPin` MOSI, `d0Pin` MISO and `d3Pin` CS. +/// +/// ## A board preset names a soldered wiring, as Ethernet's does +/// +/// `board` picks a wiring boards follow, filtered to the ones this chip can wire, and hides the pins it sets; Custom shows them for a hand-wired slot. It persists by label, and a config holding only pins opens on the preset they match. +/// +/// A card mounted over SPI carries a warning: on the Dig-Octa, reads hold but writes past about 50 KB stop the card until a power cycle. +/// +/// The default is Custom, unless the filter leaves this chip one preset: every P4 board with a slot wires the same pins, so a P4 opens on it. A classic has two, so its catalog entry names one. +/// +/// ## The mount re-attempts on every pin change, like the bus it rides +/// +/// `prepare()` remounts on a pin edit, as I2cBusModule does for sda/scl. It runs on any `affectsPrepare` change in the tree, so it returns at once while the pins it last tried are unchanged. +/// +/// A cheap probe in `tick1s()` notices a pull. An inserted card mounts on a pin edit, a module restart or a boot, since mounting an empty slot stalls the render thread. +/// +/// An unmount or a format under an open stream is refused with "busy: transfer in progress", and a card pulled mid-transfer unmounts once the stall timeout has closed that stream. +/// +/// ## Pins, gauge and format are this module's whole surface +/// +/// The card mounts at `/sd`, so the existing File Manager tree already shows it; this module owns only pins, the gauge and format. +/// +/// ## Why KiB, not the shared Progress control's bytes +/// +/// A card's byte count overruns the `uint32_t` the shared Progress control carries, so `platform::sdCardUsed`/`Total` are `uint64_t` and this module narrows to KiB before handing the gauge its numbers. +/// +/// Prior art: I2cBusModule's live pin reconfiguration; FileManagerModule's usage gauge. +class SdCardService : public MoonModule { +public: + /// A Services child, same as AudioService: the catalog entry attaches it under "Services". + ModuleRole role() const MM_NONBLOCKING override { return ModuleRole::Service; } + /// Declare the pins, the usage gauge and the format button: @xref{pins-gauge-and-format-are-this-modules-whole-surface}. + void defineControls() override; + /// A pin edit remounts the card on its new wiring: @xref{the-pins-are-runtime-configuration-like-audioservices-mic}. + bool affectsPrepare(const char* name) const override; + /// Mount the card on its pins, once per set of pins: @xref{the-mount-re-attempts-on-every-pin-change-like-the-bus-it-rides}. + void prepare() override; + /// Unmount this instance's card, if it mounted one. + void release() override; + /// Start a format when the button is pressed. + void onControlChanged(const char* controlName) override; + /// Wait out a running format, otherwise poll for a pull and refresh the usage gauge. + void tick1s() MM_NONBLOCKING override; + + /// The chip a board preset is wired for; Custom is Any, which fits every chip. + enum class SdPresetChip : uint8_t { Any, Classic, P4 }; + /// What a build can wire, so the filter answers for any chip; a classic is the chip that is none of the others. + struct SdChip { bool previews, classic, p4; }; + static constexpr SdChip kThisChip{platform::previewsSdPresets, !platform::isEsp32P4 && !platform::isEsp32S3 && !platform::isEsp32S31, platform::isEsp32P4}; + /// Whether a preset for `chip` belongs on `on`: Custom everywhere, and every preset where the platform previews them. + static constexpr bool presetFits(SdPresetChip chip, SdChip on = kThisChip) { + return chip == SdPresetChip::Any || on.previews || (chip == SdPresetChip::Classic && on.classic) || (chip == SdPresetChip::P4 && on.p4); + } + /// Whether `on` opens on its one fitting preset rather than Custom: @xref{a-board-preset-names-a-soldered-wiring-as-ethernets-does}. + static constexpr bool opensOnSolePreset(SdChip on = kThisChip) { + uint8_t fitting = 0; + for (const SdPreset& p : kSdPresets) fitting += !p.editable && presetFits(p.chip, on); + return fitting == 1 && !on.previews; + } + +private: + bool mounted_ = false; ///< true once platform::sdMount() has succeeded + bool formatting_ = false; ///< a format runs in the background, which tick1s() waits out + bool tried_ = false; ///< whether prepare() has tried triedPins_ since the last release() + platform::SdPinConfig triedPins_{}; ///< the pins prepare() last tried, mounted or not, so an unrelated prepare() is a no-op + uint32_t usedKiB_ = 0; ///< card usage, in KiB: @xref{why-kib-not-the-shared-progress-controls-bytes} + uint32_t totalKiB_ = 0; ///< the card's capacity, in KiB, read once on mount + uint8_t secondsSinceScan_ = 0; ///< paces the usage scan + + // Unused by default, so a chip with no card wired claims no GPIO; a board's catalog entry sets the ones it wires. + int8_t clkPin_ = -1; ///< SDMMC clock + int8_t cmdPin_ = -1; ///< SDMMC command line + int8_t d0Pin_ = -1; ///< SDMMC data line 0, required + int8_t d1Pin_ = -1; ///< SDMMC data line 1; unset alongside d2/d3 drops the bus to 1-bit mode + int8_t d2Pin_ = -1; ///< SDMMC data line 2 + int8_t d3Pin_ = -1; ///< SDMMC data line 3 + int8_t pwrPin_ = -1; ///< a slot's card-power switch, or -1 where the slot has none + uint8_t bus_ = 0; ///< a platform::SdBus: the SD bus, or SPI mode + + /// One board's slot wiring, the fields matching platform::SdPinConfig; -1 leaves a line unused. + struct SdPreset { + const char* label; + SdPresetChip chip; + platform::SdBus bus; + int8_t clk, cmd, d0, d1, d2, d3, pwr; + bool editable; ///< Custom alone: a preset's lines are soldered, not the user's to set + }; + /// The wirings boards follow rather than one product each; Custom keeps whatever is in the fields. + static constexpr SdPreset kSdPresets[] = { + // The P4's slot 0 IOMUX pins, which every P4 board with a slot wires; GPIO45 drives the card's power switch where the board fits its gate resistor. + {"P4 SDMMC", SdPresetChip::P4, platform::SdBus::Sdmmc, 43, 44, 39, 40, 41, 42, 45, false}, + // The classic's slot 1 IOMUX pins in 1-bit mode, as the Olimex ESP32-Gateway wires them. + {"Classic SDMMC (1-bit)", SdPresetChip::Classic, platform::SdBus::Sdmmc, 14, 15, 2, -1, -1, -1, -1, false}, + // SPI mode as the QuinLED Dig-Octa wires it: SCK 14, MOSI 15, MISO 36, CS 16. + {"Classic SPI", SdPresetChip::Classic, platform::SdBus::Spi, 14, 15, 36, -1, -1, 16, -1, false}, + {"Custom", SdPresetChip::Any, platform::SdBus::Sdmmc, -1, -1, -1, -1, -1, -1, -1, true}, + }; + static constexpr uint8_t kSdPresetCount = sizeof(kSdPresets) / sizeof(kSdPresets[0]); + static constexpr uint8_t kPresetNone = 0xFF; ///< never a valid row, so the first build applies whatever the selection resolved to + + uint8_t presetSel_ = 0; ///< the selected row of presetOptions_ + const char* presetOptions_[kSdPresetCount] = {}; ///< the labels of the presets this build offers + uint8_t presetIndex_[kSdPresetCount] = {}; ///< each offered row's index in kSdPresets + uint8_t presetCount_ = 0; ///< how many presets this build offers + uint8_t presetApplied_ = kPresetNone; ///< the row whose map the pins were last written from + + /// Offer the presets this build can wire, re-pointing the selection by label and falling back to Custom. + void buildPresetOptions(); + /// Whether the selection is Custom, whose pins are the user's to edit. + bool presetIsCustom() const; + /// Write the selected preset's map into the pins; Custom writes nothing, so an edit survives switching to it. + void applyPreset(); + /// Select the preset whose map the pins already are, so a config holding a board's pins opens on its name. + void seedPresetFromPins(); + /// A preset's wiring in the shape the platform mounts. + static platform::SdPinConfig pinsOf(const SdPreset& p); + /// Mount on `pins`, or say they are unset; the status names the outcome. + void mountOn(const platform::SdPinConfig& pins); + /// The status of a mounted card: clear, or the SPI write warning: @xref{a-board-preset-names-a-soldered-wiring-as-ethernets-does}. + void showMounted(); + /// Pick up a finished format's result, a no-op while it still runs. + void finishFormat(); + /// Refresh the gauge fields from the mounted card's real usage. + void refreshGauge(); + /// The pin fields as platform::sdMount expects them. + platform::SdPinConfig pinConfig() const; + /// tick1s()'s actual body, named so its one blocking boundary reads as a single, well-understood site, the same shape refreshGauge() already gave the usage scan. + void pollCard(); +}; + +} // namespace mm diff --git a/src/core/system/FilesystemModule.cpp b/src/core/system/FilesystemModule.cpp index c4fc26562..2bfa97ff5 100644 --- a/src/core/system/FilesystemModule.cpp +++ b/src/core/system/FilesystemModule.cpp @@ -43,10 +43,8 @@ void collectEntry(const char* name, bool isDir, uint32_t, void* user) { bool isMountRoot(const char* path) { if (!path || path[0] != '/') return false; if (path[1] == '\0') return true; // "/" - if constexpr (!platform::hasSdCard) return false; - size_t n = std::strlen(path); - if (n > 1 && path[n - 1] == '/') n--; // a trailing slash names the same root - return n == 3 && path[1] == 's' && path[2] == 'd'; // "/sd" + // "/sd" or "/sd/", through the one home of the SD prefix + return platform::isSdPath(path) && (path[3] == '\0' || (path[3] == '/' && path[4] == '\0')); } } // namespace diff --git a/src/core/system/HttpServerModule.cpp b/src/core/system/HttpServerModule.cpp index b16c2530d..2f59c081e 100644 --- a/src/core/system/HttpServerModule.cpp +++ b/src/core/system/HttpServerModule.cpp @@ -307,7 +307,7 @@ bool HttpServerModule::anyWsClientOnAccessPoint() const MM_NONBLOCKING { return false; } -// A cheap scan of the request line's query for a "/sd" path, used only to size the Content-Length ceiling before the real path is decoded; parseFilePath is the actual guard, downstream. +// A cheap scan of the query for a "/sd" path, which only sizes the Content-Length ceiling; parseFilePath is the real guard, downstream. static bool requestNamesSdPath(const char* req) { const char* lineEnd = std::strstr(req, "\r\n"); const size_t lineLen = lineEnd ? static_cast(lineEnd - req) : std::strlen(req); @@ -924,7 +924,7 @@ void HttpServerModule::handleApplyState(platform::TcpConnection& conn, const cha sendResponse(conn, r.ok ? 200 : 400, "application/json", sink.overflowed() ? "{\"error\":\"out of memory\"}" : sink.data()); } -// The /sd leg of handleWriteFile: claims a transfer slot, opens the stream, writes whatever of the body already landed in the request buffer, then hands the rest to driveSdTransfers a tick at a time. +// The /sd leg of handleWriteFile: claims a slot, opens the stream, writes the body already buffered, and leaves the rest to driveSdTransfers. void HttpServerModule::handleWriteFileSd(platform::TcpConnection& conn, const char* path, const char* initialBody, size_t initialLen, size_t contentLen) { // Reject up front if it would not fit the card, rather than failing mid-write; free is taken conservatively, with no credit for an overwrite. Without hasSdCard, claimSdTransfer answers 503. diff --git a/src/core/system/HttpServerModule.h b/src/core/system/HttpServerModule.h index 00685fb72..01a0b97a3 100644 --- a/src/core/system/HttpServerModule.h +++ b/src/core/system/HttpServerModule.h @@ -42,7 +42,7 @@ class Scheduler; /// A mutation is `POST /api/control` with a module, control and value; `POST /api/modules` to create; `POST /api/modules/{name}/move` to reorder and `.../replace` to swap; `POST /api/reboot`; or `DELETE /api/modules/{name}`. /// /// The File Manager reads and writes through `GET|POST /api/file?path=`, and lists, creates and removes through `/api/dir?path=`. The path rides the query, so a filesystem operation carries its target in the request rather than in a stored control. -/// A board with an SD slot (`SdCardModule`) mounts it at `/sd`, so these same two routes reach the card too, shown as a folder next to the internal flash. +/// A board with an SD slot (`SdCardService`) mounts it at `/sd`, so these same two routes reach the card too, shown as a folder next to the internal flash. /// /// Every JSON response streams through a `JsonSink`, so there is no fixed-buffer ceiling and a tree of any size serializes correctly. /// diff --git a/src/core/system/SdCardModule.cpp b/src/core/system/SdCardModule.cpp deleted file mode 100644 index f661998cb..000000000 --- a/src/core/system/SdCardModule.cpp +++ /dev/null @@ -1,137 +0,0 @@ -/// @defgroup sd_card_impl SD Card implementation -/// The microSD slot's pins, mount and format button; the files it holds are browsed through the File Manager's existing routes, not a surface of this module's own. -/// Public surface and class layout live in SdCardModule.h. -/// @{ -#include "core/system/SdCardModule.h" - -#include "platform/platform.h" // sdMount/sdUnmount/sdFormat/sdCardUsed/sdCardTotal, hasSdCard - -#include // strcmp - -namespace mm { - -void SdCardModule::defineControls() { - // Unhidden: a board without hasSdCard never boots this module at all (module_types.cpp gates on it), so no board is left for a hidden pin to guard against. - controls_.addPin("clkPin", clkPin_); - controls_.addPin("cmdPin", cmdPin_); - controls_.addPin("d0Pin", d0Pin_); - controls_.addPin("d1Pin", d1Pin_); - controls_.addPin("d2Pin", d2Pin_); - controls_.addPin("d3Pin", d3Pin_); - controls_.addPin("pwrPin", pwrPin_); - - // Neither is hidden: there is no panel of this module's own, so the generic card is the only place either shows. Press-twice confirmed generically, the same contract every other confirm-gated button already has. - controls_.addButton("format", /*confirm=*/true); - // Usage gauge, bound only once mounted: an absent/unreadable card has nothing to show; KiB is in the name since the shared Progress control's own label assumes bytes. - if (mounted_ && totalKiB_ > 0) controls_.addProgress("card (KiB)", usedKiB_, totalKiB_, /*bytes=*/false); - MoonModule::defineControls(); -} - -bool SdCardModule::affectsPrepare(const char* name) const { - return std::strcmp(name, "clkPin") == 0 || std::strcmp(name, "cmdPin") == 0 || - std::strcmp(name, "d0Pin") == 0 || std::strcmp(name, "d1Pin") == 0 || - std::strcmp(name, "d2Pin") == 0 || std::strcmp(name, "d3Pin") == 0 || - std::strcmp(name, "pwrPin") == 0; -} - -namespace { -bool samePins(const platform::SdPinConfig& a, const platform::SdPinConfig& b) { - return a.clk == b.clk && a.cmd == b.cmd && a.d0 == b.d0 && - a.d1 == b.d1 && a.d2 == b.d2 && a.d3 == b.d3 && a.pwr == b.pwr; -} -} // namespace - -void SdCardModule::prepare() { - platform::SdPinConfig pins = pinConfig(); - // prepare() runs on any affectsPrepare change anywhere in the tree, so most calls are about some other module entirely; skip the unmount/remount round trip when this instance's own wiring has not actually changed, rather than tearing down a mount (and any stream open on it) for nothing. - if (mounted_ && samePins(pins, mountedPins_)) return; - - const bool wasMounted = mounted_; - if (mounted_) { - if (!platform::sdUnmount()) { setStatus("busy: transfer in progress", Severity::Warning); return; } - mounted_ = false; - } - - if (clkPin_ < 0 || cmdPin_ < 0 || d0Pin_ < 0) { - // Unset is this module's idle state (nothing is wrong yet, nothing was attempted), not a warning. - setStatus("set the SD card pins first", Severity::Status); - } else { - mounted_ = platform::sdMount(pins); - if (mounted_) { mountedPins_ = pins; refreshGauge(); clearStatus(); } - else setStatus("mount failed: check the card is inserted and formatted FAT32", Severity::Error); - } - // defineControls() ran with the OLD mounted_, so its "mounted_ && totalKiB_ > 0" gate above saw a stale value either way the state just moved; one rebuild picks up or drops the gauge. - if (mounted_ != wasMounted) rebuildControls(); -} - -void SdCardModule::release() { - if (mounted_ && platform::sdUnmount()) mounted_ = false; - MoonModule::release(); -} - -void SdCardModule::onControlChanged(const char* controlName) { - if (std::strcmp(controlName, "format") != 0) return; - if (!mounted_) { setStatus("mount a card before formatting", Severity::Warning); markDirty(); return; } - if (platform::sdCardBusy()) { setStatus("busy: transfer in progress", Severity::Warning); markDirty(); return; } - if (!platform::sdFormat()) { setStatus("format failed", Severity::Error); markDirty(); return; } - refreshGauge(); - clearStatus(); - markDirty(); // push the fresh usage figures to the UI -} - -void SdCardModule::tick1s() MM_NONBLOCKING { - pollCard(); -} - -void SdCardModule::pollCard() { - if (mounted_) { - // A cheap status probe, once a second: cheap enough to poll, unlike a full mount. A card pulled mid-transfer leaves its stream to the existing stall-timeout/I/O-failure cleanup, so sdUnmount's busy guard is retried here rather than bypassed. - if (!platform::sdCardPresent()) { - if (!platform::sdUnmount()) return; - mounted_ = false; - setStatus("card removed", Severity::Warning); - rebuildControls(); - return; - } - // Once a minute, not once a second: the same reasoning as FileManagerModule's own scan. - if (++secondsSinceScan_ < 60) return; - secondsSinceScan_ = 0; - refreshGauge(); - return; - } - - // Unmounted with pins set: retry periodically, so a reinsert is noticed without a pin touch or reboot. Slower than the presence poll above, since a retry drives real GPIO/power sequencing rather than a status probe. - if (clkPin_ < 0 || cmdPin_ < 0 || d0Pin_ < 0) return; - if (++secondsSinceRetry_ < kMountRetrySeconds) return; - secondsSinceRetry_ = 0; - platform::SdPinConfig pins = pinConfig(); - if (!platform::sdMount(pins)) return; - mounted_ = true; - mountedPins_ = pins; - refreshGauge(); - clearStatus(); - rebuildControls(); -} - -void SdCardModule::refreshGauge() { - totalKiB_ = static_cast(platform::sdCardTotal() / 1024); - usedKiB_ = static_cast(platform::sdCardUsed() / 1024); -} - -platform::SdPinConfig SdCardModule::pinConfig() const { - platform::SdPinConfig pins{}; - pins.clk = clkPin_; - pins.cmd = cmdPin_; - pins.d0 = d0Pin_; - pins.d1 = d1Pin_; - pins.d2 = d2Pin_; - pins.d3 = d3Pin_; - pins.pwr = pwrPin_; - return pins; -} - -// mkdir/delete/read/write are the File Manager's existing HTTP endpoints, against paths under /sd; this module holds no op state and persists nothing itself. - -} // namespace mm - -/// @} diff --git a/src/core/system/SdCardModule.h b/src/core/system/SdCardModule.h deleted file mode 100644 index 8f73cddf9..000000000 --- a/src/core/system/SdCardModule.h +++ /dev/null @@ -1,83 +0,0 @@ -#pragma once - -#include "core/module/MoonModule.h" -#include "platform/platform.h" // SdPinConfig - -#include - -namespace mm { - -/// A board's microSD slot: pins, a usage gauge and a format button. Its files appear under `/sd`, within the existing File Manager tree. -/// -/// A Services child, added only to a board's catalog entry, the way AudioService's mic pins are. -/// @card SdCardModule.png -/// -/// @moreinfo -/// -/// ## The pins are runtime configuration, like AudioService's mic -/// -/// `clkPin`/`cmdPin`/`d0Pin`-`d3Pin`/`pwrPin` default to -1, unset; a board's catalog entry sets the ones it wires. -/// `platform::hasSdCard` reports whether this chip has SDMMC hardware at all, and hides the pin controls where it does not. -/// `d1Pin`-`d3Pin` are optional: leaving them unset drops the bus to 1-bit mode instead of 4-bit. -/// -/// ## The mount re-attempts on every pin change, like the bus it rides -/// -/// `prepare()`, not `setup()`: a pin edit unmounts and remounts on the new wiring, the same live-reconfiguration contract I2cBusModule's sda/scl have. -/// `prepare()` runs tree-wide on any `affectsPrepare` change, so a stored copy of the mounted pins lets an unrelated one return without touching the card at all. -/// A pull or a reinsert needs no pin touch or reboot. `tick1s()` polls `platform::sdCardPresent()` every second while mounted, and retries `platform::sdMount()` every few seconds while not; either direction is noticed on its own. -/// An unmount or a format while a stream is open is refused rather than run underneath it: the status reads "busy: transfer in progress" instead. A removal mid-transfer waits for the existing stall timeout to close that stream first, before its own unmount can succeed. -/// -/// ## Pins, gauge and format are this module's whole surface -/// -/// The card mounts at `/sd`, so the existing File Manager tree already shows it; this module owns only pins, the gauge and format. -/// -/// ## Why KiB, not the shared Progress control's bytes -/// -/// A real card's byte count overruns the `uint32_t` the shared Progress control carries past 4 GiB. -/// `platform::sdCardUsed`/`Total` are `uint64_t` for exactly that, and this module narrows to KiB before handing the gauge its numbers, well inside range for any card size in practice. -/// -/// Prior art: I2cBusModule's live pin reconfiguration; FileManagerModule's usage gauge. -class SdCardModule : public MoonModule { -public: - /// A Services child, same as AudioService: the catalog entry attaches it under "Services". - ModuleRole role() const MM_NONBLOCKING override { return ModuleRole::Service; } - /// Declare the pins, the usage gauge and the format button. - void defineControls() override; - /// A pin edit remounts the card on its new wiring, same as the bus it rides. - bool affectsPrepare(const char* name) const override; - /// Mount the card, if this chip has the hardware and the pins are set. - void prepare() override; - /// Unmount this instance's card, if it mounted one. - void release() override; - /// Run a format when the button is pressed. - void onControlChanged(const char* controlName) override; - /// Poll for a pull or a reinsert, and refresh the usage gauge, all on a slow cadence. - void tick1s() MM_NONBLOCKING override; - -private: - bool mounted_ = false; ///< true once platform::sdMount() has succeeded - platform::SdPinConfig mountedPins_{}; ///< the pins mounted_ was last mounted with, so an unrelated prepare() is a no-op - uint32_t usedKiB_ = 0; ///< card usage, in KiB: @xref{why-kib-not-the-shared-progress-controls-bytes} - uint32_t totalKiB_ = 0; ///< the card's capacity, in KiB, read once on mount - uint8_t secondsSinceScan_ = 0; ///< paces the usage scan - uint8_t secondsSinceRetry_ = 0; ///< paces the unmounted retry-mount attempt below - static constexpr uint8_t kMountRetrySeconds = 5; ///< slower than the 1s presence poll: a retry drives real GPIO/power sequencing, a probe does not - - // Unused by default, so a chip with no card wired claims no GPIO; a board's catalog entry sets the ones it wires. - int8_t clkPin_ = -1; ///< SDMMC clock - int8_t cmdPin_ = -1; ///< SDMMC command line - int8_t d0Pin_ = -1; ///< SDMMC data line 0, required - int8_t d1Pin_ = -1; ///< SDMMC data line 1; unset alongside d2/d3 drops the bus to 1-bit mode - int8_t d2Pin_ = -1; ///< SDMMC data line 2 - int8_t d3Pin_ = -1; ///< SDMMC data line 3 - int8_t pwrPin_ = -1; ///< a slot's card-power switch, or -1 where the slot has none - - /// Refresh the gauge fields from the mounted card's real usage. - void refreshGauge(); - /// The pin fields as platform::sdMount expects them; shared by prepare() and pollCard()'s retry path. - platform::SdPinConfig pinConfig() const; - /// tick1s()'s actual body, named so its one blocking boundary reads as a single, well-understood site, the same shape refreshGauge() already gave the usage scan. - void pollCard(); -}; - -} // namespace mm diff --git a/src/module_types.cpp b/src/module_types.cpp index 5569eb928..d37cc5afb 100644 --- a/src/module_types.cpp +++ b/src/module_types.cpp @@ -188,7 +188,7 @@ #include "core/services/InfraredService.h" #include "core/services/MoonLiveService.h" #include "core/system/FileManagerModule.h" -#include "core/system/SdCardModule.h" +#include "core/services/SdCardService.h" #include "core/system/FirmwareUpdateModule.h" #include "core/system/MoonCloudModule.h" #include "core/system/MoonStatsModule.h" @@ -368,7 +368,7 @@ void mm::registerModuleTypes() { mm::ModuleFactory::registerType("MoonLiveService", "core/services.md#moonliveservice"); mm::ModuleFactory::registerType("FileManagerModule", "core/system.md#file-manager"); // Unlike WiFi/Ethernet, a board without hasSdCard can never gain the capability without a rebuild, so the type is not left addable for nothing. - if constexpr (mm::platform::hasSdCard) mm::ModuleFactory::registerType("SdCardModule", "core/system.md#sd-card"); + if constexpr (mm::platform::hasSdCard) mm::ModuleFactory::registerType("SdCardService", "core/services.md#sd-card"); mm::ModuleFactory::registerType("FirmwareUpdateModule", "core/system.md#firmware-update"); mm::ModuleFactory::registerType("MoonCloudModule", "core/system.md#mooncloud"); mm::ModuleFactory::registerType("MoonStatsModule", "core/system.md#stats"); @@ -403,7 +403,7 @@ constexpr BootModule kBootModules[] = { {"SystemModule", "I2cBusModule", nullptr, true}, {"SystemModule", "PinsModule", nullptr, true}, {"Services", "AudioService", nullptr, true}, - {"Services", "SdCardModule", nullptr, mm::platform::hasSdCard}, + {"Services", "SdCardService", nullptr, mm::platform::hasSdCard}, {"NetworkModule", "EthernetModule", nullptr, kEthernetCard}, {"NetworkModule", "WiFiModule", nullptr, mm::platform::hasWiFi}, {"NetworkModule", "AccessPointModule", nullptr, mm::platform::hasWiFi}, @@ -424,7 +424,7 @@ mm::MoonModule* mm::createTopLevel(const char* typeName) { for (const BootModule& b : kBootModules) { if (!b.built) continue; // One extra veto: the table's own flag matches hasSdCard, but booting by default is a separate call desktop opts out of. - if (std::strcmp(b.type, "SdCardModule") == 0 && !mm::platform::sdCardBootsByDefault()) continue; + if (std::strcmp(b.type, "SdCardService") == 0 && !mm::platform::sdCardBootsByDefault()) continue; if (!b.parentType) { if (std::strcmp(b.type, typeName) == 0) top->setName(b.name); continue; diff --git a/src/platform/desktop/platform_config.h b/src/platform/desktop/platform_config.h index 715084dec..10f3586f3 100644 --- a/src/platform/desktop/platform_config.h +++ b/src/platform/desktop/platform_config.h @@ -132,6 +132,8 @@ constexpr bool hasImprov = false; /// True: unlike every other board gate here, the mount/remount/format logic under test needs a real host path, not a stub. constexpr bool hasSdCard = true; +/// True: no slot is wired, so SdCardService offers every board preset, for the tests to exercise them. +constexpr bool previewsSdPresets = true; /// @} diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index e88551dd8..74714f199 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -873,6 +873,11 @@ bool sdMounted_ = false; int sdStreamsOpen_ = 0; // True by default: a host has no real slot to pull a card from, so a test flips this to model one going missing or coming back. bool sdCardPresentForTest_ = true; +bool sdCardHasFilesystemForTest_ = true; +bool sdCardReadableForTest_ = true; +bool sdFormatHeldForTest_ = false; +SdMountError sdMountError_ = SdMountError::None; +SdFormatState sdFormatState_ = SdFormatState::Idle; // Map an API path onto the root, normalize it, and reject one that escapes; an empty result reads as failure, which callers already handle. std::filesystem::path toFsPath(const char* path) { @@ -1137,38 +1142,62 @@ uint64_t sdCardTotalForTest_ = 1024ULL * 1024 * 1024; } bool sdMount(const SdPinConfig& pins) { + if (sdFormatState_ == SdFormatState::Running) return false; if (sdMounted_) return true; - if (pins.clk < 0 || pins.cmd < 0 || pins.d0 < 0) return false; - if (!sdCardPresentForTest_) return false; // the same way a real empty slot fails to answer + if (pins.clk < 0 || pins.cmd < 0 || pins.d0 < 0 || (pins.bus == SdBus::Spi && pins.d3 < 0)) { sdMountError_ = SdMountError::Pins; return false; } + if (!sdCardPresentForTest_) { sdMountError_ = SdMountError::NoCard; return false; } // a real empty slot fails to answer + if (!sdCardReadableForTest_) { sdMountError_ = SdMountError::Unreadable; return false; } + if (!sdCardHasFilesystemForTest_) { sdMountError_ = SdMountError::NoFilesystem; return false; } + sdMountError_ = SdMountError::None; std::error_code ec; std::filesystem::create_directories(fsRoot_ / "sd", ec); if (ec) return false; + std::filesystem::remove_all(fsRoot_ / "sd" / ".tmp", ec); // the ESP32's staging folder, which a mount empties sdMounted_ = true; return true; } bool sdCardMounted() { return sdMounted_; } +SdMountError sdMountError() { return sdMountError_; } + bool sdCardPresent() { return sdMounted_ && sdCardPresentForTest_; } -bool sdCardBusy() { return sdStreamsOpen_ > 0; } +bool sdCardBusy() { return sdStreamsOpen_ > 0 || sdFormatState_ == SdFormatState::Running; } bool sdUnmount() { + if (sdFormatState_ == SdFormatState::Running) return false; if (!sdMounted_) return true; if (sdStreamsOpen_ > 0) return false; // the ESP32 conflict this fiction still models: a stream mid-transfer sdMounted_ = false; return true; } -bool sdFormat() { - if (!sdMounted_) return false; - if (sdStreamsOpen_ > 0) return false; +namespace { +// The work the ESP32 does on its format task, run here in the call unless a test holds it. +void completeSdFormat() { + // A card without a filesystem is formatted and mounted in one step, as the ESP32's formatting mount does. + if (sdMountError_ == SdMountError::NoFilesystem) { sdCardHasFilesystemForTest_ = true; sdMountError_ = SdMountError::None; } std::error_code ec; std::filesystem::remove_all(fsRoot_ / "sd", ec); std::filesystem::create_directories(fsRoot_ / "sd", ec); - return !ec; + sdMounted_ = !ec; + sdFormatState_ = ec ? SdFormatState::Failed : SdFormatState::Succeeded; +} +} // namespace + +bool sdFormat() { + if (sdCardBusy()) return false; + const bool unformatted = !sdMounted_ && sdMountError_ == SdMountError::NoFilesystem && sdCardPresentForTest_; + if (!sdMounted_ && !unformatted) return false; + sdMounted_ = false; + sdFormatState_ = SdFormatState::Running; + if (!sdFormatHeldForTest_) completeSdFormat(); + return true; } +SdFormatState sdFormatState() { return sdFormatState_; } + uint64_t sdCardUsed() { if (!sdMounted_) return 0; std::error_code ec; @@ -1185,7 +1214,7 @@ uint64_t sdCardUsed() { uint64_t sdCardTotal() { return sdMounted_ ? sdCardTotalForTest_ : 0; } namespace { -// Off by default: hasSdCard is unconditionally true on desktop for test coverage, but that must not boot SdCardModule into an unrelated session's tree; a test opts it in by name. +// Off by default: hasSdCard is unconditionally true on desktop for test coverage, but that must not boot SdCardService into an unrelated session's tree; a test opts it in by name. bool sdCardBootsByDefault_ = false; } @@ -1194,9 +1223,15 @@ bool sdCardBootsByDefault() { return sdCardBootsByDefault_; } #ifndef ESP_PLATFORM /// Shrink the fictional card, so a test can reach the "not enough space" path without writing a real gigabyte. void setTestSdCardTotal(uint64_t bytes) { sdCardTotalForTest_ = bytes; } -/// Opt SdCardModule's default boot-wiring in or out for one test's scope. +/// Opt SdCardService's default boot-wiring in or out for one test's scope. void setTestSdCardBootsByDefault(bool enabled) { sdCardBootsByDefault_ = enabled; } void setTestSdCardPresent(bool present) { sdCardPresentForTest_ = present; } +void setTestSdCardHasFilesystem(bool hasFilesystem) { sdCardHasFilesystemForTest_ = hasFilesystem; } +void setTestSdCardReadable(bool readable) { sdCardReadableForTest_ = readable; } +void setTestSdFormatHeld(bool held) { + sdFormatHeldForTest_ = held; + if (!held && sdFormatState_ == SdFormatState::Running) completeSdFormat(); +} #endif // Network stubs (desktop has no WiFi/Ethernet hardware) diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index daeadd16a..dccb7952b 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -21,10 +21,9 @@ /// The EMAC's data pads are silicon's choice, so one list serves both the driver init and the pin map. /// A pad nobody declares reads as free while the MAC drives it: an LED lane on GPIO 10 corrupted every frame sent, with the link still reporting 1 Gb. /// -/// ## Only ship it where there is flash to spare +/// ## An image without the room turns it off /// -/// `CONFIG_SOC_SDMMC_HOST_SUPPORTED` names every chip with the host, classic ESP32 included, whose fixed pins (2, 4, 12, 13, 14, 15) make it impractical here anyway. -/// `CONFIG_MM_SD_CARD` is the second, project-level gate: set only by the boards with flash to spare (P4, S31, the 16 MB S3), the same two-gate shape `CONFIG_MM_USB_MIDI_HOST` already uses. +/// `CONFIG_SOC_SDMMC_HOST_SUPPORTED` names every chip with the SD host, and `CONFIG_MM_SD_CARD` is the second, project-level gate, the same two-gate shape `CONFIG_MM_USB_MIDI_HOST` uses. It is on by default and off on an image whose boards have no slot and whose flash is tight, such as the S3-Zero. #include "sdkconfig.h" @@ -282,12 +281,14 @@ constexpr bool hasOta = true; /// True on every ESP32: the serial RPC channel is always there, so the listener always runs. constexpr bool hasImprov = true; -/// Chip capability AND flash to spare: @xref{only-ship-it-where-there-is-flash-to-spare|why the second gate}. +/// Chip capability AND room in the image: @xref{an-image-without-the-room-turns-it-off|why the second gate}. #if defined(CONFIG_SOC_SDMMC_HOST_SUPPORTED) && defined(CONFIG_MM_SD_CARD) constexpr bool hasSdCard = true; #else constexpr bool hasSdCard = false; #endif +/// False: SdCardService offers only the board presets this chip can wire. +constexpr bool previewsSdPresets = false; /// @} diff --git a/src/platform/esp32/platform_esp32_sdmmc.cpp b/src/platform/esp32/platform_esp32_sdmmc.cpp index 2627aa47d..cfa7329d4 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.cpp +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -8,35 +8,28 @@ /// ## The pins are a runtime argument, not a board constant /// /// `sdMount()` takes an `SdPinConfig`, so no board gets its own `#ifdef`; `platform::hasSdCard` gates only on whether this chip has the hardware at all. -/// The bus width follows from which data lines are set: `d1`/`d2`/`d3` unset drops it to 1-bit mode, all three set is 4-bit. /// /// ## Built field by field /// -/// `SDMMC_SLOT_CONFIG_DEFAULT()` hardwires `d4` to a fixed GPIO, an 8-bit-mode line this driver never uses in 1- or 4-bit mode. -/// On the Waveshare P4-ETH that pin is the card's own power switch, so the macro is skipped and the slot config is built field by field instead. +/// `SDMMC_SLOT_CONFIG_DEFAULT()` hardwires `d4`, an unused 8-bit line, to the GPIO that powers the card on the Waveshare P4-ETH. So the slot config is built field by field, and sets the macro's `SDMMC_SLOT_NO_CD`/`NO_WP` itself, since zero would read GPIO0 as write-protect. /// /// ## The power switch is driven before the mount /// /// `pwr`, where a board wires one, is a plain GPIO output set low (P-channel high-side-switch convention) before `esp_vfs_fat_sdmmc_mount`, and high again on unmount or a failed mount. /// -/// ## Internal pullups on CMD/D0-D3 +/// ## A format runs on its own task /// -/// `SDMMC_SLOT_FLAG_INTERNAL_PULLUP` is set unconditionally, matching the reference wiring; harmless where the lines already have external pull-ups. +/// Writing a large card's FAT tables outlasts the render loop's task watchdog, whose panic leaves a half-written volume. So the format runs on a one-shot unwatched task, with the card unmounted until it publishes its result. /// -/// ## The chip's own IO power domain, where one exists +/// ## A write lands in a staging folder first /// -/// A `SOC_SDMMC_IO_POWER_EXTERNAL` chip's SDMMC IO lines are powered by an on-chip LDO the driver must turn on itself; this is a chip fact, not a board wiring choice. -/// -/// ## CD and WP read as GPIO0 unless told otherwise -/// -/// A zero-initialized slot config leaves `.cd`/`.wp` at GPIO0, which the driver then reads as a real write-protect signal and rejects every write. -/// Building the config field by field means the macro's own `SDMMC_SLOT_NO_CD`/`NO_WP` defaults are skipped too, so both are set explicitly here. +/// Every write goes to `/sd/.tmp` and is renamed into place once whole, so a failed upload never leaves a partial file under its real name. A card that stops answering mid-write cannot delete its temporary file either, so a mount empties that one folder rather than walking the whole card. /// /// ## Drive 0 is assumed, not queried /// /// File access below is raw FatFs (`f_open`/`f_read`/`f_write`), not stdio through the VFS mount, to drop stdio's share of the flash cost. -/// `esp_vfs_fat_sdmmc_mount` assigns the FatFs drive number dynamically and never returns it, so there is no public call to ask which one this mount got. -/// Drive 0 is assumed because nothing else in this firmware registers a FatFs volume (LittleFS is a separate component); if that ever changes, this assumption needs revisiting. +/// +/// `esp_vfs_fat_sdmmc_mount` assigns the FatFs drive number and never returns it, so drive 0 is assumed: nothing else in this firmware registers a FatFs volume, LittleFS being a separate component. /// @{ #include "platform_esp32_sdmmc.h" @@ -45,16 +38,25 @@ #include "esp_log.h" #include "driver/gpio.h" +#include +#include #include // snprintf only; file I/O below is raw FatFs, not stdio #include #if defined(CONFIG_SOC_SDMMC_HOST_SUPPORTED) && defined(CONFIG_MM_SD_CARD) #define MM_HAS_SDMMC 1 #include "driver/sdmmc_host.h" +#include "driver/sdspi_host.h" +#include "driver/spi_master.h" #include "sdmmc_cmd.h" #include "esp_vfs_fat.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #include "ff.h" +#include "diskio_impl.h" +#include "diskio_sdmmc.h" #include "soc/soc_caps.h" +#include "soc/sdmmc_pins.h" #if SOC_SDMMC_IO_POWER_EXTERNAL #include "sd_pwr_ctrl_by_on_chip_ldo.h" #endif @@ -70,9 +72,16 @@ static constexpr const char* SD_MOUNT_POINT = "/sdcard"; // the VFS registrati static constexpr const char* SD_DRIVE = "0:"; static sdmmc_card_t* g_card = nullptr; -static bool g_mounted = false; +static std::atomic g_mounted{false}; // atomic, since the format task publishes it static int g_streamsOpen = 0; // every open read/write stream, counted so unmount/format can refuse under one +static SdMountError g_mountError = SdMountError::None; // why the last mount failed +static std::atomic g_formatState{SdFormatState::Idle}; +static bool g_formatUnmounted = false; // whether the running format mounts a card that had no filesystem; set before the task starts +static SdPinConfig g_lastPins{}; // what a format of a filesystem-less card mounts on static int8_t g_pwrGpio = -1; // so unmount powers back down what mount powered up +static bool g_spiBus = false; // whether the mount brought up the SPI bus, which unmount frees +// SPI3 where the chip has it: the S3's W5500 Ethernet holds SPI2. +static constexpr spi_host_device_t kSdSpiHost = SOC_SPI_PERIPH_NUM > 2 ? SPI3_HOST : SPI2_HOST; // Every call below runs to completion before the next starts, so one shared FIL off the stack serves them all; the kept-open streams further down heap-allocate their own. static FIL g_fil; #if SOC_SDMMC_IO_POWER_EXTERNAL @@ -94,7 +103,7 @@ static bool sdTranslate(const char* apiPath, char* out, size_t outLen) { bool sdCardMounted() { return g_mounted; } // A static scratch sector, not a stack buffer: keeps sdCardPresent's own frame off the 512-byte stack-frame rule, and this runs on one thread only. -static uint8_t g_presenceProbe[512]; +alignas(4) static uint8_t g_presenceProbe[512]; // word-aligned, or sdmmc_read_sectors bounces every probe through a heap buffer // A 1-sector read, not SEND_STATUS (CMD13): CMD13 panics this chip's SDMMC host on this ESP-IDF build; a block read is the primitive real file access already proves safe. bool sdCardPresent() { @@ -103,7 +112,7 @@ bool sdCardPresent() { return sdmmc_read_sectors(g_card, g_presenceProbe, 0, 1) == ESP_OK; } -bool sdCardBusy() { return g_streamsOpen > 0; } +bool sdCardBusy() { return g_streamsOpen > 0 || g_formatState == SdFormatState::Running; } bool sdCardBootsByDefault() { return hasSdCard; } @@ -111,95 +120,252 @@ bool sdCardBootsByDefault() { return hasSdCard; } static bool validOutputPin(int8_t pin) { return pin >= 0 && GPIO_IS_VALID_OUTPUT_GPIO(pin); } +/// SPI's MISO only reads, so an input-only pin such as the classic's GPIO36 serves. +static bool validInputPin(int8_t pin) { + return pin >= 0 && GPIO_IS_VALID_GPIO(pin); +} -bool sdMount(const SdPinConfig& pins) { - if (g_mounted) return true; - if (!validOutputPin(pins.clk) || !validOutputPin(pins.cmd) || !validOutputPin(pins.d0)) return false; - const bool fourBit = pins.d1 >= 0 && pins.d2 >= 0 && pins.d3 >= 0; - if (fourBit && (!validOutputPin(pins.d1) || !validOutputPin(pins.d2) || !validOutputPin(pins.d3))) return false; +namespace { +// The data lines for 4-bit mode, all three set; otherwise the bus runs 1-bit. +bool fourBitPins(const SdPinConfig& pins) { return pins.d1 >= 0 && pins.d2 >= 0 && pins.d3 >= 0; } + +// The SD bus drives D0, and D1-D3 too in 4-bit mode. +bool usableSdBusPins(const SdPinConfig& pins) { + if (!validOutputPin(pins.d0)) return false; + return !fourBitPins(pins) || (validOutputPin(pins.d1) && validOutputPin(pins.d2) && validOutputPin(pins.d3)); +} + +// Every pin the mount drives is an output-capable GPIO, and SPI's MISO one it can read; an unset power switch passes. +bool usablePins(const SdPinConfig& pins) { + if (!validOutputPin(pins.clk) || !validOutputPin(pins.cmd)) return false; if (pins.pwr >= 0 && !validOutputPin(pins.pwr)) return false; + if (pins.bus == SdBus::Spi) return validInputPin(pins.d0) && validOutputPin(pins.d3); + return usableSdBusPins(pins); +} + +#ifdef SDMMC_SLOT0_IOMUX_PIN_NUM_CLK +// Slot 0's own IOMUX pins, which the P4's slot 0 alone reaches: its default slot 1 routes them through the GPIO matrix instead. +bool onSlot0Iomux(const SdPinConfig& pins) { + if (pins.clk != SDMMC_SLOT0_IOMUX_PIN_NUM_CLK || pins.cmd != SDMMC_SLOT0_IOMUX_PIN_NUM_CMD || pins.d0 != SDMMC_SLOT0_IOMUX_PIN_NUM_D0) return false; + return !fourBitPins(pins) || (pins.d1 == SDMMC_SLOT0_IOMUX_PIN_NUM_D1 && pins.d2 == SDMMC_SLOT0_IOMUX_PIN_NUM_D2 && pins.d3 == SDMMC_SLOT0_IOMUX_PIN_NUM_D3); +} +#endif + +// Powered before anything talks to the slot, where this board wires a switch: @xref{the-power-switch-is-driven-before-the-mount}. +void powerSlot(int8_t pwr) { + g_pwrGpio = pwr; + if (pwr < 0) return; + gpio_config_t pwrCfg = {}; + pwrCfg.pin_bit_mask = 1ULL << pwr; + pwrCfg.mode = GPIO_MODE_OUTPUT; + gpio_config(&pwrCfg); + gpio_set_level(static_cast(pwr), 0); // LOW = enabled +} + +// Field by field, not SDMMC_SLOT_CONFIG_DEFAULT(): @xref{built-field-by-field}. +sdmmc_slot_config_t slotConfig(const SdPinConfig& pins) { + sdmmc_slot_config_t slotCfg = {}; + slotCfg.clk = static_cast(pins.clk); + slotCfg.cmd = static_cast(pins.cmd); + slotCfg.d0 = static_cast(pins.d0); + const bool fourBit = fourBitPins(pins); + if (fourBit) { + slotCfg.d1 = static_cast(pins.d1); + slotCfg.d2 = static_cast(pins.d2); + slotCfg.d3 = static_cast(pins.d3); + } + slotCfg.width = fourBit ? 4 : 1; + slotCfg.cd = SDMMC_SLOT_NO_CD; // @xref{built-field-by-field} + slotCfg.wp = SDMMC_SLOT_NO_WP; + slotCfg.flags |= SDMMC_SLOT_FLAG_INTERNAL_PULLUP; // the reference wiring's, harmless over external pull-ups + return slotCfg; +} + +// The card on the SPI bus, its CS on D3 per the SD spec's SPI pin mapping. +sdspi_device_config_t spiDevice(const SdPinConfig& pins) { + sdspi_device_config_t dev = SDSPI_DEVICE_CONFIG_DEFAULT(); + dev.host_id = kSdSpiHost; + dev.gpio_cs = static_cast(pins.d3); + return dev; +} + +// The host the wiring's bus calls for, on slot 0 where the pins are that slot's own. +sdmmc_host_t hostFor(const SdPinConfig& pins) { + sdmmc_host_t host = SDMMC_HOST_DEFAULT(); + if (pins.bus == SdBus::Spi) { + host = SDSPI_HOST_DEFAULT(); + host.slot = kSdSpiHost; + } +#ifdef SDMMC_SLOT0_IOMUX_PIN_NUM_CLK + else if (onSlot0Iomux(pins)) host.slot = SDMMC_HOST_SLOT_0; +#endif + return host; +} + +// Bring up the SPI bus the card sits on, or nothing on the SD bus; false when the bus will not start. +bool startBus(const SdPinConfig& pins) { + if (pins.bus != SdBus::Spi) return true; + spi_bus_config_t bus = {}; + bus.mosi_io_num = pins.cmd; + bus.miso_io_num = pins.d0; + bus.sclk_io_num = pins.clk; + bus.quadwp_io_num = -1; + bus.quadhd_io_num = -1; + bus.max_transfer_sz = 4000; // the IDF SD SPI example's own figure + g_spiBus = spi_bus_initialize(kSdSpiHost, &bus, SDSPI_DEFAULT_DMA) == ESP_OK; + return g_spiBus; +} + +void stopBus() { + if (g_spiBus) spi_bus_free(kSdSpiHost); + g_spiBus = false; +} + +// Initialize the card a failed SD-bus mount left, for fatVerdict; false when it does not answer. +bool initCard(sdmmc_host_t& host, const SdPinConfig& pins, sdmmc_card_t& card) { + const sdmmc_slot_config_t slotCfg = slotConfig(pins); + return host.init() == ESP_OK && sdmmc_host_init_slot(host.slot, &slotCfg) == ESP_OK && sdmmc_card_init(&host, &card) == ESP_OK; +} +} // namespace + +// Release what a mount took, the chip's LDO, the power switch and the SPI bus, on every exit that ends unmounted. +static void powerDown() { +#if SOC_SDMMC_IO_POWER_EXTERNAL + if (g_pwrCtrl) { sd_pwr_ctrl_del_on_chip_ldo(g_pwrCtrl); g_pwrCtrl = nullptr; } +#endif + if (g_pwrGpio >= 0) gpio_set_level(static_cast(g_pwrGpio), 1); + stopBus(); +} - // Powered before anything talks to the slot, where this board wires a switch: @xref{the-power-switch-is-driven-before-the-mount}. - if (pins.pwr >= 0) { - gpio_config_t pwrCfg = {}; - pwrCfg.pin_bit_mask = 1ULL << pins.pwr; - pwrCfg.mode = GPIO_MODE_OUTPUT; - gpio_config(&pwrCfg); - gpio_set_level(static_cast(pins.pwr), 0); // LOW = enabled +// FatFs's own verdict on a card whose mount failed with ESP_FAIL, which IDF returns for an unreadable card and for one without a filesystem alike. +static FRESULT fatVerdict(sdmmc_host_t host, const SdPinConfig& pins) { + FRESULT res = FR_NOT_READY; + FATFS* fs = new (std::nothrow) FATFS; + if (!fs) return res; + sdmmc_card_t card = {}; + BYTE pdrv = FF_DRV_NOT_USED; + if (initCard(host, pins, card) && ff_diskio_get_drive(&pdrv) == ESP_OK && pdrv != FF_DRV_NOT_USED) { + ff_diskio_register_sdmmc(pdrv, &card); + const char drv[3] = {static_cast('0' + pdrv), ':', 0}; + res = f_mount(fs, drv, 1); + f_mount(nullptr, drv, 0); + ff_diskio_unregister(pdrv); } - g_pwrGpio = pins.pwr; + if (host.flags & SDMMC_HOST_FLAG_DEINIT_ARG) host.deinit_p(host.slot); + else host.deinit(); + delete fs; + return res; +} + +static void clearStaging(); + +// `format` writes a FAT filesystem when the card has none, which only the user's format asks for. +static bool mountCard(const SdPinConfig& pins, bool format) { + if (g_mounted) return true; + g_lastPins = pins; + if (!usablePins(pins)) { g_mountError = SdMountError::Pins; return false; } + powerSlot(pins.pwr); esp_vfs_fat_sdmmc_mount_config_t mountCfg = {}; - mountCfg.format_if_mount_failed = false; // a blank/foreign card stays untouched + mountCfg.format_if_mount_failed = format; // true only on the format task's mount: a blank or foreign card stays untouched otherwise mountCfg.max_files = 5; - mountCfg.allocation_unit_size = 16 * 1024; + mountCfg.allocation_unit_size = 32 * 1024; // the SD Association's FAT32 cluster for 1 to 32 GB cards - sdmmc_host_t host = SDMMC_HOST_DEFAULT(); + sdmmc_host_t host = hostFor(pins); + if (!startBus(pins)) { powerDown(); g_mountError = SdMountError::Pins; return false; } #if SOC_SDMMC_IO_POWER_EXTERNAL + // This chip powers its SDMMC IO lines from an on-chip LDO the driver turns on itself, a chip fact rather than board wiring. sd_pwr_ctrl_ldo_config_t ldoCfg = {}; ldoCfg.ldo_chan_id = 4; // VO4: the P4 TRM's own reference design wires this LDO channel to VDD_IO_5, the SD card's switchable supply. const esp_err_t ldoErr = sd_pwr_ctrl_new_on_chip_ldo(&ldoCfg, &g_pwrCtrl); if (ldoErr != ESP_OK) { ESP_LOGE(SD_TAG, "SD LDO power control failed: %s", esp_err_to_name(ldoErr)); - if (g_pwrGpio >= 0) gpio_set_level(static_cast(g_pwrGpio), 1); + powerDown(); + g_mountError = SdMountError::NoCard; // no reason names a chip supply that would not start, and without it no card can answer return false; } host.pwr_ctrl_handle = g_pwrCtrl; #endif - // Field by field, not SDMMC_SLOT_CONFIG_DEFAULT(): @xref{built-field-by-field}. - sdmmc_slot_config_t slotCfg = {}; - slotCfg.clk = static_cast(pins.clk); - slotCfg.cmd = static_cast(pins.cmd); - slotCfg.d0 = static_cast(pins.d0); - if (fourBit) { - slotCfg.d1 = static_cast(pins.d1); - slotCfg.d2 = static_cast(pins.d2); - slotCfg.d3 = static_cast(pins.d3); + esp_err_t err; + if (pins.bus == SdBus::Spi) { + const sdspi_device_config_t dev = spiDevice(pins); + err = esp_vfs_fat_sdspi_mount(SD_MOUNT_POINT, &host, &dev, &mountCfg, &g_card); + } else { + const sdmmc_slot_config_t slotCfg = slotConfig(pins); + err = esp_vfs_fat_sdmmc_mount(SD_MOUNT_POINT, &host, &slotCfg, &mountCfg, &g_card); } - slotCfg.width = fourBit ? 4 : 1; - slotCfg.cd = SDMMC_SLOT_NO_CD; // @xref{cd-and-wp-read-as-gpio0-unless-told-otherwise} - slotCfg.wp = SDMMC_SLOT_NO_WP; - slotCfg.flags |= SDMMC_SLOT_FLAG_INTERNAL_PULLUP; - - const esp_err_t err = esp_vfs_fat_sdmmc_mount(SD_MOUNT_POINT, &host, &slotCfg, &mountCfg, &g_card); if (err != ESP_OK) { ESP_LOGE(SD_TAG, "SD mount failed: %s", esp_err_to_name(err)); -#if SOC_SDMMC_IO_POWER_EXTERNAL - sd_pwr_ctrl_del_on_chip_ldo(g_pwrCtrl); - g_pwrCtrl = nullptr; -#endif - if (g_pwrGpio >= 0) gpio_set_level(static_cast(g_pwrGpio), 1); + // ESP_FAIL is a card that answers; it is formattable on exactly the FatFs results IDF's own formatting mount formats on, every other error being the card not answering. + if (err != ESP_FAIL) g_mountError = SdMountError::NoCard; + else { + // SPI reads as no filesystem: a second SPI init after IDF's failed-mount cleanup asserts in the SPI bus lock. + const FRESULT res = pins.bus == SdBus::Spi ? FR_NO_FILESYSTEM : fatVerdict(host, pins); + g_mountError = res == FR_NO_FILESYSTEM || res == FR_INT_ERR ? SdMountError::NoFilesystem : SdMountError::Unreadable; + } + powerDown(); g_card = nullptr; return false; } g_mounted = true; + g_mountError = SdMountError::None; + clearStaging(); ESP_LOGI(SD_TAG, "SD card mounted at %s", SD_MOUNT_POINT); return true; } -bool sdUnmount() { - if (!g_mounted) return true; - if (g_streamsOpen > 0) return false; // a transfer holds a FIL into the context this would free +bool sdMount(const SdPinConfig& pins) { + if (g_formatState == SdFormatState::Running) return false; + return mountCard(pins, false); +} + +SdMountError sdMountError() { return g_mountError; } + +// Unmount and power the slot down, for a caller that has already ruled out an open stream. +static void unmountCard() { esp_vfs_fat_sdcard_unmount(SD_MOUNT_POINT, g_card); -#if SOC_SDMMC_IO_POWER_EXTERNAL - if (g_pwrCtrl) { sd_pwr_ctrl_del_on_chip_ldo(g_pwrCtrl); g_pwrCtrl = nullptr; } -#endif - if (g_pwrGpio >= 0) gpio_set_level(static_cast(g_pwrGpio), 1); + powerDown(); g_card = nullptr; g_mounted = false; +} + +bool sdUnmount() { + if (g_formatState == SdFormatState::Running) return false; + if (!g_mounted) return true; + if (g_streamsOpen > 0) return false; // a transfer holds a FIL into the context this would free + unmountCard(); return true; } +// The format itself, on its own task: @xref{a-format-runs-on-its-own-task}. +static void formatTask(void*) { + bool ok = false; + // A card the last mount found without a filesystem is formatted by a mount that may format, the one path that writes a new FAT. + if (g_formatUnmounted) ok = mountCard(g_lastPins, true); + else if (esp_vfs_fat_sdcard_format(SD_MOUNT_POINT, g_card) == ESP_OK) ok = g_mounted = true; + else unmountCard(); + g_formatState = ok ? SdFormatState::Succeeded : SdFormatState::Failed; + vTaskDelete(nullptr); +} + bool sdFormat() { - if (!g_mounted || !g_card) return false; - if (g_streamsOpen > 0) return false; // same conflict as an unmount under an open stream - // Formats and remounts in one call; the mount stays live either way. - return esp_vfs_fat_sdcard_format(SD_MOUNT_POINT, g_card) == ESP_OK; + if (sdCardBusy()) return false; + g_formatUnmounted = !g_mounted && g_mountError == SdMountError::NoFilesystem; + if (!g_formatUnmounted && (!g_mounted || !g_card)) return false; + g_mounted = false; // every file call refuses until the task publishes its result + g_formatState = SdFormatState::Running; + // One above the render loop, the textbook favoring of an I/O-bound task: it takes the CPU only between transfers it waits on. + if (xTaskCreate(&formatTask, "sdFormat", 4096, nullptr, 2, nullptr) == pdPASS) return true; + g_mounted = !g_formatUnmounted; + g_formatState = SdFormatState::Failed; + return false; } +SdFormatState sdFormatState() { return g_formatState; } + uint64_t sdCardUsed() { if (!g_mounted) return 0; FATFS* fs = nullptr; @@ -287,6 +453,30 @@ static void sdMakeParent(const char* apiPath) { sdCardMkdir(dir); } +// The staging folder every write lands in first: @xref{a-write-lands-in-a-staging-folder-first}. +static constexpr const char* SD_STAGING = "0:/.tmp"; + +// A staging name unique among the writes in flight; false on truncation. +static bool stagingPath(char* out, size_t outLen) { + static uint32_t seq = 0; + f_mkdir(SD_STAGING); // FR_EXIST once it is there + const int n = std::snprintf(out, outLen, "%s/w%lu.tmp", SD_STAGING, static_cast(seq++)); + return n > 0 && static_cast(n) < outLen; +} + +// Delete what writes cut short by a failed card left behind. +static void clearStaging() { + FF_DIR dir; + if (f_opendir(&dir, SD_STAGING) != FR_OK) return; + FILINFO info; + char path[40]; + while (f_readdir(&dir, &info) == FR_OK && info.fname[0]) { + const int n = std::snprintf(path, sizeof(path), "%s/%s", SD_STAGING, info.fname); + if (n > 0 && static_cast(n) < sizeof(path)) f_unlink(path); + } + f_closedir(&dir); +} + // f_rename refuses an existing destination, unlike POSIX rename; the old file is unlinked first, with the brief gap as FAT's cost for having no atomic replace. static bool sdCommitTmp(const char* tmp, const char* full) { f_unlink(full); @@ -300,10 +490,8 @@ bool sdCardWriteAtomic(const char* apiPath, const char* data, size_t len) { if (len > 0 && !data) return false; sdMakeParent(apiPath); char full[128]; - char tmp[136]; - if (!sdTranslate(apiPath, full, sizeof(full))) return false; - int n = std::snprintf(tmp, sizeof(tmp), "%s.tmp", full); - if (n < 0 || static_cast(n) >= sizeof(tmp)) return false; + char tmp[32]; + if (!sdTranslate(apiPath, full, sizeof(full)) || !stagingPath(tmp, sizeof(tmp))) return false; if (f_open(&g_fil, tmp, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) return false; UINT bw = 0; @@ -357,11 +545,11 @@ void sdCardReadStreamClose(void* handle) { --g_streamsOpen; } -// Carries both paths, not just the FIL, so close() can rename or discard without re-deriving them. +// Carries both paths beside the FIL, so close() can rename or discard without re-deriving them. struct SdWriteHandle { FIL f; char full[128] = {}; - char tmp[136] = {}; + char tmp[32] = {}; }; bool sdCardWriteStreamOpen(void*& handle, const char* apiPath) { @@ -369,8 +557,7 @@ bool sdCardWriteStreamOpen(void*& handle, const char* apiPath) { sdMakeParent(apiPath); auto* h = new SdWriteHandle(); if (!sdTranslate(apiPath, h->full, sizeof(h->full))) { delete h; return false; } - const int n = std::snprintf(h->tmp, sizeof(h->tmp), "%s.tmp", h->full); - if (n < 0 || static_cast(n) >= sizeof(h->tmp)) { delete h; return false; } + if (!stagingPath(h->tmp, sizeof(h->tmp))) { delete h; return false; } if (f_open(&h->f, h->tmp, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) { delete h; return false; } handle = h; ++g_streamsOpen; @@ -400,10 +587,12 @@ bool sdCardWriteStreamClose(void* handle, bool commit) { bool sdCardMounted() { return false; } bool sdCardPresent() { return false; } bool sdMount(const SdPinConfig&) { return false; } +SdMountError sdMountError() { return SdMountError::NoCard; } bool sdCardBusy() { return false; } bool sdCardBootsByDefault() { return false; } bool sdUnmount() { return true; } bool sdFormat() { return false; } +SdFormatState sdFormatState() { return SdFormatState::Idle; } uint64_t sdCardUsed() { return 0; } uint64_t sdCardTotal() { return 0; } bool sdCardMkdir(const char*) { return false; } diff --git a/src/platform/esp32/platform_esp32_sdmmc.h b/src/platform/esp32/platform_esp32_sdmmc.h index fc8dab9c1..0feedcedb 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.h +++ b/src/platform/esp32/platform_esp32_sdmmc.h @@ -2,8 +2,7 @@ #include "platform/platform.h" -/// The SD card backend behind every `/sd`-prefixed `fsXxx` call, split out of platform_esp32_fs.cpp, which dispatches here. -/// Every path below is the full API path (such as `/sd/foo.txt`), translated internally onto the real FatFs mount point. +/// The SD card backend behind every `/sd`-prefixed `fsXxx` call, split out of platform_esp32_fs.cpp, which dispatches here. Every path below is the full API path (such as `/sd/foo.txt`), translated internally onto the real FatFs mount point. namespace mm::platform { diff --git a/src/platform/platform.h b/src/platform/platform.h index 870826913..950942fae 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -329,48 +329,67 @@ inline bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { // A second, independent mount (microSD, FAT) sharing the fsXxx surface above through its own `/sd` path prefix; inert wherever hasSdCard is false. +// Never where hasSdCard is false, so a flash folder named `sd` stays an ordinary folder there. /// Whether `path` is rooted at the SD mount: "sd", "/sd", "sd/foo" or "/sd/foo" (a leading slash is optional). inline bool isSdPath(const char* path) { + if constexpr (!hasSdCard) return false; if (!path) return false; if (*path == '/') path++; return path[0] == 's' && path[1] == 'd' && (path[2] == '\0' || path[2] == '/'); } -/// A board's SDMMC wiring, read at mount time rather than compiled in; -1 leaves a line unused. +/// The bus a slot is wired for: the native SD bus, or the SD spec's SPI mode. +enum class SdBus : uint8_t { Sdmmc, Spi }; +/// A board's slot wiring, read at mount time rather than compiled in; -1 leaves a line unused. SPI mode reuses the SD spec's own pin mapping: CLK is SCK, CMD is MOSI, D0 is MISO and D3 is CS. struct SdPinConfig { - int8_t clk = -1; ///< SDMMC clock - int8_t cmd = -1; ///< SDMMC command line - int8_t d0 = -1; ///< SDMMC data line 0, required - int8_t d1 = -1; ///< unset alongside d2/d3 drops the bus to 1-bit mode - int8_t d2 = -1; ///< SDMMC data line 2 - int8_t d3 = -1; ///< SDMMC data line 3 + int8_t clk = -1; ///< clock + int8_t cmd = -1; ///< command line, MOSI in SPI mode + int8_t d0 = -1; ///< data line 0, required, MISO in SPI mode + int8_t d1 = -1; ///< unset alongside d2/d3 drops the SD bus to 1-bit mode + int8_t d2 = -1; ///< data line 2 + int8_t d3 = -1; ///< data line 3, CS in SPI mode int8_t pwr = -1; ///< a slot's card-power switch, or -1 where the slot has none + SdBus bus = SdBus::Sdmmc; ///< which bus the lines above form }; -/// Mount the card under `/sd`, idempotent; false if no card, no slot, or the pins are unset. +/// Mount the card under `/sd`, idempotent; false if no card, no slot, or the pins are unset, with the reason in sdMountError(). bool sdMount(const SdPinConfig& pins); +/// Why a mount fails: a pin that cannot drive the slot, no card answering, a card whose sectors do not read, or a card without a FAT filesystem. +enum class SdMountError : uint8_t { None, Pins, NoCard, Unreadable, NoFilesystem }; +/// The reason the last sdMount() failed, None after a success. +SdMountError sdMountError(); /// True once sdMount succeeds and nothing has unmounted it since. A chip capable of SD is not the same as a card present, so the root listing gates on this, not hasSdCard. bool sdCardMounted(); -/// True while the mounted card still answers a status probe; false once it has been pulled, even though sdCardMounted() has not noticed yet. SdCardModule polls this to detect a live removal. +/// True while the mounted card still answers a status probe; false once it has been pulled, even though sdCardMounted() has not noticed yet. SdCardService polls this to detect a live removal. bool sdCardPresent(); -/// True while a read or write stream is open on the card; sdUnmount/sdFormat refuse while this holds, so a caller checks it first to give the specific reason. +/// True while a stream is open on the card or a format runs; sdUnmount and sdFormat refuse meanwhile, so a caller checks it first for the specific reason. bool sdCardBusy(); -/// Unmount it; false (refused) while a stream is open, true otherwise, including when nothing was mounted. +/// Unmount it; false (refused) while a stream is open or a format runs, true otherwise, including when nothing was mounted. bool sdUnmount(); -/// Format and remount; false while unmounted, while a stream is open, or on a format failure. +/// Start formatting the mounted card, or one the last mount found without a FAT filesystem, in the background; false when there is nothing to format or the card is busy. bool sdFormat(); +/// Where the last sdFormat() stands; the card is unmounted while it runs and mounted again once it succeeds. +enum class SdFormatState : uint8_t { Idle, Running, Succeeded, Failed }; +/// The state of the last sdFormat(), Idle before the first. +SdFormatState sdFormatState(); /// Card bytes used, 0 when unmounted; wide because a real card routinely exceeds 4 GiB. uint64_t sdCardUsed(); /// Card bytes total, 0 when unmounted. uint64_t sdCardTotal(); -/// Whether SdCardModule boots by default via kBootModules: matches hasSdCard on ESP32, off by default on desktop, so a plain desktop run carries no SD clutter unless a test opts in. +/// Whether SdCardService boots by default via kBootModules: matches hasSdCard on ESP32, off by default on desktop, so a plain desktop run carries no SD clutter unless a test opts in. bool sdCardBootsByDefault(); #ifndef ESP_PLATFORM // a host-test seam, which no ESP32 code calls /// Shrink the host's fictional card, so a test reaches the "not enough space" path without writing a real gigabyte. void setTestSdCardTotal(uint64_t bytes); -/// Opt SdCardModule's default boot-wiring in or out for one test's scope. +/// Opt SdCardService's default boot-wiring in or out for one test's scope. void setTestSdCardBootsByDefault(bool enabled); /// Simulate the card being pulled (false) or reinserted (true); also gates sdMount's own success, the way a real absent card would fail to answer. void setTestSdCardPresent(bool present); +/// Simulate a card that answers but holds no FAT filesystem (false), which only a format makes mountable. +void setTestSdCardHasFilesystem(bool hasFilesystem); +/// Simulate a card that answers but whose sectors do not read (false), which no format helps. +void setTestSdCardReadable(bool readable); +/// Hold a started format Running (true), the way a large card's format outlasts many ticks; false completes it. +void setTestSdFormatHeld(bool held); #endif // Network: ESP32 only, stubs on desktop. diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4db7576ad..107e626b5 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -20,7 +20,7 @@ add_executable(mm_tests unit/core/unit_crc.cpp unit/core/unit_AudioService_codec.cpp unit/core/unit_I2cBusModule.cpp - unit/core/unit_SdCardModule.cpp + unit/core/unit_SdCardService.cpp unit/core/unit_AudioService_sync.cpp unit/core/unit_WledAudioSyncPacket.cpp unit/core/unit_ThreadSlot.cpp diff --git a/test/js/file-manager-sd-upload.test.mjs b/test/js/file-manager-sd-upload.test.mjs index fde9a77e3..ee5047636 100644 --- a/test/js/file-manager-sd-upload.test.mjs +++ b/test/js/file-manager-sd-upload.test.mjs @@ -12,7 +12,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { fnSource } from "./app-source.mjs"; -// A minimal fake DOM: just enough of createElement/appendChild/remove/getElementById for +// A minimal fake DOM: enough of createElement/appendChild/remove/getElementById for // fmUploadPanel and fmUploadPanelRow, which this codebase has no existing mock for (its DOM-heavy // code is exercised by the Playwright --ui scenarios instead, not this node:test harness). function fakeElement(tag) { diff --git a/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json b/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json index 2594e95e1..7c3dc5131 100644 --- a/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json +++ b/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json @@ -174,14 +174,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 7, + "p50": 8, "p95": 324, "min": 5, "max": 462, - "n": 30, - "samples": [5, 462, 324, 5, 5, 5, 7, 5, 6, 5, 6, 6, 6, 5, 5, 13, 6, 5, 11, 8, 8, 8, 8, 8, 9, 14, 8, 8, 8, 11] + "n": 31, + "samples": [5, 462, 324, 5, 5, 5, 7, 5, 6, 5, 6, 6, 6, 5, 5, 13, 6, 5, 11, 8, 8, 8, 8, 8, 9, 14, 8, 8, 8, 11, 8] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json b/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json index e1e189989..d73a160f7 100644 --- a/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json +++ b/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json @@ -73,10 +73,10 @@ "p95": 387, "min": 1, "max": 555, - "n": 29, - "samples": [367, 555, 1, 387, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 4, 1, 7, 7, 8, 7, 7, 8, 7, 7, 7, 7, 8] + "n": 30, + "samples": [367, 555, 1, 387, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 4, 1, 7, 7, 8, 7, 7, 8, 7, 7, 7, 7, 8, 9] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_Drivers_output_and_brightness.json b/test/scenarios/light/scenario_Drivers_output_and_brightness.json index 00007c329..f7a2c7fa2 100644 --- a/test/scenarios/light/scenario_Drivers_output_and_brightness.json +++ b/test/scenarios/light/scenario_Drivers_output_and_brightness.json @@ -107,9 +107,9 @@ "min": 6, "max": 17, "n": 32, - "samples": [7, 7, 7, 7, 7, 17, 6, 7, 7, 7, 7, 9, 8, 7, 7, 7, 6, 8, 15, 8, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 8] + "samples": [7, 7, 7, 7, 17, 6, 7, 7, 7, 7, 9, 8, 7, 7, 7, 6, 8, 15, 8, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 8, 7] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json b/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json index af5c1ade2..19a9d73f3 100644 --- a/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json +++ b/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json @@ -54,10 +54,10 @@ "p95": 7, "min": 1, "max": 7, - "n": 19, - "samples": [1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 7] + "n": 20, + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 7, 6] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" } } }, @@ -84,13 +84,13 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 7, + "p95": 8, "min": 1, "max": 8, "n": 32, - "samples": [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 8] + "samples": [1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 8, 8] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" }, "desktop-windows": { "tick_us": { @@ -118,9 +118,9 @@ "min": 6, "max": 46, "n": 32, - "samples": [7, 7, 7, 46, 7, 6, 6, 6, 6, 7, 7, 7, 7, 7, 6, 6, 10, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 6] + "samples": [7, 7, 46, 7, 6, 6, 6, 6, 7, 7, 7, 7, 7, 6, 6, 10, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 6, 7] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Effects_swap_while_running.json b/test/scenarios/light/scenario_Effects_swap_while_running.json index ebeb465e4..afff1f41a 100644 --- a/test/scenarios/light/scenario_Effects_swap_while_running.json +++ b/test/scenarios/light/scenario_Effects_swap_while_running.json @@ -86,9 +86,9 @@ "min": 1, "max": 2, "n": 32, - "samples": [1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" }, "desktop-windows": { "tick_us": { @@ -168,9 +168,9 @@ "min": 1, "max": 4, "n": 32, - "samples": [1, 1, 1, 1, 1, 1, 4, 1, 1, 1, 4, 1, 1, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 4, 1, 1, 1, 4, 1, 1, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json b/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json index 6a7eb4051..40b0f394f 100644 --- a/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json +++ b/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json @@ -68,9 +68,9 @@ "min": 6, "max": 81, "n": 32, - "samples": [7, 6, 7, 7, 7, 7, 18, 7, 6, 7, 81, 7, 7, 7, 7, 7, 15, 7, 7, 12, 7, 7, 8, 7, 8, 8, 8, 8, 8, 8, 8, 10] + "samples": [6, 7, 7, 7, 7, 18, 7, 6, 7, 81, 7, 7, 7, 7, 7, 15, 7, 7, 12, 7, 7, 8, 7, 8, 8, 8, 8, 8, 8, 8, 10, 8] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" }, "esp32s3-n16r8": { "tick_us": { @@ -149,9 +149,9 @@ "min": 4, "max": 32, "n": 32, - "samples": [4, 4, 7, 4, 5, 5, 32, 4, 4, 4, 12, 5, 5, 5, 4, 6, 5, 5, 4, 7, 4, 4, 5, 4, 6, 6, 6, 6, 6, 4, 5, 9] + "samples": [4, 7, 4, 5, 5, 32, 4, 4, 4, 12, 5, 5, 5, 4, 6, 5, 5, 4, 7, 4, 4, 5, 4, 6, 6, 6, 6, 6, 4, 5, 9, 6] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" }, "esp32s3-n16r8": { "tick_us": { @@ -226,9 +226,9 @@ "min": 6, "max": 19, "n": 32, - "samples": [7, 7, 7, 7, 7, 7, 19, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 6, 7, 15, 7, 7, 9, 7, 8, 8, 8, 7, 7, 7, 7, 9] + "samples": [7, 7, 7, 7, 7, 19, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 6, 7, 15, 7, 7, 9, 7, 8, 8, 8, 7, 7, 7, 7, 9, 8] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" }, "esp32s3-n16r8": { "tick_us": { @@ -313,9 +313,9 @@ "min": 6, "max": 18, "n": 32, - "samples": [7, 6, 7, 7, 7, 7, 18, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 6, 7, 14, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 10] + "samples": [6, 7, 7, 7, 7, 18, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 6, 7, 14, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 10, 8] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" }, "esp32s3-n16r8": { "tick_us": { @@ -406,9 +406,9 @@ "min": 6, "max": 31, "n": 32, - "samples": [7, 6, 7, 7, 7, 7, 31, 6, 6, 6, 6, 7, 7, 7, 7, 7, 11, 6, 7, 13, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 11] + "samples": [6, 7, 7, 7, 7, 31, 6, 6, 6, 6, 7, 7, 7, 7, 7, 11, 6, 7, 13, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 11, 7] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" }, "esp32s3-n16r8": { "tick_us": { @@ -495,9 +495,9 @@ "min": 3, "max": 25, "n": 32, - "samples": [3, 3, 4, 4, 4, 4, 25, 4, 3, 3, 5, 4, 4, 4, 4, 4, 5, 4, 4, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5] + "samples": [3, 4, 4, 4, 4, 25, 4, 3, 3, 5, 4, 4, 4, 4, 4, 5, 4, 4, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" }, "esp32s3-n16r8": { "tick_us": { @@ -600,9 +600,9 @@ "min": 3, "max": 20, "n": 32, - "samples": [3, 3, 4, 4, 4, 4, 20, 4, 3, 3, 4, 4, 4, 4, 4, 4, 6, 4, 4, 7, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 7] + "samples": [3, 4, 4, 4, 4, 20, 4, 3, 3, 4, 4, 4, 4, 4, 4, 6, 4, 4, 7, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 7, 4] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_Layers_stack_and_blend_live.json b/test/scenarios/light/scenario_Layers_stack_and_blend_live.json index 357096355..3d6845f8f 100644 --- a/test/scenarios/light/scenario_Layers_stack_and_blend_live.json +++ b/test/scenarios/light/scenario_Layers_stack_and_blend_live.json @@ -92,9 +92,9 @@ "min": 8, "max": 24, "n": 32, - "samples": [8, 8, 8, 8, 8, 8, 24, 8, 8, 8, 9, 8, 8, 8, 8, 9, 11, 8, 8, 12, 9, 9, 8, 8, 8, 8, 9, 9, 10, 11, 9, 10] + "samples": [8, 8, 8, 8, 8, 24, 8, 8, 8, 9, 8, 8, 8, 8, 9, 11, 8, 8, 12, 9, 9, 8, 8, 8, 8, 9, 9, 10, 11, 9, 10, 8] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" } } }, @@ -136,9 +136,9 @@ "min": 13, "max": 37, "n": 32, - "samples": [15, 15, 15, 15, 15, 15, 35, 15, 15, 15, 15, 15, 15, 14, 15, 16, 37, 13, 15, 19, 15, 15, 15, 15, 15, 15, 16, 16, 16, 19, 16, 15] + "samples": [15, 15, 15, 15, 15, 35, 15, 15, 15, 15, 15, 15, 14, 15, 16, 37, 13, 15, 19, 15, 15, 15, 15, 15, 15, 16, 16, 16, 19, 16, 15, 15] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" } } }, @@ -224,9 +224,9 @@ "min": 18, "max": 46, "n": 32, - "samples": [20, 20, 20, 21, 20, 20, 46, 21, 20, 20, 21, 20, 20, 20, 20, 21, 29, 18, 20, 24, 20, 20, 21, 20, 21, 21, 21, 21, 21, 24, 21, 20] + "samples": [20, 20, 21, 20, 20, 46, 21, 20, 20, 21, 20, 20, 20, 20, 21, 29, 18, 20, 24, 20, 20, 21, 20, 21, 21, 21, 21, 21, 24, 21, 20, 20] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" } } }, @@ -259,14 +259,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 123, + "p50": 124, "p95": 162, "min": 121, "max": 225, "n": 32, - "samples": [123, 122, 123, 124, 124, 123, 225, 124, 122, 123, 126, 124, 123, 123, 123, 125, 141, 121, 123, 162, 124, 123, 123, 123, 123, 123, 125, 125, 125, 128, 124, 124] + "samples": [122, 123, 124, 124, 123, 225, 124, 122, 123, 126, 124, 123, 123, 123, 125, 141, 121, 123, 162, 124, 123, 123, 123, 123, 123, 125, 125, 125, 128, 124, 124, 125] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" } } }, @@ -304,9 +304,9 @@ "min": 262, "max": 454, "n": 32, - "samples": [270, 264, 271, 267, 266, 267, 454, 280, 262, 266, 304, 267, 265, 264, 275, 267, 309, 267, 265, 355, 265, 265, 265, 265, 264, 265, 274, 284, 291, 280, 267, 272] + "samples": [264, 271, 267, 266, 267, 454, 280, 262, 266, 304, 267, 265, 264, 275, 267, 309, 267, 265, 355, 265, 265, 265, 265, 264, 265, 274, 284, 291, 280, 267, 272, 268] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" } } }, @@ -341,9 +341,9 @@ "min": 51, "max": 100, "n": 32, - "samples": [56, 56, 56, 58, 57, 60, 85, 56, 56, 56, 65, 56, 65, 55, 56, 69, 56, 56, 56, 100, 56, 56, 56, 56, 56, 56, 56, 57, 61, 54, 56, 51] + "samples": [56, 56, 58, 57, 60, 85, 56, 56, 56, 65, 56, 65, 55, 56, 69, 56, 56, 56, 100, 56, 56, 56, 56, 56, 56, 56, 57, 61, 54, 56, 51, 56] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" } } }, @@ -367,9 +367,9 @@ "min": 51, "max": 103, "n": 32, - "samples": [52, 52, 53, 53, 54, 53, 97, 52, 52, 54, 57, 53, 54, 52, 52, 75, 103, 52, 52, 76, 52, 52, 58, 52, 52, 52, 53, 54, 55, 51, 55, 53] + "samples": [52, 53, 53, 54, 53, 97, 52, 52, 54, 57, 53, 54, 52, 52, 75, 103, 52, 52, 76, 52, 52, 58, 52, 52, 52, 53, 54, 55, 51, 55, 53, 52] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" } } }, @@ -403,9 +403,9 @@ "min": 48, "max": 281, "n": 32, - "samples": [51, 51, 52, 52, 57, 51, 85, 51, 51, 52, 56, 51, 56, 51, 52, 51, 205, 52, 51, 281, 51, 51, 51, 52, 51, 55, 51, 54, 52, 54, 52, 48] + "samples": [51, 52, 52, 57, 51, 85, 51, 51, 52, 56, 51, 56, 51, 52, 51, 205, 52, 51, 281, 51, 51, 51, 52, 51, 55, 51, 54, 52, 54, 52, 48, 52] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-12" } } } diff --git a/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json b/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json index 1e6691db6..962c52073 100644 --- a/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json +++ b/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json @@ -82,9 +82,9 @@ "min": 6, "max": 23, "n": 32, - "samples": [6, 6, 7, 6, 7, 7, 14, 7, 6, 7, 23, 7, 7, 7, 7, 7, 20, 8, 8, 22, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7] + "samples": [6, 7, 6, 7, 7, 14, 7, 6, 7, 23, 7, 7, 7, 7, 7, 20, 8, 8, 22, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-13" }, "desktop-windows": { "tick_us": { @@ -164,9 +164,9 @@ "min": 9, "max": 22, "n": 32, - "samples": [10, 10, 10, 11, 10, 11, 19, 10, 11, 10, 22, 10, 11, 10, 10, 10, 17, 10, 10, 19, 10, 10, 11, 10, 11, 11, 11, 11, 10, 9, 11, 9] + "samples": [10, 10, 11, 10, 11, 19, 10, 11, 10, 22, 10, 11, 10, 10, 10, 17, 10, 10, 19, 10, 10, 11, 10, 11, 11, 11, 11, 10, 9, 11, 9, 10] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-13" }, "desktop-windows": { "tick_us": { @@ -253,9 +253,9 @@ "min": 6, "max": 14, "n": 32, - "samples": [7, 6, 6, 6, 7, 7, 14, 6, 6, 6, 9, 6, 7, 7, 7, 7, 9, 7, 6, 14, 6, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7] + "samples": [6, 6, 6, 7, 7, 14, 6, 6, 6, 9, 6, 7, 7, 7, 7, 9, 7, 6, 14, 6, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 10] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-13" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json b/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json index 8feca06b5..ce17e894a 100644 --- a/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json +++ b/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json @@ -67,9 +67,9 @@ "min": 6, "max": 19, "n": 32, - "samples": [6, 6, 7, 7, 7, 7, 19, 7, 6, 6, 8, 6, 7, 8, 11, 7, 8, 8, 6, 10, 7, 7, 8, 7, 7, 7, 7, 7, 8, 7, 7, 7] + "samples": [6, 7, 7, 7, 7, 19, 7, 6, 6, 8, 6, 7, 8, 11, 7, 8, 8, 6, 10, 7, 7, 8, 7, 7, 7, 7, 7, 8, 7, 7, 7, 9] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-13" }, "esp32s3-n16r8": { "tick_us": { @@ -156,9 +156,9 @@ "min": 3, "max": 11, "n": 32, - "samples": [3, 3, 3, 3, 3, 3, 11, 3, 3, 3, 4, 4, 4, 3, 3, 3, 5, 4, 3, 9, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4] + "samples": [3, 3, 3, 3, 3, 11, 3, 3, 3, 4, 4, 4, 3, 3, 3, 5, 4, 3, 9, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 5] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-13" }, "esp32s3-n16r8": { "tick_us": { @@ -238,9 +238,9 @@ "min": 3, "max": 9, "n": 32, - "samples": [3, 3, 3, 3, 3, 3, 8, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 3, 9, 3, 3, 4, 3, 4, 3, 3, 4, 4, 4, 4, 4] + "samples": [3, 3, 3, 3, 3, 8, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 3, 9, 3, 3, 4, 3, 4, 3, 3, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-13" }, "esp32s3-n16r8": { "tick_us": { @@ -319,9 +319,9 @@ "min": 2, "max": 6, "n": 32, - "samples": [2, 2, 2, 2, 2, 2, 6, 2, 2, 2, 3, 3, 2, 3, 2, 2, 3, 3, 3, 5, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3] + "samples": [2, 2, 2, 2, 2, 6, 2, 2, 2, 3, 3, 2, 3, 2, 2, 3, 3, 3, 5, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-13" }, "esp32s3-n16r8": { "tick_us": { @@ -406,9 +406,9 @@ "min": 3, "max": 11, "n": 32, - "samples": [3, 3, 3, 3, 3, 3, 11, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 5, 7, 3, 3, 4, 3, 4, 3, 4, 4, 4, 4, 4, 4] + "samples": [3, 3, 3, 3, 3, 11, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 5, 7, 3, 3, 4, 3, 4, 3, 4, 4, 4, 4, 4, 4, 4] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-13" }, "esp32s3-n16r8": { "tick_us": { @@ -495,9 +495,9 @@ "min": 3, "max": 9, "n": 32, - "samples": [4, 3, 3, 3, 4, 3, 9, 3, 3, 3, 4, 3, 3, 3, 3, 3, 4, 4, 3, 6, 3, 3, 4, 3, 4, 3, 3, 4, 4, 3, 4, 4] + "samples": [3, 3, 3, 4, 3, 9, 3, 3, 3, 4, 3, 3, 3, 3, 3, 4, 4, 3, 6, 3, 3, 4, 3, 4, 3, 3, 4, 4, 3, 4, 4, 4] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-13" }, "esp32s3-n16r8": { "tick_us": { @@ -582,9 +582,9 @@ "min": 6, "max": 20, "n": 32, - "samples": [6, 6, 6, 7, 7, 7, 20, 7, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 12, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 8] + "samples": [6, 6, 7, 7, 7, 20, 7, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 12, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 8, 8] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-13" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json b/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json index 992e642eb..a478e5f34 100644 --- a/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json +++ b/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json @@ -107,9 +107,9 @@ "min": 6, "max": 202, "n": 32, - "samples": [6, 202, 11, 6, 74, 74, 7, 6, 6, 7, 6, 6, 6, 7, 8, 11, 7, 7, 9, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 9] + "samples": [202, 11, 6, 74, 74, 7, 6, 6, 7, 6, 6, 6, 7, 8, 11, 7, 7, 9, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 9, 8] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-13" } } }, @@ -175,9 +175,9 @@ "min": 6, "max": 205, "n": 32, - "samples": [7, 205, 7, 7, 115, 108, 8, 6, 6, 7, 7, 7, 7, 7, 7, 8, 6, 6, 11, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6] + "samples": [205, 7, 7, 115, 108, 8, 6, 6, 7, 7, 7, 7, 7, 7, 8, 6, 6, 11, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7] }, - "last_updated": "2026-10-10" + "last_updated": "2026-10-13" } } } diff --git a/test/unit/core/unit_HttpServerModule_sdcard.cpp b/test/unit/core/unit_HttpServerModule_sdcard.cpp index a643bde76..762a78f98 100644 --- a/test/unit/core/unit_HttpServerModule_sdcard.cpp +++ b/test/unit/core/unit_HttpServerModule_sdcard.cpp @@ -1,18 +1,19 @@ /// @module HttpServerModule -/// @also SdCardModule +/// @also SdCardService /// -/// `/api/file` and `/api/dir` reach the card through the same path-prefix dispatch every other `fsXxx` call uses. -/// A large transfer resumes a bounded slice per tick (driveSdTransfers) instead of blocking one. -/// Driven over loopback against the real server, as unit_HttpServerModule_connections.cpp does for parking. +/// `/api/file` and `/api/dir` reach the card through the `fsXxx` path-prefix dispatch, a large transfer moving a bounded slice per tick, driven over loopback against the real server. #include "doctest.h" #include "core/system/HttpServerModule.h" #include "platform/platform.h" #include +#include +#include #include #include #include +#include #include namespace { @@ -33,6 +34,15 @@ bool connectAndAccept(mm::HttpServerModule& http, mm::platform::TcpConnection& c return true; } +// Sends a POST of `payload` to `path`: the header, then the whole body. +void postFile(mm::platform::TcpConnection& client, const char* path, const std::string& payload) { + char req[256]; + const int reqLen = std::snprintf(req, sizeof(req), + "POST /api/file?path=%s HTTP/1.1\r\nContent-Length: %zu\r\n\r\n", path, payload.size()); + REQUIRE(client.write(reinterpret_cast(req), static_cast(reqLen))); + REQUIRE(client.write(reinterpret_cast(payload.data()), payload.size())); +} + // Ticks the server, accumulating whatever the client reads, until `out` holds at least `wantAtLeast` bytes or `maxTicks` is spent; returns what was accumulated. std::string pumpUntil(mm::HttpServerModule& http, mm::platform::TcpConnection& client, size_t wantAtLeast, int maxTicks = 3000) { @@ -47,6 +57,23 @@ std::string pumpUntil(mm::HttpServerModule& http, mm::platform::TcpConnection& c return out; } +// Whether a reply holds a whole response: its headers and the Content-Length bytes of body after them. +bool wholeResponse(const std::string& reply) { + const size_t end = reply.find("\r\n\r\n"); + if (end == std::string::npos) return false; + const size_t at = reply.find("Content-Length: "); + const size_t length = at < end ? std::strtoul(reply.c_str() + at + 16, nullptr, 10) : 0; + return reply.size() >= end + 4 + length; +} + +// Sends a whole request and returns the whole response, since macOS loopback can deliver the body a moment after the status line. +std::string request(mm::HttpServerModule& http, mm::platform::TcpConnection& client, const char* req) { + REQUIRE(client.write(reinterpret_cast(req), std::strlen(req))); + std::string reply = pumpUntil(http, client, 12); + for (int i = 0; i < 3000 && !wholeResponse(reply); i++) reply += pumpUntil(http, client, 1, 1); + return reply; +} + // A deterministic byte pattern, long enough (> the 4096-byte per-tick chunk) to force several ticks of driveSdTransfers regardless of how fast loopback delivers the bytes. std::string pattern(size_t n) { std::string s(n, '\0'); @@ -54,7 +81,26 @@ std::string pattern(size_t n) { return s; } -// Connects, declares a 10000-byte upload to `path` but sends only 100, then ticks once so the slot claims it: in flight, never finished. +// Ticks with a millisecond between them, since Linux loopback delivers a write at once and macOS a moment later; a tick with nothing new is harmless. +void tickWhileDelivering(mm::HttpServerModule& http, int ticks = 5) { + for (int i = 0; i < ticks; i++) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + http.tick20ms(); + } +} + +// What a read answers after the server closes the socket: -1 while the close is in flight, then 0. +int readAfterClose(mm::platform::TcpConnection& client) { + char buf[32]; + int got = -1; + for (int ms = 0; ms < 200 && got == -1; ms++) { + got = client.read(reinterpret_cast(buf), sizeof(buf)); + if (got == -1) std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + return got; +} + +// Connects, declares a 10000-byte upload to `path` but sends only 100, then ticks so the slot claims it: in flight, never finished. void claimStuckUpload(mm::HttpServerModule& http, mm::platform::TcpConnection& client, const char* path) { REQUIRE(connectAndAccept(http, client)); char req[256]; @@ -63,7 +109,7 @@ void claimStuckUpload(mm::HttpServerModule& http, mm::platform::TcpConnection& c REQUIRE(client.write(reinterpret_cast(req), static_cast(reqLen))); const std::string partial = pattern(100); REQUIRE(client.write(reinterpret_cast(partial.data()), partial.size())); - http.tick20ms(); + tickWhileDelivering(http); } // Connects and GETs `path`, returning the response once at least `wantAtLeast` bytes have arrived. @@ -110,7 +156,7 @@ TEST_CASE("a path traversal through /sd is rejected, the same shared guard flash CHECK(std::strcmp(out, "/sd/ok.bin") == 0); } -// The card is a second mount, not a real subdirectory, so the File Manager tree would never offer it without this: a regression guard for exactly the gap found live on the bench, where /sd listed fine once you knew to ask, but the root never advertised it as a child to expand. +// The card is a second mount, not a subdirectory: on the bench /sd listed when asked for, but the root never advertised it to the File Manager tree. TEST_CASE("the root listing advertises /sd as a folder, so the tree can reach it") { SdFixture fix; struct Seen { std::vector names; bool sdIsDir = false; } seen; @@ -127,7 +173,7 @@ TEST_CASE("the root listing advertises /sd as a folder, so the tree can reach it TEST_CASE("the root listing does not advertise /sd once the card is unmounted") { SdFixture fix; - REQUIRE(mm::platform::sdUnmount()); // same slot, pins still wired, just nothing answering now + REQUIRE(mm::platform::sdUnmount()); // same slot, pins still wired, nothing answering now std::vector names; mm::platform::fsList("/", [](const char* name, bool, uint32_t, void* user) { @@ -147,8 +193,7 @@ TEST_CASE("an upload to /sd without a mounted card answers \"no card mounted\", REQUIRE(connectAndAccept(http, client)); const char* req = "POST /api/file?path=/sd/x.bin HTTP/1.1\r\nContent-Length: 2\r\n\r\nhi"; - REQUIRE(client.write(reinterpret_cast(req), std::strlen(req))); - const std::string reply = pumpUntil(http, client, 12); + const std::string reply = request(http, client, req); CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 503", 12) == 0); CHECK(reply.find("no card mounted") != std::string::npos); @@ -165,11 +210,7 @@ TEST_CASE("an SD upload larger than one tick's chunk streams across several tick REQUIRE(connectAndAccept(http, client)); const std::string payload = pattern(10000); - char req[256]; - const int reqLen = std::snprintf(req, sizeof(req), - "POST /api/file?path=/sd/big.bin HTTP/1.1\r\nContent-Length: %zu\r\n\r\n", payload.size()); - REQUIRE(client.write(reinterpret_cast(req), static_cast(reqLen))); - REQUIRE(client.write(reinterpret_cast(payload.data()), payload.size())); + postFile(client, "/sd/big.bin", payload); const std::string reply = pumpUntil(http, client, 12); CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) == 0); @@ -203,14 +244,10 @@ TEST_CASE("an SD upload spanning several chunks drains in far fewer ticks than c REQUIRE(connectAndAccept(http, client)); const std::string payload = pattern(20000); // 5 chunks' worth, at the old one-chunk-per-tick rate - char req[256]; - const int reqLen = std::snprintf(req, sizeof(req), - "POST /api/file?path=/sd/multi.bin HTTP/1.1\r\nContent-Length: %zu\r\n\r\n", payload.size()); - REQUIRE(client.write(reinterpret_cast(req), static_cast(reqLen))); - REQUIRE(client.write(reinterpret_cast(payload.data()), payload.size())); + postFile(client, "/sd/multi.bin", payload); - // The old design needed 5 ticks for the body alone; this proves the whole upload commits in far fewer. - for (int i = 0; i < 2; i++) http.tick20ms(); + // The old design needed 5 ticks for the body alone, so committing within 4 proves the drain. + tickWhileDelivering(http, 4); CHECK(mm::platform::fsSize("/sd/multi.bin") == static_cast(payload.size())); const std::string reply = pumpUntil(http, client, 12); @@ -243,7 +280,7 @@ TEST_CASE("an SD download larger than one tick's chunk streams across several ti CHECK(body.size() == payload.size()); CHECK(body == payload); - // The last byte lands this tick, but driveSdDownload only notices bufSent caught up to bufLen and closes the read stream on the NEXT one; give it a few before the fixture unmounts underneath it. + // driveSdDownload closes the read stream the tick after the last byte lands, so a few more ticks pass before the fixture unmounts underneath it. for (int i = 0; i < 5; i++) http.tick20ms(); client.close(); http.release(); @@ -285,8 +322,7 @@ TEST_CASE("an SD upload that stalls mid-transfer is closed and its slot frees fo // Comfortably past the stall guard (the real bound is a private implementation detail). mm::platform::setTestNowMs(1000 + 60000); for (int i = 0; i < 10; i++) http.tick20ms(); - char req[32]; - CHECK(client.read(reinterpret_cast(req), sizeof(req)) == 0); // closed, no response + CHECK(readAfterClose(client) == 0); // closed, no response // The slot is free again: a fresh, small upload still completes normally. mm::platform::TcpConnection client2; @@ -311,9 +347,8 @@ TEST_CASE("release mid-transfer closes the slot's stream and socket, leaking nei REQUIRE(mm::platform::sdCardBusy()); // the open stream holds the busy guard http.release(); - CHECK_FALSE(mm::platform::sdCardBusy()); // the stream closed, not just abandoned to a dangling handle - char req[32]; - CHECK(client.read(reinterpret_cast(req), sizeof(req)) == 0); // the socket closed too + CHECK_FALSE(mm::platform::sdCardBusy()); // the stream closed, not abandoned to a dangling handle + CHECK(readAfterClose(client) == 0); // the socket closed too CHECK_FALSE(std::filesystem::exists(std::string(fix.root) + "/sd/leak.bin.tmp")); // no leftover temp client.close(); @@ -329,11 +364,7 @@ TEST_CASE("an SD upload past the card's free space is refused with 507, immediat REQUIRE(connectAndAccept(http, client)); const std::string payload = pattern(2000); // bigger than the fictional card - char req[256]; - const int reqLen = std::snprintf(req, sizeof(req), - "POST /api/file?path=/sd/toobig.bin HTTP/1.1\r\nContent-Length: %zu\r\n\r\n", payload.size()); - REQUIRE(client.write(reinterpret_cast(req), static_cast(reqLen))); - REQUIRE(client.write(reinterpret_cast(payload.data()), payload.size())); + postFile(client, "/sd/toobig.bin", payload); const std::string reply = pumpUntil(http, client, 12); CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 507", 12) == 0); @@ -343,7 +374,7 @@ TEST_CASE("an SD upload past the card's free space is refused with 507, immediat http.release(); } -// A declared length past FAT32's own ceiling (4 GiB-1) is well-formed, just too big: 413, not the generic 400 a malformed header gets; no body is sent, since the ceiling rejects on the header alone. +// A declared length past FAT32's 4 GiB-1 ceiling is well-formed but too big: 413, not a malformed header's 400, decided on the header alone. TEST_CASE("an SD upload declaring more than FAT32's 4 GiB-1 ceiling is refused with 413") { SdFixture fix; mm::HttpServerModule http; @@ -353,17 +384,14 @@ TEST_CASE("an SD upload declaring more than FAT32's 4 GiB-1 ceiling is refused w REQUIRE(connectAndAccept(http, client)); const char* req = "POST /api/file?path=/sd/huge.bin HTTP/1.1\r\nContent-Length: 5000000000\r\n\r\n"; - REQUIRE(client.write(reinterpret_cast(req), std::strlen(req))); - - const std::string reply = pumpUntil(http, client, 12); + const std::string reply = request(http, client, req); CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 413", 12) == 0); client.close(); http.release(); } -// Point 1 of the review moved removeRecursiveSd's job onto FilesystemModule::removeTree, which already dispatches through platform::fsList/fsRemove. -// This pins that DELETE /api/dir on /sd actually reaches it, recursing through a non-empty folder exactly as it does for flash. +// Review point 1 moved removeRecursiveSd onto FilesystemModule::removeTree; DELETE /api/dir on /sd reaches it, recursing through a non-empty folder as it does on flash. TEST_CASE("DELETE /api/dir removes a file and a non-empty folder on the card, via removeTree") { SdFixture fix; REQUIRE(mm::platform::fsWriteAtomic("/sd/lone.bin", "x", 1)); @@ -379,8 +407,7 @@ TEST_CASE("DELETE /api/dir removes a file and a non-empty folder on the card, vi mm::platform::TcpConnection client; REQUIRE(connectAndAccept(http, client)); const char* req = "DELETE /api/dir?path=/sd/lone.bin HTTP/1.1\r\n\r\n"; - REQUIRE(client.write(reinterpret_cast(req), std::strlen(req))); - const std::string reply = pumpUntil(http, client, 12); + const std::string reply = request(http, client, req); CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) == 0); client.close(); } @@ -390,8 +417,7 @@ TEST_CASE("DELETE /api/dir removes a file and a non-empty folder on the card, vi mm::platform::TcpConnection client; REQUIRE(connectAndAccept(http, client)); const char* req = "DELETE /api/dir?path=/sd/tree HTTP/1.1\r\n\r\n"; - REQUIRE(client.write(reinterpret_cast(req), std::strlen(req))); - const std::string reply = pumpUntil(http, client, 12); + const std::string reply = request(http, client, req); CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) == 0); client.close(); } @@ -413,8 +439,7 @@ TEST_CASE("DELETE /api/dir on /sd itself is refused, leaving the card untouched" mm::platform::TcpConnection client; REQUIRE(connectAndAccept(http, client)); const char* req = "DELETE /api/dir?path=/sd HTTP/1.1\r\n\r\n"; - REQUIRE(client.write(reinterpret_cast(req), std::strlen(req))); - const std::string reply = pumpUntil(http, client, 12); + const std::string reply = request(http, client, req); CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 500", 12) == 0); client.close(); diff --git a/test/unit/core/unit_ModuleFactory.cpp b/test/unit/core/unit_ModuleFactory.cpp index dbc617c3b..f5a130222 100644 --- a/test/unit/core/unit_ModuleFactory.cpp +++ b/test/unit/core/unit_ModuleFactory.cpp @@ -115,6 +115,8 @@ class HeapCountedStub : public mm::MoonModule { public: static inline int heapBuilt = 0; void* operator new(size_t size) noexcept { heapBuilt++; return mm::MoonModule::operator new(size); } + // Paired with the new above, which GCC requires of a class-specific allocator. + void operator delete(void* ptr) noexcept { mm::MoonModule::operator delete(ptr); } uint8_t big[8192] = {}; ///< larger than a classic ESP32's boot-task stack can spare }; } // namespace diff --git a/test/unit/core/unit_SdCardModule.cpp b/test/unit/core/unit_SdCardModule.cpp deleted file mode 100644 index 5e00b3b0c..000000000 --- a/test/unit/core/unit_SdCardModule.cpp +++ /dev/null @@ -1,254 +0,0 @@ -/// @module SdCardModule -/// @also I2cBusModule - -/// The SD card is runtime configuration, like AudioService's mic pins. -/// `hasSdCard` is forced true on desktop precisely so these tests exercise the real mount/remount/format path against a temp-dir-backed "/sd", not only the no-hardware stub a chip without the host takes. - -#include "doctest.h" -#include "core/system/SdCardModule.h" -#include "platform/platform.h" - -#include -#include -#include - -using namespace mm; - -namespace { - -const ControlDescriptor* find(const SdCardModule& sd, const char* name) { - for (uint8_t i = 0; i < sd.controls().count(); i++) - if (std::strcmp(sd.controls()[i].name, name) == 0) return &sd.controls()[i]; - return nullptr; -} - -void setPin(SdCardModule& sd, const char* name, int value) { - const auto* c = find(sd, name); - REQUIRE(c != nullptr); - *static_cast(c->ptr) = static_cast(value); -} - -// A card on a fresh temp filesystem root; the Waveshare P4-ETH's own pins, since any valid set does. -void wireRequiredPins(SdCardModule& sd) { - setPin(sd, "clkPin", 43); - setPin(sd, "cmdPin", 44); - setPin(sd, "d0Pin", 39); -} - -struct Rig { - char root[256]; - SdCardModule sd; - Rig() { - static unsigned counter = 0; - std::snprintf(root, sizeof(root), "%s/mm_sd_test_%u", - std::filesystem::temp_directory_path().string().c_str(), counter++); - std::filesystem::remove_all(root); - std::filesystem::create_directories(root); - platform::fsSetRoot(root); - sd.setTypeName("SdCardModule"); - sd.defineControls(); - } - // NOLINTNEXTLINE(bugprone-exception-escape) - ~Rig() noexcept { - sd.release(); - platform::fsSetRoot(""); - platform::setTestSdCardPresent(true); - std::error_code ec; - std::filesystem::remove_all(root, ec); - } -}; - -// Wires the required pins and mounts, for a test whose point starts after that. -void mountRig(SdCardModule& sd) { - wireRequiredPins(sd); - sd.prepare(); - REQUIRE(find(sd, "card (KiB)") != nullptr); -} - -// Writes "hello" to /sd/clip.bin and reopens it for reading, so a test can hold the busy guard open without repeating the round trip. -platform::FsReadStream openClipForReading() { - platform::FsWriteStream w{}; - REQUIRE(platform::fsWriteStreamOpen(w, "/sd/clip.bin")); - REQUIRE(platform::fsWriteStreamWrite(w, "hello", 5)); - REQUIRE(platform::fsWriteStreamClose(w, true)); - platform::FsReadStream rs{}; - REQUIRE(platform::fsReadStreamOpen(rs, "/sd/clip.bin")); - return rs; -} - -} // namespace - -TEST_CASE("the SD pins are never hidden: a board without hasSdCard simply never boots this module") { - SdCardModule sd; - sd.defineControls(); - for (const char* name : {"clkPin", "cmdPin", "d0Pin", "d1Pin", "d2Pin", "d3Pin", "pwrPin"}) { - const auto* c = find(sd, name); - REQUIRE(c != nullptr); - CHECK_FALSE(c->hidden); - } -} - -TEST_CASE("SdCardModule is a Service") { - SdCardModule sd; - CHECK(sd.role() == ModuleRole::Service); -} - -TEST_CASE("every SD pin affects prepare, so an edit remounts the card; format does not") { - SdCardModule sd; - for (const char* name : {"clkPin", "cmdPin", "d0Pin", "d1Pin", "d2Pin", "d3Pin", "pwrPin"}) - CHECK(sd.affectsPrepare(name)); - CHECK_FALSE(sd.affectsPrepare("format")); -} - -TEST_CASE("prepare refuses to mount with no pins set") { - Rig r; - r.sd.prepare(); - REQUIRE(r.sd.status() != nullptr); - CHECK(std::string(r.sd.status()) == "set the SD card pins first"); - CHECK(find(r.sd, "card (KiB)") == nullptr); -} - -TEST_CASE("prepare mounts once the required pins are set, and the gauge appears") { - Rig r; - wireRequiredPins(r.sd); - r.sd.prepare(); - CHECK(r.sd.status() == nullptr); - CHECK(std::filesystem::exists(std::string(r.root) + "/sd")); - CHECK(find(r.sd, "card (KiB)") != nullptr); -} - -TEST_CASE("a pin edit remounts the card live, the same contract I2cBusModule's sda/scl have") { - Rig r; - wireRequiredPins(r.sd); - r.sd.prepare(); - REQUIRE(find(r.sd, "card (KiB)") != nullptr); - - // Unset a required pin: prepare() must unmount on the next pass, same as the live-reconfig contract promises. - setPin(r.sd, "d0Pin", -1); - r.sd.prepare(); - CHECK(find(r.sd, "card (KiB)") == nullptr); - REQUIRE(r.sd.status() != nullptr); - CHECK(std::string(r.sd.status()) == "set the SD card pins first"); -} - -TEST_CASE("release unmounts; it is harmless when nothing was mounted") { - Rig r; - r.sd.release(); // nothing mounted yet: must not crash or misbehave - CHECK(find(r.sd, "card (KiB)") == nullptr); - - wireRequiredPins(r.sd); - r.sd.prepare(); - REQUIRE(find(r.sd, "card (KiB)") != nullptr); - r.sd.release(); - // The card still exists on disk; this instance only let go of it. - CHECK(std::filesystem::exists(std::string(r.root) + "/sd")); -} - -TEST_CASE("format refuses while unmounted, and wipes the card while mounted") { - Rig r; - r.sd.onControlChanged("format"); - REQUIRE(r.sd.status() != nullptr); - CHECK(std::string(r.sd.status()) == "mount a card before formatting"); - - wireRequiredPins(r.sd); - r.sd.prepare(); - std::filesystem::create_directories(std::string(r.root) + "/sd/leftover"); - REQUIRE(std::filesystem::exists(std::string(r.root) + "/sd/leftover")); - - r.sd.onControlChanged("format"); - CHECK(r.sd.status() == nullptr); - CHECK_FALSE(std::filesystem::exists(std::string(r.root) + "/sd/leftover")); // wiped - CHECK(std::filesystem::exists(std::string(r.root) + "/sd")); // recreated, still mounted -} - -TEST_CASE("an unrelated prepare does not remount when the pins have not changed") { - Rig r; - mountRig(r.sd); - - platform::FsWriteStream w{}; - REQUIRE(platform::fsWriteStreamOpen(w, "/sd/clip.bin")); - - r.sd.prepare(); // same pins: prepare() runs tree-wide on any affectsPrepare change, so this must be a no-op - CHECK(r.sd.status() == nullptr); // never tried to unmount, so no "busy" status either - CHECK(find(r.sd, "card (KiB)") != nullptr); // mount state undisturbed - - REQUIRE(platform::fsWriteStreamWrite(w, "hello", 5)); // the stream the no-op prepare() did not touch - REQUIRE(platform::fsWriteStreamClose(w, true)); - CHECK(std::filesystem::exists(std::string(r.root) + "/sd/clip.bin")); -} - -TEST_CASE("format refuses while a download stream is open, and succeeds once it closes") { - Rig r; - mountRig(r.sd); - - platform::FsReadStream rs = openClipForReading(); - - r.sd.onControlChanged("format"); // a download is in flight - REQUIRE(r.sd.status() != nullptr); - CHECK(std::string(r.sd.status()) == "busy: transfer in progress"); - CHECK(std::filesystem::exists(std::string(r.root) + "/sd/clip.bin")); // not wiped - - platform::fsReadStreamClose(rs); - r.sd.onControlChanged("format"); // the stream is gone: format now runs - CHECK(r.sd.status() == nullptr); - CHECK_FALSE(std::filesystem::exists(std::string(r.root) + "/sd/clip.bin")); -} - -TEST_CASE("tick1s is a no-op while unmounted, and refreshes the gauge once mounted") { - Rig r; - for (int i = 0; i < 120; i++) r.sd.tick1s(); // two minutes' worth, nothing to show - CHECK(find(r.sd, "card (KiB)") == nullptr); - - wireRequiredPins(r.sd); - r.sd.prepare(); - REQUIRE(find(r.sd, "card (KiB)") != nullptr); - for (int i = 0; i < 60; i++) r.sd.tick1s(); // the minute-paced scan fires once - CHECK(find(r.sd, "card (KiB)") != nullptr); -} - -TEST_CASE("a pulled card is noticed within a second: tick1s auto-unmounts with no pin touch") { - Rig r; - mountRig(r.sd); - - platform::setTestSdCardPresent(false); // simulate the card being pulled - r.sd.tick1s(); - CHECK(find(r.sd, "card (KiB)") == nullptr); - REQUIRE(r.sd.status() != nullptr); - CHECK(std::string(r.sd.status()) == "card removed"); -} - -TEST_CASE("a pull mid-transfer waits for the busy guard, same as a manual unmount would") { - Rig r; - mountRig(r.sd); - - platform::FsReadStream rs = openClipForReading(); // an open stream holds the busy guard - - platform::setTestSdCardPresent(false); - r.sd.tick1s(); - // Refused underneath the open stream: still mounted, no "card removed" status yet. - CHECK(find(r.sd, "card (KiB)") != nullptr); - CHECK(r.sd.status() == nullptr); - - platform::fsReadStreamClose(rs); - r.sd.tick1s(); // the next poll succeeds now that nothing holds the guard - CHECK(find(r.sd, "card (KiB)") == nullptr); - REQUIRE(r.sd.status() != nullptr); - CHECK(std::string(r.sd.status()) == "card removed"); -} - -TEST_CASE("a reinserted card is noticed within a few seconds: tick1s auto-remounts with no pin touch") { - Rig r; - mountRig(r.sd); - platform::setTestSdCardPresent(false); - r.sd.tick1s(); - REQUIRE(find(r.sd, "card (KiB)") == nullptr); // auto-unmounted, as above - - platform::setTestSdCardPresent(true); // simulate reinsertion - for (int i = 0; i < 4; i++) { - r.sd.tick1s(); - CHECK(find(r.sd, "card (KiB)") == nullptr); // the retry is paced slower than every tick - } - r.sd.tick1s(); // the 5th tick since going unmounted: the retry interval elapses - CHECK(find(r.sd, "card (KiB)") != nullptr); - CHECK(r.sd.status() == nullptr); -} diff --git a/test/unit/core/unit_SdCardService.cpp b/test/unit/core/unit_SdCardService.cpp new file mode 100644 index 000000000..f2f463689 --- /dev/null +++ b/test/unit/core/unit_SdCardService.cpp @@ -0,0 +1,430 @@ +/// @module SdCardService +/// @also I2cBusModule + +/// `hasSdCard` is true on the desktop, so these tests run the real mount, remount and format against a temp-dir `/sd`, the card being runtime configuration like AudioService's mic pins. + +#include "doctest.h" +#include "core/services/SdCardService.h" +#include "platform/platform.h" + +#include +#include +#include +#include +#include +#include + +using namespace mm; + +namespace { + +const ControlDescriptor* find(const SdCardService& sd, const char* name) { + for (uint8_t i = 0; i < sd.controls().count(); i++) + if (std::strcmp(sd.controls()[i].name, name) == 0) return &sd.controls()[i]; + return nullptr; +} + +void setPin(SdCardService& sd, const char* name, int value) { + const auto* c = find(sd, name); + REQUIRE(c != nullptr); + *static_cast(c->ptr) = static_cast(value); +} + +// A card on a fresh temp filesystem root; the Waveshare P4-ETH's own pins, since any valid set does. +void wireRequiredPins(SdCardService& sd) { + setPin(sd, "clkPin", 43); + setPin(sd, "cmdPin", 44); + setPin(sd, "d0Pin", 39); +} + +struct Rig { + char root[256]; + SdCardService sd; + Rig() { + static unsigned counter = 0; + std::snprintf(root, sizeof(root), "%s/mm_sd_test_%u", + std::filesystem::temp_directory_path().string().c_str(), counter++); + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + platform::fsSetRoot(root); + sd.setTypeName("SdCardService"); + sd.defineControls(); + } + // NOLINTNEXTLINE(bugprone-exception-escape) + ~Rig() noexcept { + platform::setTestSdFormatHeld(false); + sd.release(); + platform::fsSetRoot(""); + platform::setTestSdCardPresent(true); + platform::setTestSdCardHasFilesystem(true); + platform::setTestSdCardReadable(true); + std::error_code ec; + std::filesystem::remove_all(root, ec); + } +}; + +// The label of the selected board preset. +std::string boardOf(const SdCardService& sd) { + const auto* c = find(sd, "board"); + REQUIRE(c != nullptr); + const auto* opts = reinterpret_cast(c->aux); + return opts[*static_cast(c->ptr)]; +} + +// Selects the board preset by label and rebuilds, as the scheduler does on a select change. +void selectBoard(SdCardService& sd, const char* label) { + const auto* c = find(sd, "board"); + REQUIRE(c != nullptr); + const auto* opts = reinterpret_cast(c->aux); + for (uint8_t k = 0; k < c->max; k++) + if (std::strcmp(opts[k], label) == 0) *static_cast(c->ptr) = k; + sd.rebuildControls(); +} + +// Whether a pull was noticed: the gauge gone and the status naming the removal. +bool unmountedAfterPull(const SdCardService& sd) { + return find(sd, "card (KiB)") == nullptr && sd.status() && std::string(sd.status()).rfind("card removed", 0) == 0; +} + +// Wires the required pins and mounts, for a test whose point starts after that. +void mountRig(SdCardService& sd) { + wireRequiredPins(sd); + sd.prepare(); + REQUIRE(find(sd, "card (KiB)") != nullptr); +} + +// Writes "hello" to /sd/clip.bin and reopens it for reading, so a test can hold the busy guard open without repeating the round trip. +platform::FsReadStream openClipForReading() { + platform::FsWriteStream w{}; + REQUIRE(platform::fsWriteStreamOpen(w, "/sd/clip.bin")); + REQUIRE(platform::fsWriteStreamWrite(w, "hello", 5)); + REQUIRE(platform::fsWriteStreamClose(w, true)); + platform::FsReadStream rs{}; + REQUIRE(platform::fsReadStreamOpen(rs, "/sd/clip.bin")); + return rs; +} + +} // namespace + +TEST_CASE("the pins show under Custom, the default, and hide under a board preset that sets them") { + SdCardService sd; + sd.rebuildControls(); + CHECK(boardOf(sd) == "Custom"); + for (const char* name : {"clkPin", "cmdPin", "d0Pin", "d1Pin", "d2Pin", "d3Pin", "pwrPin"}) CHECK_FALSE(find(sd, name)->hidden); + + selectBoard(sd, "P4 SDMMC"); + for (const char* name : {"clkPin", "cmdPin", "d0Pin", "d1Pin", "d2Pin", "d3Pin", "pwrPin"}) CHECK(find(sd, name)->hidden); +} + +TEST_CASE("a board preset writes its wiring and the mount follows; Custom keeps it to edit") { + Rig r; + selectBoard(r.sd, "P4 SDMMC"); + CHECK(*static_cast(find(r.sd, "clkPin")->ptr) == 43); + CHECK(*static_cast(find(r.sd, "d3Pin")->ptr) == 42); + CHECK(*static_cast(find(r.sd, "pwrPin")->ptr) == 45); + CHECK(r.sd.affectsPrepare("board")); + r.sd.prepare(); + CHECK(find(r.sd, "card (KiB)") != nullptr); + + selectBoard(r.sd, "Custom"); + CHECK_FALSE(find(r.sd, "clkPin")->hidden); + CHECK(*static_cast(find(r.sd, "clkPin")->ptr) == 43); // Custom writes nothing, so the wiring stays to edit +} + +// What a restore does with a config that holds a board's pins and no saved `board`: the card opens on that board's name. +TEST_CASE("pins that match a board preset open on its name rather than on Custom") { + SdCardService sd; + sd.rebuildControls(); + for (const auto& [name, value] : {std::pair{"clkPin", 43}, {"cmdPin", 44}, {"d0Pin", 39}, + {"d1Pin", 40}, {"d2Pin", 41}, {"d3Pin", 42}, {"pwrPin", 45}}) setPin(sd, name, value); + sd.rebuildControls(); + CHECK(boardOf(sd) == "P4 SDMMC"); + sd.rebuildControls(); // and the seed keeps a chosen preset on the next rebuild + CHECK(boardOf(sd) == "P4 SDMMC"); +} + +// Asked per chip rather than per build, since a host test runs as the desktop, which previews every preset. +namespace { +using Chip = SdCardService::SdPresetChip; +constexpr SdCardService::SdChip kClassic{false, true, false}, kP4{false, false, true}, kS3{false, false, false}, kDesktop{true, true, false}; +} // namespace + +TEST_CASE("each chip offers only the SD board presets it can wire") { + for (const auto& chip : {kClassic, kP4, kS3, kDesktop}) CHECK(SdCardService::presetFits(Chip::Any, chip)); + CHECK(SdCardService::presetFits(Chip::P4, kP4)); + CHECK_FALSE(SdCardService::presetFits(Chip::P4, kClassic)); + CHECK_FALSE(SdCardService::presetFits(Chip::P4, kS3)); // an S3 or S31 has no board preset + CHECK(SdCardService::presetFits(Chip::Classic, kClassic)); + CHECK_FALSE(SdCardService::presetFits(Chip::Classic, kP4)); + CHECK_FALSE(SdCardService::presetFits(Chip::Classic, kS3)); + for (Chip c : {Chip::Classic, Chip::P4}) CHECK(SdCardService::presetFits(c, kDesktop)); // the desktop previews them all +} + +// Every P4 board with a slot wires the same pins, so a P4 opens on that preset; a classic has two to choose from, an S3 none, and the desktop previews them all. +TEST_CASE("a P4 opens on its one SD board preset, every other build on Custom") { + CHECK(SdCardService::opensOnSolePreset(kP4)); + CHECK_FALSE(SdCardService::opensOnSolePreset(kClassic)); + CHECK_FALSE(SdCardService::opensOnSolePreset(kS3)); + CHECK_FALSE(SdCardService::opensOnSolePreset(kDesktop)); +} + +TEST_CASE("an SPI preset sets the bus, warns about long writes once mounted, and the mount needs its CS") { + Rig r; + selectBoard(r.sd, "Classic SPI"); + CHECK(*static_cast(find(r.sd, "bus")->ptr) == static_cast(platform::SdBus::Spi)); + CHECK(*static_cast(find(r.sd, "d0Pin")->ptr) == 36); // MISO + CHECK(*static_cast(find(r.sd, "d3Pin")->ptr) == 16); // CS + r.sd.prepare(); + CHECK(find(r.sd, "card (KiB)") != nullptr); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()).rfind("SPI: writes over 50 KB", 0) == 0); + + selectBoard(r.sd, "Custom"); + CHECK_FALSE(find(r.sd, "bus")->hidden); + CHECK(find(r.sd, "d1Pin")->hidden); + CHECK(find(r.sd, "d2Pin")->hidden); + CHECK_FALSE(find(r.sd, "d3Pin")->hidden); + + setPin(r.sd, "d3Pin", -1); // SPI with no CS cannot select the card + r.sd.prepare(); + CHECK(find(r.sd, "card (KiB)") == nullptr); +} + +TEST_CASE("SdCardService is a Service") { + SdCardService sd; + CHECK(sd.role() == ModuleRole::Service); +} + +TEST_CASE("every SD pin affects prepare, so an edit remounts the card; format does not") { + SdCardService sd; + for (const char* name : {"clkPin", "cmdPin", "d0Pin", "d1Pin", "d2Pin", "d3Pin", "pwrPin"}) + CHECK(sd.affectsPrepare(name)); + CHECK_FALSE(sd.affectsPrepare("format")); +} + +TEST_CASE("prepare refuses to mount with no pins set") { + Rig r; + r.sd.prepare(); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()) == "set the SD card pins first"); + CHECK(find(r.sd, "card (KiB)") == nullptr); +} + +TEST_CASE("prepare mounts once the required pins are set, and the gauge appears") { + Rig r; + wireRequiredPins(r.sd); + r.sd.prepare(); + CHECK(r.sd.status() == nullptr); + CHECK(std::filesystem::exists(std::string(r.root) + "/sd")); + CHECK(find(r.sd, "card (KiB)") != nullptr); +} + +TEST_CASE("a pin edit remounts the card live, the same contract I2cBusModule's sda/scl have") { + Rig r; + wireRequiredPins(r.sd); + r.sd.prepare(); + REQUIRE(find(r.sd, "card (KiB)") != nullptr); + + // Unset a required pin: prepare() must unmount on the next pass, same as the live-reconfig contract promises. + setPin(r.sd, "d0Pin", -1); + r.sd.prepare(); + CHECK(find(r.sd, "card (KiB)") == nullptr); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()) == "set the SD card pins first"); +} + +TEST_CASE("release unmounts; it is harmless when nothing was mounted") { + Rig r; + r.sd.release(); // nothing mounted yet: must not crash or misbehave + CHECK(find(r.sd, "card (KiB)") == nullptr); + + wireRequiredPins(r.sd); + r.sd.prepare(); + REQUIRE(find(r.sd, "card (KiB)") != nullptr); + r.sd.release(); + // The card still exists on disk; this instance only let go of it. + CHECK(std::filesystem::exists(std::string(r.root) + "/sd")); +} + +TEST_CASE("format refuses while unmounted, and wipes the card while mounted") { + Rig r; + r.sd.onControlChanged("format"); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()) == "mount a card before formatting"); + + wireRequiredPins(r.sd); + r.sd.prepare(); + std::filesystem::create_directories(std::string(r.root) + "/sd/leftover"); + REQUIRE(std::filesystem::exists(std::string(r.root) + "/sd/leftover")); + + r.sd.onControlChanged("format"); + CHECK(r.sd.status() == nullptr); + CHECK_FALSE(std::filesystem::exists(std::string(r.root) + "/sd/leftover")); // wiped + CHECK(std::filesystem::exists(std::string(r.root) + "/sd")); // recreated, still mounted +} + +TEST_CASE("an unrelated prepare does not remount when the pins have not changed") { + Rig r; + mountRig(r.sd); + + platform::FsWriteStream w{}; + REQUIRE(platform::fsWriteStreamOpen(w, "/sd/clip.bin")); + + r.sd.prepare(); // same pins: prepare() runs tree-wide on any affectsPrepare change, so this must be a no-op + CHECK(r.sd.status() == nullptr); // never tried to unmount, so no "busy" status either + CHECK(find(r.sd, "card (KiB)") != nullptr); // mount state undisturbed + + REQUIRE(platform::fsWriteStreamWrite(w, "hello", 5)); // the stream the no-op prepare() did not touch + REQUIRE(platform::fsWriteStreamClose(w, true)); + CHECK(std::filesystem::exists(std::string(r.root) + "/sd/clip.bin")); +} + +TEST_CASE("format refuses while a download stream is open, and succeeds once it closes") { + Rig r; + mountRig(r.sd); + + platform::FsReadStream rs = openClipForReading(); + + r.sd.onControlChanged("format"); // a download is in flight + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()) == "busy: transfer in progress"); + CHECK(std::filesystem::exists(std::string(r.root) + "/sd/clip.bin")); // not wiped + + platform::fsReadStreamClose(rs); + r.sd.onControlChanged("format"); // the stream is gone: format now runs + CHECK(r.sd.status() == nullptr); + CHECK_FALSE(std::filesystem::exists(std::string(r.root) + "/sd/clip.bin")); +} + +TEST_CASE("tick1s is a no-op while unmounted, and refreshes the gauge once mounted") { + Rig r; + for (int i = 0; i < 120; i++) r.sd.tick1s(); // two minutes' worth, nothing to show + CHECK(find(r.sd, "card (KiB)") == nullptr); + + wireRequiredPins(r.sd); + r.sd.prepare(); + REQUIRE(find(r.sd, "card (KiB)") != nullptr); + for (int i = 0; i < 60; i++) r.sd.tick1s(); // the minute-paced scan fires once + CHECK(find(r.sd, "card (KiB)") != nullptr); +} + +TEST_CASE("a pulled card is noticed within a second: tick1s auto-unmounts with no pin touch") { + Rig r; + mountRig(r.sd); + + platform::setTestSdCardPresent(false); // simulate the card being pulled + r.sd.tick1s(); + CHECK(unmountedAfterPull(r.sd)); +} + +TEST_CASE("a pull mid-transfer waits for the busy guard, same as a manual unmount would") { + Rig r; + mountRig(r.sd); + + platform::FsReadStream rs = openClipForReading(); // an open stream holds the busy guard + + platform::setTestSdCardPresent(false); + r.sd.tick1s(); + // Refused underneath the open stream: still mounted, no "card removed" status yet. + CHECK(find(r.sd, "card (KiB)") != nullptr); + CHECK(r.sd.status() == nullptr); + + platform::fsReadStreamClose(rs); + r.sd.tick1s(); // the next poll succeeds now that nothing holds the guard + CHECK(unmountedAfterPull(r.sd)); +} + +// Mounting an empty slot stalls the render thread, so no timer and no unrelated prepare retries it; switching the module off and on tries again. +TEST_CASE("a card inserted after a failed mount mounts when the module is switched off and on, never on a timer") { + Rig r; + platform::setTestSdCardPresent(false); // an empty slot + wireRequiredPins(r.sd); + r.sd.prepare(); + REQUIRE(find(r.sd, "card (KiB)") == nullptr); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()).rfind("no card answered", 0) == 0); + + platform::setTestSdCardPresent(true); // inserted later + r.sd.prepare(); // an unrelated prepare, same pins: the slot is not tried again + for (int i = 0; i < 120; i++) r.sd.tick1s(); // nor on a timer + CHECK(find(r.sd, "card (KiB)") == nullptr); + + r.sd.release(); // switched off + r.sd.prepare(); // and on + CHECK(find(r.sd, "card (KiB)") != nullptr); + CHECK(r.sd.status() == nullptr); +} + +// A card that answers but holds no FAT filesystem (fresh, or used in a camera) says so, and format writes one and mounts it; an empty slot stays refused. +TEST_CASE("a card without a FAT filesystem says so, and format makes one and mounts it") { + Rig r; + platform::setTestSdCardHasFilesystem(false); + wireRequiredPins(r.sd); + r.sd.prepare(); + REQUIRE(find(r.sd, "card (KiB)") == nullptr); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()).rfind("the card has no FAT filesystem", 0) == 0); + + r.sd.onControlChanged("format"); + CHECK(find(r.sd, "card (KiB)") != nullptr); // formatted and mounted in one press + CHECK(r.sd.status() == nullptr); +} + +TEST_CASE("format refuses on an empty slot: only a card without a filesystem is formatted unmounted") { + Rig r; + platform::setTestSdCardPresent(false); + wireRequiredPins(r.sd); + r.sd.prepare(); + r.sd.onControlChanged("format"); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()) == "mount a card before formatting"); + CHECK(find(r.sd, "card (KiB)") == nullptr); +} + +TEST_CASE("a card that answers but does not read says so, and format does not offer to fix it") { + Rig r; + platform::setTestSdCardReadable(false); + wireRequiredPins(r.sd); + r.sd.prepare(); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()).rfind("the card answers but does not read", 0) == 0); + + r.sd.onControlChanged("format"); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()) == "mount a card before formatting"); +} + +// A large card's format outlasts the watchdog, so it runs in the background and the module waits it out a tick at a time. +TEST_CASE("a format runs in the background: the card is busy and the module says formatting until it finishes") { + Rig r; + mountRig(r.sd); + platform::setTestSdFormatHeld(true); + + r.sd.onControlChanged("format"); + REQUIRE(r.sd.status() != nullptr); + CHECK(std::string(r.sd.status()).rfind("formatting", 0) == 0); + CHECK(find(r.sd, "card (KiB)") == nullptr); // unmounted while it formats + CHECK(platform::sdCardBusy()); + CHECK_FALSE(platform::sdUnmount()); + + setPin(r.sd, "d0Pin", 40); // a pin edit mid-format waits too + r.sd.prepare(); + r.sd.onControlChanged("format"); // and so does a second press + for (int i = 0; i < 30; i++) r.sd.tick1s(); + CHECK(std::string(r.sd.status()).rfind("formatting", 0) == 0); + + platform::setTestSdFormatHeld(false); // the format finishes + r.sd.tick1s(); + CHECK(r.sd.status() == nullptr); + CHECK(find(r.sd, "card (KiB)") != nullptr); + CHECK_FALSE(platform::sdCardBusy()); +} + +// A card that stops answering mid-write cannot delete its temporary file, so the next mount empties the staging folder. +TEST_CASE("a mount clears the temporary files a failed write left in the staging folder") { + Rig r; + std::filesystem::create_directories(std::string(r.root) + "/sd/.tmp"); + { std::FILE* f = std::fopen((std::string(r.root) + "/sd/.tmp/w7.tmp").c_str(), "wb"); REQUIRE(f); std::fclose(f); } + mountRig(r.sd); + CHECK_FALSE(std::filesystem::exists(std::string(r.root) + "/sd/.tmp/w7.tmp")); +} From 61912727c6bd15a4d63f19d04de5509d5b52f0ba Mon Sep 17 00:00:00 2001 From: ewowi Date: Tue, 13 Oct 2026 00:42:28 +0200 Subject: [PATCH 20/20] Process CodeRabbit's review and keep SD to images over 4 MB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SD ships only on images over 4 MB; the 4 MB Olimex loses it. A card's buffers are taken only while it is mounted, the SPI interrupt moves out of IRAM, and a few edge cases from review are closed: the card's root spelled "/sd//" can no longer be wiped, a write that fails to flush never replaces the old file, and a replaced file returns if the new one cannot take its name. Checks (pragmatic set): build_desktop, build_desktop --tests with the SD tests, build_esp32 esp32p4rev1-eth; collect_kpi skipped. Core - MM_SD_CARD off in the 4 MB layout's fragment; per-image opt-outs removed - SD buffers (FIL and probe sector) allocated at mount, freed at unmount; CONFIG_SPI_MASTER_ISR_IN_IRAM=n, the W5500 keeps IRAM - isMountRoot counts significant segments, so "/sd//" and "//" are mount roots - requestNamesSdPath routes through parseFilePath and isSdPath, so a relative sd/ path gets the card's limit - f_close checked before a commit; the old file steps aside in staging and returns if the rename fails UI - drop upload takes the upload panel per file, so later rows are not attached to a removed panel Tests - the card root with extra slashes is refused; a relative sd/ upload gets the card's limit; the multi-chunk test freezes the clock Docs/CI - deviceModels: the Olimex boards back to SD planned; spec and Kconfig state the over-4-MB rule Reviews - ๐Ÿ‡ isMountRoot and repeated slashes โ†’ done: "/sd//" listed the card's root and wiped it - ๐Ÿ‡ relative sd/ path got the flash limit โ†’ done, parseFilePath + isSdPath - ๐Ÿ‡ P4 should default to Custom โ†’ skipped: the same single-preset rule as Ethernet's P4 RMII, and every P4 board with a slot wires those pins - ๐Ÿ‡ f_close unchecked before commit โ†’ done - ๐Ÿ‡ commit deletes the old file first โ†’ done for a failed rename; crash recovery between the renames skipped, it needs a journal for a millisecond window - ๐Ÿ‡ upload panel reused after removal โ†’ done - ๐Ÿ‡ multi-chunk test depends on wall time โ†’ done, clock frozen Co-Authored-By: Claude Opus 5.5 --- docs/moonmodules/core/services.md | 2 +- docs/reference/metrics/code.md | 12 ++-- esp32/main/Kconfig.projbuild | 5 +- esp32/sdkconfig.defaults | 3 + esp32/sdkconfig.defaults.esp32s3-zero | 3 - esp32/sdkconfig.defaults.eth-spi | 3 + esp32/sdkconfig.defaults.moonbase-4mb | 3 + mooninstaller/deviceModels.json | 21 ++----- src/core/services/SdCardService.h | 2 +- src/core/system/FilesystemModule.cpp | 16 +++-- src/core/system/HttpServerModule.cpp | 16 +++-- src/platform/esp32/platform_config.h | 2 +- src/platform/esp32/platform_esp32_sdmmc.cpp | 59 ++++++++++++------- src/ui/app.js | 4 +- .../core/unit_HttpServerModule_sdcard.cpp | 36 +++++++++++ 15 files changed, 125 insertions(+), 62 deletions(-) diff --git a/docs/moonmodules/core/services.md b/docs/moonmodules/core/services.md index 112766537..302112507 100644 --- a/docs/moonmodules/core/services.md +++ b/docs/moonmodules/core/services.md @@ -344,7 +344,7 @@ The status line reports setup state ("set pin to receive" / "ready"), the learn ## SD Card, details -The presets: `P4 SDMMC` is the slot every P4 board with one wires, and a P4 opens on it. `Classic SDMMC (1-bit)` is the Olimex ESP32-Gateway's, and `Classic SPI` the QuinLED Dig-Octa's. SPI mode maps `clkPin` to SCK, `cmdPin` to MOSI, `d0Pin` to MISO and `d3Pin` to CS. Over SPI, reads hold but a write over about 50 KB can stop the card until a power cycle, so a card mounted that way says so on its status. +The presets: `P4 SDMMC` is the slot every P4 board with one wires, and a P4 opens on it. `Classic SDMMC (1-bit)` is the classic's own SD host pins, which the Olimex ESP32-Gateway wires, and `Classic SPI` the QuinLED Dig-Octa's. A 4 MB image carries no SD at all, so the 4 MB Olimex has none. SPI mode maps `clkPin` to SCK, `cmdPin` to MOSI, `d0Pin` to MISO and `d3Pin` to CS. Over SPI, reads hold but a write over about 50 KB can stop the card until a power cycle, so a card mounted that way says so on its status. Mounts live, in `prepare()` rather than `setup()`: editing a pin unmounts and remounts on the new wiring, no reboot needed, the same contract the [I2C bus](system.md#i2c-bus)'s `sda`/`scl` have. A pulled card is noticed within a second. A missing or unreadable card shows its own status instead of retrying, since a mount attempt on an empty slot stalls the render thread: insert the card, then switch the module off and on, edit a pin, or reboot. diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index 7aab1ee68..fe08fa103 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,14 +4,14 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows. -**1632 finding(s)** across 322 file(s). 2.83% of all lines are duplicated, 3732 lines. +**1629 finding(s)** across 322 file(s). 2.83% of all lines are duplicated, 3732 lines. ## By rule | Rule | Findings | |---|---:| | duplicated block | 768 | -| complex function | 293 | +| complex function | 290 | | blocking call on the render path | 192 | | deeply nested | 113 | | module type in the UI | 71 | @@ -46,8 +46,8 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta |---|---:| | `test` | 574 | | `light` | 421 | -| `core` | 359 | -| `platform` | 171 | +| `core` | 357 | +| `platform` | 170 | | `ui` | 74 | | `moondeck` | 24 | | `src` | 7 | @@ -166,7 +166,6 @@ The next function worth simplifying is at the top of each file's list. | `src/core/moonlive/MoonLive.cpp` | 4 | `moonlive::MoonLive::ensureArena`, complex function 21 | | `src/core/moonlive/MoonLiveScriptFile.h` | 4 | `moonlive::compileScriptFile`, complex function 20 | | `src/core/services/ButtonService.h` | 4 | `lines 96-118, also in src/core/services/InfraredService.h:113`, duplicated block 23 | -| `src/core/services/SdCardService.cpp` | 4 | `SdCardService::prepare`, complex function 11 | | `src/core/system/EthernetModule.h` | 4 | `EthernetModule::defineControls`, complex function 24 | | `src/core/system/FilesystemModule.cpp` | 4 | `FilesystemModule::removeTree`, complex function 13 | | `src/core/system/ImprovProvisioningModule.h` | 4 | `NetworkModule::setTxPowerSetting in tick1s`, blocking call on the render path 1 | @@ -229,6 +228,7 @@ The next function worth simplifying is at the top of each file's list. | `moondeck/scenario/run_scenario.py` | 2 | `lines 33-39, also in moondeck/docs/build_docs.py:43`, duplicated block 7 | | `src/core/moonlive/MoonLiveBuiltins.h` | 2 | `lines 208-219, also in src/core/moonlive/MoonLiveBuiltins.h:111`, duplicated block 12 | | `src/core/services/MidiService.h` | 2 | `MidiService::serveShare in tick20ms`, blocking call on the render path 1 | +| `src/core/services/SdCardService.cpp` | 2 | `SdCardService::finishFormat in tick1s`, blocking call on the render path 1 | | `src/core/system/DevicePlugin.h` | 2 | `lines 94-100, also in src/core/system/DevicePlugin.h:75`, duplicated block 7 | | `src/core/system/HttpServerModule.h` | 2 | `HttpServerModule in 17 files`, name spread 17 | | `src/core/system/I2cBusModule.h` | 2 | `I2cBusModule::scan`, complex function 18 | @@ -262,7 +262,6 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/moonlive_asm_xtensa.cpp` | 2 | `lines 481-487, also in src/platform/esp32/moonlive_asm_riscv.cpp:297`, duplicated block 7 | | `src/platform/esp32/moonlive_emit_riscv.cpp` | 2 | `lines 52-57, also in src/platform/esp32/moonlive_emit_riscv.cpp:25`, duplicated block 6 | | `src/platform/esp32/platform_esp32_fs.cpp` | 2 | `platform::fsList`, complex function 17 | -| `src/platform/esp32/platform_esp32_sdmmc.cpp` | 2 | `platform::usablePins`, complex function 13 | | `test/js/ui-midi-bridge.test.mjs` | 2 | `lines 4-16, also in test/js/ui-gamepad-bridge.test.mjs:4`, duplicated block 13 | | `test/unit/core/unit_AudioService_sync.cpp` | 2 | `lines 313-322, also in test/unit/core/unit_AudioService_sync.cpp:269`, duplicated block 10 | | `test/unit/core/unit_DevicesModule_hue.cpp` | 2 | `lines 63-69, also in test/unit/core/unit_DevicesModule_hue.cpp:42`, duplicated block 7 | @@ -348,6 +347,7 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/moonlive_asm_xtensa.h` | 1 | `lines 79-96, also in src/platform/esp32/moonlive_asm_riscv.h:74`, duplicated block 18 | | `src/platform/esp32/platform_config.h` | 1 | `lines 240-254, also in src/platform/desktop/platform_config.h:71`, duplicated block 15 | | `src/platform/esp32/platform_esp32_i2s.cpp` | 1 | `platform::audioMicInit`, complex function 11 | +| `src/platform/esp32/platform_esp32_sdmmc.cpp` | 1 | `platform::mountCard`, complex function 12 | | `src/platform/esp32/platform_esp32_tasks.cpp` | 1 | `platform::taskSnapshot`, complex function 12 | | `src/platform/platform.h` | 1 | `platform.h`, large file 1401 | | `src/ui/install-picker.js` | 1 | `install-picker.js`, large file 1048 | diff --git a/esp32/main/Kconfig.projbuild b/esp32/main/Kconfig.projbuild index 39b380c19..1208cc71a 100644 --- a/esp32/main/Kconfig.projbuild +++ b/esp32/main/Kconfig.projbuild @@ -50,8 +50,7 @@ menu "MoonLight" bool "microSD card support (SDMMC or SPI)" default y help - Compiles the FatFs SD card backend, on every chip with an SD host. An - image whose boards have no slot and whose flash is tight sets it off, - as the S3-Zero does. + Compiles the FatFs SD card backend, on every chip with an SD host and + more than 4 MB of flash: the 4 MB layout's fragment sets it off. endmenu diff --git a/esp32/sdkconfig.defaults b/esp32/sdkconfig.defaults index f21adfd1f..24d6c241f 100644 --- a/esp32/sdkconfig.defaults +++ b/esp32/sdkconfig.defaults @@ -110,3 +110,6 @@ CONFIG_PARLIO_RX_ISR_CACHE_SAFE=y # MALLOC_CAP_DEFAULT and stay PSRAM-eligible, which the same ISR dereferences the moment our # callback returns. An IRAM handler reading a PSRAM queue is the same fault one frame later. CONFIG_PARLIO_OBJ_CACHE_SAFE=y + +# The SPI master's interrupt in flash, not IRAM: no SPI user here needs it while the flash cache is off, and IRAM is the classic's scarcest memory. +CONFIG_SPI_MASTER_ISR_IN_IRAM=n diff --git a/esp32/sdkconfig.defaults.esp32s3-zero b/esp32/sdkconfig.defaults.esp32s3-zero index 038cdb799..703b37769 100644 --- a/esp32/sdkconfig.defaults.esp32s3-zero +++ b/esp32/sdkconfig.defaults.esp32s3-zero @@ -29,6 +29,3 @@ CONFIG_SPIRAM_MODE_QUAD=y # hardware W^X wall. CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=n CONFIG_HEAP_HAS_EXEC_HEAP=y - -# No SD card backend: none of the S3-Zero boards has a slot, and 4 MB of flash is nearly full. -CONFIG_MM_SD_CARD=n diff --git a/esp32/sdkconfig.defaults.eth-spi b/esp32/sdkconfig.defaults.eth-spi index adeaa98d4..beb10fb22 100644 --- a/esp32/sdkconfig.defaults.eth-spi +++ b/esp32/sdkconfig.defaults.eth-spi @@ -16,3 +16,6 @@ # pulled in esp32/main/idf_component.yml and gated to the S3). Setting the removed # CONFIG_ETH_SPI_ETHERNET_W5500 here only produced an "unknown kconfig symbol" warning. CONFIG_ETH_USE_SPI_ETHERNET=y + +# The W5500 keeps the SPI master's interrupt in IRAM, as it shipped. +CONFIG_SPI_MASTER_ISR_IN_IRAM=y diff --git a/esp32/sdkconfig.defaults.moonbase-4mb b/esp32/sdkconfig.defaults.moonbase-4mb index ae3902c42..b2d0ffbe8 100644 --- a/esp32/sdkconfig.defaults.moonbase-4mb +++ b/esp32/sdkconfig.defaults.moonbase-4mb @@ -1 +1,4 @@ CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/4mb_moonbase.csv" + +# No SD card backend on a 4 MB image: its flash is better spent on everything else, so SD ships only where there is more. +CONFIG_MM_SD_CARD=n diff --git a/mooninstaller/deviceModels.json b/mooninstaller/deviceModels.json index ee52a0aa5..03a1361e4 100644 --- a/mooninstaller/deviceModels.json +++ b/mooninstaller/deviceModels.json @@ -13,10 +13,11 @@ "LEDs", "WiFi", "Ethernet", - "Buttons", + "Buttons" + ], + "planned": [ "SD card" ], - "planned": [], "state": { "System": { "deviceModel": "Olimex ESP32-Gateway Rev G" @@ -46,10 +47,6 @@ "kind": "toggle" } ] - }, - "SdCard": { - "type": "SdCardService", - "board": "Classic SDMMC (1-bit)" } } } @@ -966,11 +963,11 @@ "supported": [ "LEDs", "WiFi", - "Ethernet", - "SD card" + "Ethernet" ], "planned": [ - "Button" + "Button", + "SD card" ], "state": { "System": { @@ -1005,12 +1002,6 @@ "Layer": { "type": "Layer" } - }, - "Services": { - "SdCard": { - "type": "SdCardService", - "board": "Classic SDMMC (1-bit)" - } } }, "flashBaud": 460800 diff --git a/src/core/services/SdCardService.h b/src/core/services/SdCardService.h index 827687dbc..46c323c98 100644 --- a/src/core/services/SdCardService.h +++ b/src/core/services/SdCardService.h @@ -107,7 +107,7 @@ class SdCardService : public MoonModule { static constexpr SdPreset kSdPresets[] = { // The P4's slot 0 IOMUX pins, which every P4 board with a slot wires; GPIO45 drives the card's power switch where the board fits its gate resistor. {"P4 SDMMC", SdPresetChip::P4, platform::SdBus::Sdmmc, 43, 44, 39, 40, 41, 42, 45, false}, - // The classic's slot 1 IOMUX pins in 1-bit mode, as the Olimex ESP32-Gateway wires them. + // The classic's slot 1 IOMUX pins in 1-bit mode, the only pins its SD host reaches, as the Olimex ESP32-Gateway wires them. {"Classic SDMMC (1-bit)", SdPresetChip::Classic, platform::SdBus::Sdmmc, 14, 15, 2, -1, -1, -1, -1, false}, // SPI mode as the QuinLED Dig-Octa wires it: SCK 14, MOSI 15, MISO 36, CS 16. {"Classic SPI", SdPresetChip::Classic, platform::SdBus::Spi, 14, 15, 36, -1, -1, 16, -1, false}, diff --git a/src/core/system/FilesystemModule.cpp b/src/core/system/FilesystemModule.cpp index 2bfa97ff5..59a84eca0 100644 --- a/src/core/system/FilesystemModule.cpp +++ b/src/core/system/FilesystemModule.cpp @@ -39,12 +39,20 @@ void collectEntry(const char* name, bool isDir, uint32_t, void* user) { lvl->count++; } -// True for a mount's root - "/" or "/sd", trailing slash and all - which removeTree refuses to wipe: a user deletes what is inside a mount, not the mount. +// True for a mount's root, which removeTree refuses to wipe: a user deletes what is inside a mount, not the mount. Counted in significant segments, since the filesystems skip repeated slashes and "." alike, so "//" is the flash root and "/sd//" the card's. bool isMountRoot(const char* path) { if (!path || path[0] != '/') return false; - if (path[1] == '\0') return true; // "/" - // "/sd" or "/sd/", through the one home of the SD prefix - return platform::isSdPath(path) && (path[3] == '\0' || (path[3] == '/' && path[4] == '\0')); + uint8_t segments = 0; + bool firstIsSd = false; + for (const char* p = path; *p;) { + while (*p == '/') p++; + const char* start = p; + while (*p && *p != '/') p++; + const size_t len = static_cast(p - start); + if (len == 0 || (len == 1 && *start == '.')) continue; + if (++segments == 1) firstIsSd = len == 2 && start[0] == 's' && start[1] == 'd'; + } + return segments == 0 || (segments == 1 && firstIsSd && platform::isSdPath("/sd")); // the card's root only where "/sd" is the card } } // namespace diff --git a/src/core/system/HttpServerModule.cpp b/src/core/system/HttpServerModule.cpp index 2f59c081e..7fb21458d 100644 --- a/src/core/system/HttpServerModule.cpp +++ b/src/core/system/HttpServerModule.cpp @@ -309,13 +309,17 @@ bool HttpServerModule::anyWsClientOnAccessPoint() const MM_NONBLOCKING { // A cheap scan of the query for a "/sd" path, which only sizes the Content-Length ceiling; parseFilePath is the real guard, downstream. static bool requestNamesSdPath(const char* req) { + const char* query = std::strchr(req, '?'); const char* lineEnd = std::strstr(req, "\r\n"); - const size_t lineLen = lineEnd ? static_cast(lineEnd - req) : std::strlen(req); - for (const char* needle : {"path=/sd", "path=%2Fsd", "path=%2fsd"}) { - const char* hit = std::strstr(req, needle); - if (hit && static_cast(hit - req) < lineLen) return true; - } - return false; + if (!query || (lineEnd && query > lineEnd)) return false; + const char* queryEnd = std::strchr(query, ' '); + char line[160]; + const size_t len = static_cast((queryEnd ? queryEnd : query + std::strlen(query)) - query - 1); + if (len >= sizeof(line)) return false; + std::memcpy(line, query + 1, len); + line[len] = '\0'; + char path[160]; + return HttpServerModule::parseFilePath(line, path, sizeof(path)) && platform::isSdPath(path); } bool HttpServerModule::handleConnection(platform::TcpConnection& conn, int patienceMs) { diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index dccb7952b..f0245beeb 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -23,7 +23,7 @@ /// /// ## An image without the room turns it off /// -/// `CONFIG_SOC_SDMMC_HOST_SUPPORTED` names every chip with the SD host, and `CONFIG_MM_SD_CARD` is the second, project-level gate, the same two-gate shape `CONFIG_MM_USB_MIDI_HOST` uses. It is on by default and off on an image whose boards have no slot and whose flash is tight, such as the S3-Zero. +/// `CONFIG_SOC_SDMMC_HOST_SUPPORTED` names every chip with the SD host, and `CONFIG_MM_SD_CARD` is the second, project-level gate, the same two-gate shape `CONFIG_MM_USB_MIDI_HOST` uses. It is on by default and off on every 4 MB image, through the 4 MB layout's fragment. #include "sdkconfig.h" diff --git a/src/platform/esp32/platform_esp32_sdmmc.cpp b/src/platform/esp32/platform_esp32_sdmmc.cpp index cfa7329d4..8d35b6cb0 100644 --- a/src/platform/esp32/platform_esp32_sdmmc.cpp +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -36,6 +36,7 @@ #include "sdkconfig.h" #include "esp_log.h" +#include "esp_heap_caps.h" #include "driver/gpio.h" #include @@ -82,8 +83,12 @@ static int8_t g_pwrGpio = -1; // so unmount powers back down what mount static bool g_spiBus = false; // whether the mount brought up the SPI bus, which unmount frees // SPI3 where the chip has it: the S3's W5500 Ethernet holds SPI2. static constexpr spi_host_device_t kSdSpiHost = SOC_SPI_PERIPH_NUM > 2 ? SPI3_HOST : SPI2_HOST; -// Every call below runs to completion before the next starts, so one shared FIL off the stack serves them all; the kept-open streams further down heap-allocate their own. -static FIL g_fil; +// What only a mounted card needs, allocated at mount and freed at unmount, so a board without a card holds none of it. +struct SdScratch { + FIL fil; // every call below runs to completion before the next, so one FIL serves them all; kept-open streams allocate their own + alignas(4) uint8_t probe[512]; // sdCardPresent's sector, word-aligned or sdmmc_read_sectors bounces it through another buffer +}; +static SdScratch* g_scratch = nullptr; #if SOC_SDMMC_IO_POWER_EXTERNAL static sd_pwr_ctrl_handle_t g_pwrCtrl = nullptr; #endif @@ -102,14 +107,11 @@ static bool sdTranslate(const char* apiPath, char* out, size_t outLen) { bool sdCardMounted() { return g_mounted; } -// A static scratch sector, not a stack buffer: keeps sdCardPresent's own frame off the 512-byte stack-frame rule, and this runs on one thread only. -alignas(4) static uint8_t g_presenceProbe[512]; // word-aligned, or sdmmc_read_sectors bounces every probe through a heap buffer - // A 1-sector read, not SEND_STATUS (CMD13): CMD13 panics this chip's SDMMC host on this ESP-IDF build; a block read is the primitive real file access already proves safe. bool sdCardPresent() { if (!g_mounted || !g_card) return false; - if (g_card->csd.sector_size > sizeof(g_presenceProbe)) return false; // never true for a real SD/SDHC/SDXC card; refuses rather than overflowing the scratch sector - return sdmmc_read_sectors(g_card, g_presenceProbe, 0, 1) == ESP_OK; + if (g_card->csd.sector_size > static_cast(sizeof(g_scratch->probe))) return false; // never true for a real SD/SDHC/SDXC card; refuses rather than overflowing the scratch sector + return sdmmc_read_sectors(g_card, g_scratch->probe, 0, 1) == ESP_OK; } bool sdCardBusy() { return g_streamsOpen > 0 || g_formatState == SdFormatState::Running; } @@ -310,6 +312,16 @@ static bool mountCard(const SdPinConfig& pins, bool format) { return false; } + // DMA-capable, so the probe sector is read straight into it on every chip. + g_scratch = static_cast(heap_caps_calloc(1, sizeof(SdScratch), MALLOC_CAP_DMA)); + if (!g_scratch) { + ESP_LOGE(SD_TAG, "SD scratch allocation failed"); + esp_vfs_fat_sdcard_unmount(SD_MOUNT_POINT, g_card); + powerDown(); + g_card = nullptr; + g_mountError = SdMountError::NoCard; + return false; + } g_mounted = true; g_mountError = SdMountError::None; clearStaging(); @@ -328,6 +340,8 @@ SdMountError sdMountError() { return g_mountError; } static void unmountCard() { esp_vfs_fat_sdcard_unmount(SD_MOUNT_POINT, g_card); powerDown(); + heap_caps_free(g_scratch); + g_scratch = nullptr; g_card = nullptr; g_mounted = false; } @@ -422,10 +436,10 @@ int sdCardRead(const char* apiPath, char* buf, size_t maxLen) { if (!g_mounted || !buf || maxLen == 0) return -1; char full[128]; if (!sdTranslate(apiPath, full, sizeof(full))) return -1; - if (f_open(&g_fil, full, FA_READ) != FR_OK) return -1; + if (f_open(&g_scratch->fil, full, FA_READ) != FR_OK) return -1; UINT br = 0; - const FRESULT r = f_read(&g_fil, buf, static_cast(maxLen - 1), &br); - f_close(&g_fil); + const FRESULT r = f_read(&g_scratch->fil, buf, static_cast(maxLen - 1), &br); + f_close(&g_scratch->fil); if (r != FR_OK) return -1; buf[br] = 0; return static_cast(br); @@ -477,10 +491,15 @@ static void clearStaging() { f_closedir(&dir); } -// f_rename refuses an existing destination, unlike POSIX rename; the old file is unlinked first, with the brief gap as FAT's cost for having no atomic replace. +// FAT has no atomic replace and f_rename refuses an existing destination, so the old file steps aside into staging and returns if the new one cannot take its name. static bool sdCommitTmp(const char* tmp, const char* full) { - f_unlink(full); - if (f_rename(tmp, full) == FR_OK) return true; + char old[32]; + const bool movedOld = stagingPath(old, sizeof(old)) && f_rename(full, old) == FR_OK; + if (f_rename(tmp, full) == FR_OK) { + if (movedOld) f_unlink(old); + return true; + } + if (movedOld) f_rename(old, full); f_unlink(tmp); return false; } @@ -493,11 +512,11 @@ bool sdCardWriteAtomic(const char* apiPath, const char* data, size_t len) { char tmp[32]; if (!sdTranslate(apiPath, full, sizeof(full)) || !stagingPath(tmp, sizeof(tmp))) return false; - if (f_open(&g_fil, tmp, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) return false; + if (f_open(&g_scratch->fil, tmp, FA_WRITE | FA_CREATE_ALWAYS) != FR_OK) return false; UINT bw = 0; - const FRESULT wr = f_write(&g_fil, data, static_cast(len), &bw); - f_close(&g_fil); - if (wr != FR_OK || bw != len) { f_unlink(tmp); return false; } + const FRESULT wr = f_write(&g_scratch->fil, data, static_cast(len), &bw); + const FRESULT closed = f_close(&g_scratch->fil); // the final flush: a file that did not close whole never replaces the old one + if (wr != FR_OK || bw != len || closed != FR_OK) { f_unlink(tmp); return false; } return sdCommitTmp(tmp, full); } @@ -574,9 +593,9 @@ bool sdCardWriteStreamWrite(void* handle, const char* buf, size_t len) { bool sdCardWriteStreamClose(void* handle, bool commit) { auto* h = static_cast(handle); if (!h) return false; - f_close(&h->f); - const bool ok = commit ? sdCommitTmp(h->tmp, h->full) : true; - if (!commit) f_unlink(h->tmp); + const bool closed = f_close(&h->f) == FR_OK; // the final flush, as in sdCardWriteAtomic + const bool ok = commit && closed ? sdCommitTmp(h->tmp, h->full) : !commit; + if (!commit || !closed) f_unlink(h->tmp); delete h; --g_streamsOpen; return ok; diff --git a/src/ui/app.js b/src/ui/app.js index f2a3eaf6c..463bb2152 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -7799,10 +7799,10 @@ function fmUploadPanelRow(panel, name) { async function fmDropUpload(destDir, files) { const skipped = []; const onSd = fmIsSdPath(destDir); - const panel = files.length ? fmUploadPanel() : null; for (const file of files) { if (!onSd && file.size > FM_UPLOAD_CAP) { skipped.push(file.name + " (" + fmSize(file.size) + ")"); continue; } - const row = fmUploadPanelRow(panel, file.name); + // Per file: the previous file's last row removed an emptied panel. + const row = fmUploadPanelRow(fmUploadPanel(), file.name); const route = "/api/file?path=" + encodeURIComponent(joinFsPath(destDir, file.name)); try { await uploadWithProgress(route, file, { diff --git a/test/unit/core/unit_HttpServerModule_sdcard.cpp b/test/unit/core/unit_HttpServerModule_sdcard.cpp index 762a78f98..3459a5e53 100644 --- a/test/unit/core/unit_HttpServerModule_sdcard.cpp +++ b/test/unit/core/unit_HttpServerModule_sdcard.cpp @@ -236,6 +236,8 @@ TEST_CASE("an SD upload larger than one tick's chunk streams across several tick // Point 13 of the review: one chunk per tick capped a transfer around 200 KB/s. A payload spanning several 4 KB chunks now commits within a couple of ticks, not one chunk per tick. TEST_CASE("an SD upload spanning several chunks drains in far fewer ticks than chunks") { + struct Clock { ~Clock() { mm::platform::setTestNowMs(0); } } clock; + mm::platform::setTestNowMs(1000); // frozen, so the per-tick budget cannot run out on a slow machine SdFixture fix; mm::HttpServerModule http; http.port = kTestPort; @@ -446,3 +448,37 @@ TEST_CASE("DELETE /api/dir on /sd itself is refused, leaving the card untouched" CHECK(mm::platform::fsSize("/sd/keep.bin") >= 0); // untouched, not even one level in http.release(); } + +// The filesystems skip repeated slashes, so "/sd//" names the card's root as surely as "/sd" does, and is refused the same way. +TEST_CASE("DELETE /api/dir on the card root spelled with extra slashes is refused too") { + SdFixture fix; + REQUIRE(mm::platform::fsWriteAtomic("/sd/keep.bin", "x", 1)); + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + for (const char* req : {"DELETE /api/dir?path=/sd// HTTP/1.1\r\n\r\n", "DELETE /api/dir?path=//sd HTTP/1.1\r\n\r\n"}) { + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client)); + const std::string reply = request(http, client, req); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) != 0); + client.close(); + } + CHECK(mm::platform::fsSize("/sd/keep.bin") >= 0); + http.release(); +} + +// A relative path reaches the card as "/sd/...", so its upload gets the card's size limit, not flash's 8 MB: past the card's free space is 507, not 413. +TEST_CASE("an upload to a relative sd/ path gets the card's size limit") { + SdFixture fix; + mm::platform::setTestSdCardTotal(500); + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client)); + const char* req = "POST /api/file?path=sd/rel.bin HTTP/1.1\r\nContent-Length: 9000000\r\n\r\n"; + const std::string reply = request(http, client, req); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 507", 12) == 0); + client.close(); + http.release(); +}