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.
+
+
+
+
+- `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