diff --git a/CMakeLists.txt b/CMakeLists.txt index 0f9f8d98..48490e23 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/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 00000000..0b948011 Binary files /dev/null and b/docs/assets/core/SdCardService-files.png differ diff --git a/docs/assets/core/SdCardService.png b/docs/assets/core/SdCardService.png new file mode 100644 index 00000000..f5e8c1c4 Binary files /dev/null and b/docs/assets/core/SdCardService.png differ diff --git a/docs/moonmodules/core/services.md b/docs/moonmodules/core/services.md index 51b72dc8..30211250 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 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. + +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/reference/hardware/esp32-p4-eth.md b/docs/reference/hardware/esp32-p4-eth.md index fed839c2..855645ca 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 [SdCardService](../../moonmodules/core/services.md#sd-card): | Signal | GPIO | |---|---| diff --git a/docs/reference/hardware/gpio-usage.md b/docs/reference/hardware/gpio-usage.md index 2d2d6493..a38aa915 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 e4c89ea8..fe08fa10 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 322 file(s). 2.83% of all lines are duplicated, 3732 lines. ## By rule | Rule | Findings | |---|---:| | duplicated block | 768 | -| complex function | 283 | -| blocking call on the render path | 189 | +| complex function | 290 | +| blocking call on the render path | 192 | | deeply nested | 113 | | module type in the UI | 71 | | long function | 49 | -| large stack frame | 38 | +| 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` | 570 | +| `test` | 574 | | `light` | 421 | -| `core` | 351 | -| `platform` | 173 | +| `core` | 357 | +| `platform` | 170 | | `ui` | 74 | | `moondeck` | 24 | -| `src` | 5 | +| `src` | 7 | | `mooninstaller` | 2 | ## By file @@ -59,11 +59,11 @@ 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/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 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 | @@ -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 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 | @@ -133,7 +133,6 @@ The next function worth simplifying is at the top of each file's list. | `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_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 | @@ -168,7 +167,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 | @@ -208,7 +207,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/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 | @@ -227,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 | @@ -259,6 +261,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 | @@ -301,9 +304,9 @@ 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/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 +342,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/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: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 236-250, 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::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 1301 | +| `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 | @@ -362,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: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 d0bf9091..8915536b 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 **4289 warning(s)** across 415 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. +**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. @@ -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 | 1791 | -| over-wide comment lines | 0 | 1582 | +| over-wide comment lines | 0 | 1585 | | appendix sections nothing refers to | 0 | 369 | | multi-line comment blocks | 0 | 317 | | over-long sentences | 0 | 82 | @@ -30,8 +30,8 @@ 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 | 2065 | +| `platform/index.md` | 0 | 745 | | `light/effects.md` | 0 | 403 | | `core/system.md` | 0 | 203 | | `core/supporting.md` | 0 | 181 | @@ -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 **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 | *148 more warning files, 1298 findings* | | +| 1-42 each | *149 more warning files, 1299 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: 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 **743 warning(s)** across 31 file(s). +**0 error(s)** and **745 warning(s)** across 32 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 | *22 more warning files, 148 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: 398 split paragraphs, 219 over-wide comment lines, 114 multi-line comment blocks, 14 appendix sections nothing refers to. ### light/effects.md diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index 7401f233..adaca04d 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,51 +1,54 @@ { - "commit": "48ab0058", + "commit": "ef125474", "flash": { - "esp32s3-n16r8": 2336304, - "esp32": 2226256, - "esp32p4rev1-eth": 2177776, - "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, - "esp32s3-zero": 2219120, + "esp32p4rev3-eth": 2231792, + "esp32s3-zero": 2225360, "esp32-pico": 2107168, - "desktop-macos": 2270440, + "desktop-macos": 2293080, "desktop-windows": 1925120, - "esp32c3": 2335072 + "esp32c3": 2335072, + "desktop-linux": 2548000 }, "measured": { - "esp32p4rev1-eth": "2026-10-07", - "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-10", - "esp32-16mb": "2026-09-09", - "esp32p4rev1-eth-wifi": "2026-09-30", + "esp32s3-zero": "2026-10-12", + "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" + "esp32c3": "2026-10-10", + "desktop-linux": "2026-10-12" }, "perf": { "esp32": { - "tick_us": 8354, - "fps": 119 + "tick_us": 1716, + "fps": 582, + "heap_free": 33826927 }, "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, @@ -110,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, @@ -487,7 +490,7 @@ "p50": 7, "p95": 15, "n": 32, - "last": "2026-10-10" + "last": "2026-10-12" }, "desktop-windows": { "p50": 4, @@ -513,7 +516,7 @@ "p50": 7, "p95": 10, "n": 32, - "last": "2026-10-10" + "last": "2026-10-12" }, "desktop-windows": { "p50": 4, @@ -527,7 +530,7 @@ "p50": 1, "p95": 2, "n": 32, - "last": "2026-10-10" + "last": "2026-10-12" }, "desktop-windows": { "p50": 4, @@ -553,7 +556,7 @@ "p50": 7, "p95": 18, "n": 32, - "last": "2026-10-10" + "last": "2026-10-12" }, "esp32s3-n16r8": { "p50": 9080, @@ -579,7 +582,7 @@ "p50": 267, "p95": 355, "n": 32, - "last": "2026-10-10" + "last": "2026-10-12" } }, "Layouts_resize_reallocates_live": { @@ -587,7 +590,7 @@ "p50": 10, "p95": 19, "n": 32, - "last": "2026-10-10" + "last": "2026-10-13" }, "desktop-windows": { "p50": 93, @@ -613,7 +616,7 @@ "p50": 7, "p95": 11, "n": 32, - "last": "2026-10-10" + "last": "2026-10-13" }, "esp32s3-n16r8": { "p50": 10350, @@ -639,7 +642,7 @@ "p50": 7, "p95": 74, "n": 32, - "last": "2026-10-10" + "last": "2026-10-13" } } }, @@ -651,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" } } }, @@ -678,57 +681,61 @@ "last": "2026-09-30" } } + }, + "desktop-linux": { + "tick_us": 5, + "fps": 200000 } }, "loc": { - "core": 28271, + "core": 28880, "light": 31027, - "platform": 17930, - "ui": 12307, - "test": 64597, - "moondeck": 30600 + "platform": 18938, + "ui": 12350, + "test": 65504, + "moondeck": 30611 }, "comments": { "core": { - "lines": 6165, - "ratio": 0.242 + "lines": 6273, + "ratio": 0.241 }, "light": { "lines": 7439, "ratio": 0.269 }, "platform": { - "lines": 3659, - "ratio": 0.228 + "lines": 3819, + "ratio": 0.225 }, "ui": { - "lines": 3464, + "lines": 3472, "ratio": 0.298 }, "test": { - "lines": 6968, - "ratio": 0.125 + "lines": 7020, + "ratio": 0.124 }, "moondeck": { - "lines": 4906, + "lines": 4907, "ratio": 0.183 } }, "tests": { - "cases": 2398, + "cases": 2438, "scenarios": 30 }, "docs": { "md_files": 150, - "md_lines": 32271, + "md_lines": 32355, "plans_files": 44, - "backlog_lines": 3319, + "backlog_lines": 3344, "lessons_lines": 532, "claude_md_lines": 283 }, "complexity": { - "functions": 4389, - "over_threshold": 331, + "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 d488dc2e..929a8e58 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 `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,23 +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,127 KB | +105.0 KB (+5.2%) | -| 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,167 KB | +190.4 KB (+9.6%) | -| flash esp32s31 | 2,395 KB | 2,534 KB | +139.0 KB (+5.8%) | +| flash esp32s3-zero | 1,977 KB | 2,173 KB | +196.5 KB (+9.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,271 | +5,972 (+26.8%) | +| lines core | 22,299 | 28,880 | +6,581 (+29.5%) | | 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 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%) | +| lines platform | 16,798 | 18,938 | +2,140 (+12.7%) | +| lines ui | 11,394 | 12,350 | +956 (+8.4%) | +| 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. @@ -33,21 +35,22 @@ 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 | - | - | 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 (+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 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. @@ -56,19 +59,21 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | 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` | +| 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: -- **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 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/services/SdCardService.cpp b/src/core/services/SdCardService.cpp new file mode 100644 index 00000000..a351dce9 --- /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 00000000..46c323c9 --- /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, 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}, + {"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/ControlModule.h b/src/core/system/ControlModule.h index ef2d6aa5..9f912162 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 443c487a..59a84eca 100644 --- a/src/core/system/FilesystemModule.cpp +++ b/src/core/system/FilesystemModule.cpp @@ -39,10 +39,27 @@ void collectEntry(const char* name, bool isDir, uint32_t, void* user) { lvl->count++; } +// 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; + 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 // 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 @@ -188,10 +205,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 4bddb6a1..7fb21458 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); @@ -144,6 +145,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(); + 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. @@ -183,6 +185,112 @@ void HttpServerModule::serveParked() { } } +HttpServerModule::SdTransfer* HttpServerModule::claimSdTransfer() { + for (auto& t : sdTransfers_) if (!t.active) return &t; + return nullptr; // both slots busy: the caller answers 503/closes rather than blocking for one to free +} + +void HttpServerModule::finishSdTransfer(SdTransfer& t, bool closeConn) { + if (closeConn) t.conn.close(); + t.active = false; + t.wstream = platform::FsWriteStream{}; + 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; 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); + 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 false; + } + t.remaining -= static_cast(n); + t.lastActivityMs = now; + 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 false; + } + } + 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 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. +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 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 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 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; +} + +// 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_) { + if (!t.active) continue; + 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; + } + } +} + void HttpServerModule::tick1s() MM_NONBLOCKING { const SecretsHidden hide(anyWsClientOnAccessPoint()); // one push serves every client pushStateToWebSockets(); @@ -199,6 +307,21 @@ bool HttpServerModule::anyWsClientOnAccessPoint() const MM_NONBLOCKING { return false; } +// 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"); + 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) { // 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 +353,69 @@ 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 (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; 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; } } } @@ -632,7 +765,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. @@ -640,23 +773,45 @@ 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 (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)) { + 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; + t->lastActivityMs = platform::millis(); + t->active = true; + t->conn = std::move(conn); + 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]; - 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 +928,49 @@ 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 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. + 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; + 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 +978,10 @@ void HttpServerModule::handleWriteFile(platform::TcpConnection& conn, const char sendResponse(conn, 400, "application/json", "{\"error\":\"bad path\"}"); return; } + if (platform::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 e52aa472..01a0b97a 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 { @@ -41,6 +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 (`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. /// @@ -380,6 +382,37 @@ 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 + }; + // 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. 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. + void releaseSdTransfers(); + 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 +519,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/module_types.cpp b/src/module_types.cpp index edc9d072..d37cc5af 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/services/SdCardService.h" #include "core/system/FirmwareUpdateModule.h" #include "core/system/MoonCloudModule.h" #include "core/system/MoonStatsModule.h" @@ -366,6 +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"); + // 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("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"); @@ -400,6 +403,7 @@ constexpr BootModule kBootModules[] = { {"SystemModule", "I2cBusModule", nullptr, true}, {"SystemModule", "PinsModule", nullptr, true}, {"Services", "AudioService", nullptr, true}, + {"Services", "SdCardService", nullptr, mm::platform::hasSdCard}, {"NetworkModule", "EthernetModule", nullptr, kEthernetCard}, {"NetworkModule", "WiFiModule", nullptr, mm::platform::hasWiFi}, {"NetworkModule", "AccessPointModule", nullptr, mm::platform::hasWiFi}, @@ -419,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, "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 33d0f69d..10f3586f 100644 --- a/src/platform/desktop/platform_config.h +++ b/src/platform/desktop/platform_config.h @@ -130,6 +130,11 @@ 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; +/// True: no slot is wired, so SdCardService offers every board preset, for the tests to exercise them. +constexpr bool previewsSdPresets = true; + /// @} } // namespace mm::platform diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 04fa4714..74714f19 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,23 @@ 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; +// 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; +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) { if (!path) return {}; while (*path == '/') path++; // strip any number of leading slashes + 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. @@ -988,61 +1005,102 @@ 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 { +// Tags whether this handle counts toward sdStreamsOpen_, so close() decrements exactly what open() counted. +struct DesktopReadHandle { FILE* f = nullptr; bool isSd = false; }; } -int fsReadAt(const char* path, long offset, char* buf, size_t len) { - if (!buf) return -1; +bool fsReadStreamOpen(FsReadStream& h, const char* path) { 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 + if (!f) return false; + auto* rh = new DesktopReadHandle(); + rh->f = f; + rh->isSd = isSdPath(path); + if (rh->isSd) ++sdStreamsOpen_; + h.impl = rh; + return true; +} + +int fsReadStreamRead(FsReadStream& h, char* buf, size_t len) { + 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) { + 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; isSd mirrors DesktopReadHandle's. +struct DesktopWriteHandle { FILE* f = nullptr; std::filesystem::path target, tmp; bool isSd = false; }; } -bool fsWriteStream(const char* path, FsWriteSrc src, void* user) { - if (!src) return false; +bool fsWriteStreamOpen(FsWriteStream& h, const char* path) { auto target = toFsPath(path); + if (target.empty()) return false; makeParent(target); - auto tmp = target; - tmp += ".tmp"; + 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; } + wh->isSd = isSdPath(path); + if (wh->isSd) ++sdStreamsOpen_; + h.impl = wh; + return true; +} - 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); +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(f); + int fd = ::_fileno(wh->f); if (fd >= 0) ::_commit(fd); #else - int fd = ::fileno(f); + int fd = ::fileno(wh->f); if (fd >= 0) ::fsync(fd); #endif - std::fclose(f); - + std::fclose(wh->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; + 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; } void fsList(const char* dir, FsListCb cb, void* user) { if (!cb) return; + // 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 (sdCardMounted() && isRoot) cb("sd", true, 0, user); std::error_code ec; auto p = toFsPath(dir); if (!std::filesystem::exists(p, ec)) return; @@ -1050,6 +1108,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 +1136,104 @@ 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 (sdFormatState_ == SdFormatState::Running) return false; + if (sdMounted_) return true; + 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 || 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; +} + +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); + 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; + 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; } + +namespace { +// 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; +} + +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 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) 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 6a5318b8..f0245bee 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -20,6 +20,10 @@ /// /// 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. +/// +/// ## 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 every 4 MB image, through the 4 MB layout's fragment. #include "sdkconfig.h" @@ -277,6 +281,15 @@ 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 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; + /// @} } // namespace mm::platform diff --git a/src/platform/esp32/platform_esp32_fs.cpp b/src/platform/esp32/platform_esp32_fs.cpp index c05a0be2..5943b09e 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 @@ -95,6 +96,7 @@ void fsUnmount() { } bool fsMkdir(const char* 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; @@ -113,6 +115,7 @@ bool fsMkdir(const char* path) { } bool fsExists(const char* 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; @@ -121,6 +124,7 @@ bool fsExists(const char* path) { } bool fsRemove(const char* 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; @@ -131,6 +135,7 @@ bool fsRemove(const char* path) { } int fsRead(const char* path, char* buf, size_t 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; @@ -156,6 +161,7 @@ static void makeParent(const char* path) { } bool fsWriteAtomic(const char* path, const char* data, size_t 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; @@ -186,57 +192,23 @@ 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 (!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) { +int64_t fsSize(const char* 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; struct stat st; if (::stat(full, &st) != 0 || S_ISDIR(st.st_mode)) return -1; - return static_cast(st.st_size); -} - -bool fsWriteStream(const char* path, FsWriteSrc src, void* 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; + return static_cast(st.st_size); } void fsList(const char* dir, FsListCb cb, void* user) { + 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) { + 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); @@ -255,6 +227,103 @@ 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 constexpr (hasSdCard) { + if (isSdPath(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; +} + +int fsReadStreamRead(FsReadStream& h, char* buf, size_t len) { + auto* impl = static_cast(h.impl); + if (!impl) return -1; + 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); +} + +void fsReadStreamClose(FsReadStream& h) { + auto* impl = static_cast(h.impl); + if (!impl) return; + 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 constexpr (hasSdCard) { + if (isSdPath(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; +} + +bool fsWriteStreamWrite(FsWriteStream& h, const char* buf, size_t len) { + auto* impl = static_cast(h.impl); + if (!impl) return false; + 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; + 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; +} + 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 00000000..8d35b6cb --- /dev/null +++ b/src/platform/esp32/platform_esp32_sdmmc.cpp @@ -0,0 +1,635 @@ +/// @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. +/// +/// ## Built field by field +/// +/// `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. +/// +/// ## A format runs on its own task +/// +/// 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. +/// +/// ## A write lands in a staging folder first +/// +/// 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 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" + +#include "sdkconfig.h" +#include "esp_log.h" +#include "esp_heap_caps.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 +#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 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; +// 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 + +/// 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 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 > 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; } + +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); +} +/// 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); +} + +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(); +} + +// 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); + } + 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 = 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 = 32 * 1024; // the SD Association's FAT32 cluster for 1 to 32 GB cards + + 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)); + 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 + + 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); + } + if (err != ESP_OK) { + ESP_LOGE(SD_TAG, "SD mount failed: %s", esp_err_to_name(err)); + // 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; + } + + // 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(); + ESP_LOGI(SD_TAG, "SD card mounted at %s", SD_MOUNT_POINT); + return true; +} + +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); + powerDown(); + heap_caps_free(g_scratch); + g_scratch = nullptr; + 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 (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; + 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; + 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; + if (f_open(&g_scratch->fil, full, FA_READ) != FR_OK) return -1; + UINT br = 0; + 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); +} + +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); +} + +/// 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); +} + +// 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); +} + +// 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) { + 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; +} + +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[32]; + if (!sdTranslate(apiPath, full, sizeof(full)) || !stagingPath(tmp, sizeof(tmp))) 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_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); +} + +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; + ++g_streamsOpen; + 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; + --g_streamsOpen; +} + +// 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[32] = {}; +}; + +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; } + 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; + 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; + 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; +} + +#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; } +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; } +bool sdCardExists(const char*) { return false; } +bool sdCardRemove(const char*) { return false; } +int sdCardRead(const char*, char*, size_t) { 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; } +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 00000000..0feedced --- /dev/null +++ b/src/platform/esp32/platform_esp32_sdmmc.h @@ -0,0 +1,40 @@ +#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 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. +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, renaming the temporary onto its real name on `commit`, discarding it otherwise. +bool sdCardWriteStreamClose(void* handle, bool commit); + +} // namespace mm::platform diff --git a/src/platform/platform.h b/src/platform/platform.h index 74ec36f9..950942fa 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__) @@ -277,21 +281,117 @@ 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); -/// 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); +/// 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. 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. 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); + +/// 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. + +// 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] == '/'); +} + +/// 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; ///< 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, 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. SdCardService polls this to detect a live removal. +bool sdCardPresent(); +/// 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 or a format runs, true otherwise, including when nothing was mounted. +bool sdUnmount(); +/// 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 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 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. /// 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 609ba3d4..463bb215 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); 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; } + // 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 { - 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 1a5d8f6f..723ef967 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 94626e2e..107e626b 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_SdCardService.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 00000000..ee504763 --- /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: 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/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 2594e95e..7c3dc513 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 e1e18998..d73a160f 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 00007c32..f7a2c7fa 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 af5c1ade..19a9d73f 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 ebeb465e..afff1f41 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 6a7eb405..40b0f394 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 35709635..3d6845f8 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 1e6691db..962c5207 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 8feca06b..ce17e894 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 992e642e..a478e5f3 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_ControlModule.cpp b/test/unit/core/unit_ControlModule.cpp index abd682e2..375b6ba0 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 72972535..769430d0 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")); @@ -145,7 +159,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 { @@ -169,14 +183,19 @@ 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 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). @@ -250,6 +269,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 new file mode 100644 index 00000000..3459a5e5 --- /dev/null +++ b/test/unit/core/unit_HttpServerModule_sdcard.cpp @@ -0,0 +1,484 @@ +/// @module HttpServerModule +/// @also SdCardService +/// +/// `/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 { + +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; +} + +// 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) { + 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; +} + +// 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'); + for (size_t i = 0; i < n; i++) s[i] = static_cast('A' + (i % 26)); + return s; +} + +// 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]; + 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())); + tickWhileDelivering(http); +} + +// 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]; + 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 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; + 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("the root listing does not advertise /sd once the card is unmounted") { + SdFixture fix; + 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) { + 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"; + 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); + + 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; + http.port = kTestPort; + http.setup(); + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client)); + + const std::string payload = pattern(10000); + 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); + + 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::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(); + 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") { + 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; + 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 + postFile(client, "/sd/multi.bin", payload); + + // 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); + 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); + 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); + + // 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(); +} + +// 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); + SdFixture fix; + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + mm::platform::TcpConnection client; + 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(); + CHECK(readAfterClose(client) == 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(); +} + +// 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 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(); +} + +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 + 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); + CHECK(mm::platform::fsSize("/sd/toobig.bin") < 0); // nothing written + + client.close(); + http.release(); +} + +// 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; + 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"; + const std::string reply = request(http, client, req); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 413", 12) == 0); + + client.close(); + http.release(); +} + +// 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)); + 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"; + 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/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"; + 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/tree/sub/leaf.txt") < 0); + CHECK_FALSE(mm::platform::fsExists("/sd/tree")); + + 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"; + const std::string reply = request(http, client, req); + 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(); +} + +// 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(); +} diff --git a/test/unit/core/unit_ModuleFactory.cpp b/test/unit/core/unit_ModuleFactory.cpp index dbc617c3..f5a13022 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_SdCardService.cpp b/test/unit/core/unit_SdCardService.cpp new file mode 100644 index 00000000..f2f46368 --- /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")); +}