Compare commits

...

2 Commits

Author SHA1 Message Date
Julien Calixte
0b52f34940 docs(postmortems): record Spike 3 SD CMD59 card incompatibility
New docs/postmortems/ folder. The bench card (133 GB SDXC) rejects CMD59
(SPI-mode CRC) as illegal; CMD0/CMD8 succeed, so wiring and firmware are
proven and the fix is a compliant card. Captures the root cause, the
keep-CRC-required decision, and the reusable findings (EPD bus lock, LFN
requirement, R1-byte diagnostic) before the work pauses for hardware.
2026-07-05 18:16:49 +02:00
Julien Calixte
9fc21568e7 feat(firmware): add SD/FAT spike (Spike 3)
Standalone bench program (src/bin/sd_fat.rs, `just flash-sd`) that mounts
FAT over the EPD's shared SPI2 bus and proves the persistence module's
atomic save: write .tmp, fsync, rename, read back and compare.

Runs SD-only: the EPD's SpiBusDriver holds an exclusive bus lock for its
lifetime, so an arbitrated SD device can't be live alongside it yet. Keeps
CRC required and maps a card that rejects CMD59 to a clear "use a genuine
card" message rather than running the user's notes over an unchecked bus.

sdkconfig gains CONFIG_FATFS_LFN_HEAP (the atomic-save .tmp name isn't valid
8.3) and, temporarily, CONFIG_LOG_MAXIMUM_LEVEL_DEBUG to read the drivers'
per-command R1 bytes during bring-up.
2026-07-05 18:16:42 +02:00
6 changed files with 498 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
# Spike 3 (SD) — blocked on a card that rejects CMD59 (SPI-mode CRC)
> Date: 2026-07-05 · Build at time of failure: `07-05 16:07Z @f77f669-dirty`
> Status: **paused, not failed** — bench card is incompatible; awaiting a
> compliant microSD. Wiring and firmware are proven good.
>
> Context: Spike 3 in
> [`../v0.1-mvp-technical.md`](../v0.1-mvp-technical.md#hardware-bring-up-order),
> storage split [ADR-007](../adr.md#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config),
> firmware notes [`../../firmware/README.md`](../../firmware/README.md).
> Spike program: [`../../firmware/src/bin/sd_fat.rs`](../../firmware/src/bin/sd_fat.rs).
## Summary
Built the Spike 3 bench program (`sd_fat`): bring up the SD card over the EPD's
shared SPI2 bus, mount FAT, and prove the persistence module's atomic-save
pattern (write `*.tmp` → fsync → rename → read-back). The card **never mounts**:
SD init gets cleanly through CMD0 and CMD8 (the card even identifies as an
SDHC/SDXC v2 card), then fails at **CMD59 (CRC_ON_OFF)**, which the card rejects
as an illegal command (`ESP_ERR_NOT_SUPPORTED`, `0x106`). `sdmmc_card_init`
treats that as fatal.
Root cause is the **card**, not our wiring or code: the bench card is a 133 GB
SDXC that doesn't implement CMD59 in SPI mode — common on large / cheap /
counterfeit SDXC cards. It works fine on the Mac because macOS uses native SD
mode, never the SPI fallback the ESP32 uses.
**Decision:** keep CRC required (don't disable it to limp the bad card along —
see below) and reject incompatible cards with a clear message. Resume when a
genuine ≤32 GB card is on the bench.
## Bench wiring (shared SPI2, proven good)
| Signal | GPIO | Shared with EPD? |
| ------ | ---- | ---------------- |
| SCK | 12 | yes (epd.rs) |
| MOSI | 11 | yes (epd.rs) |
| MISO | 13 | **no** — new line; the EPD is write-only and never used MISO |
| SD CS | 10 | no — EPD CS is 7 |
The SD sits on the same SPI2 bus as the panel, on its own chip-select. The spike
runs **SD-only** (see "EPD bus lock" below).
## Timeline
1. First flash → `sdmmc_send_cmd_crc_on_off returned 0x106`, `sdmmc_card_init
failed (0x106)`. Cryptic; could be wiring, pull-ups, speed, or card.
2. Added internal pull-ups on SCK/MOSI/MISO/CS and drove the EPD CS (GPIO 7)
high (deselect the panel on the shared bus). **No change** — identical
failure at the same command. A deterministic failure at one command argues
against marginal signal integrity.
3. Verified the card on the Mac: `Windows_FAT_32 TYPOENA`, 33.6 GB partition on
a 133 GB card, **healthy and readable**. Rules out a dead card / wrong FS.
4. Read the esp-idf SD init source: the CMD8 handler *silently tolerates* the
same `ESP_ERR_NOT_SUPPORTED` (treats it as "not a v2 card"), while CMD59's
does not. So the failure might be a persistent bad response, not a CMD59
one-off — needed the raw R1 bytes to tell.
5. Raised the compile-time log ceiling (`CONFIG_LOG_MAXIMUM_LEVEL_DEBUG`) and
bumped the `sdmmc_*` / `sdspi_*` tags to DEBUG at runtime. The dump was
decisive:
- `cmd=52` / `cmd=5` fail (`0x107`/`0x106`) — **normal**: those are SDIO
probes (CMD52/CMD5) a memory card doesn't answer.
- `sdmmc_sd: SDHC/SDXC card` — printed **only when CMD8 succeeds**. So CMD0
and CMD8 returned clean, correct responses (right 0xAA echo, no error bit).
- `cmd=59, R1 response: command not supported` — only CMD59 is rejected.
6. Confirmed the breakout is a bare microSD→pin adapter (no level shifter), so
the 3.3 V SPI path is clean.
## Root cause
The card responds correctly to CMD0 and CMD8 (proving wiring, signal integrity,
pull-ups, bus sharing, and the hand-built FFI mount path are all correct) but
**rejects CMD59 (CRC_ON_OFF) as an illegal command**. CMD59 is mandatory per the
SD spec; some large/counterfeit SDXC cards don't implement it in SPI mode. Since
esp-idf's `sdmmc_init_spi_crc` hard-fails when CMD59 fails (and there is **no**
Kconfig or host flag to skip it), the mount aborts.
## What it was *not*
- **Not wiring / a swap** — CMD0/CMD8 responses are clean and correct.
- **Not signal integrity** — internal pull-ups changed nothing; failure is
deterministic at one command; init runs at 400 kHz where the jumpers are fine.
- **Not the filesystem** — failure is at SD *protocol* init, before any FAT
access. The card is FAT32 and mounts on the Mac.
- **Not a level-shifter breakout** — it's a bare 3.3 V adapter.
- **Not our FFI** — the hand-rolled `SDSPI_HOST_DEFAULT()` descriptors work; the
driver reaches and drives the card correctly.
## Decision: keep CRC required, reject bad cards clearly
CMD59's job is to enable **CRC on data transfers**. The only way to mount this
card is to tolerate CMD59 failing, i.e. run with data CRC **off**. For a device
whose entire value is not losing the user's writing, giving up integrity
checking — precisely on the cards that are already the sketchy ones — is the
wrong trade. It would also require patching a vendored esp-idf function that
re-applies on every update.
So `sd_fat` now maps `ESP_ERR_NOT_SUPPORTED` from the mount to an actionable
message ("card rejected CMD59… use a genuine card, ideally ≤32 GB…") instead of
a raw code. This is the behavior the real `persistence` module should carry too:
a bad card at boot should say "swap the SD," not hang on a hex code.
A CRC-off patch remains *possible* if bench work ever needs this exact card, but
it is explicitly **not recommended** and not applied.
## Other findings worth keeping
- **EPD bus lock forces SD-only (for now).** The EPD driver uses esp-idf-hal's
`SpiBusDriver`, whose constructor calls `spi_device_acquire_bus(BLOCK)` and
holds that **exclusive** lock for its whole lifetime (it needs CS held across
a cmd→data sequence while DC toggles). While held, any other device on SPI2 —
the SD — blocks. So the EPD and an arbitrated SD device can't both be live on
one host as things stand. This spike proves the SD stack; the shared-bus
**arbitration decision** (release/re-acquire around EPD ops, vs. the
risk-table fallback of giving the SD its own SPI3) is still open and is what a
follow-up must settle before integration.
- **FatFS long filenames are required.** The atomic-save temp name
(`notes.md.tmp`, two dots) is not a valid 8.3 name, and FatFS defaults to
8.3-only. `CONFIG_FATFS_LFN_HEAP=y` is needed by this spike **and** the real
persistence path.
- **`SDSPI_*_DEFAULT()` macros are bindgen-invisible.** Built the `sdmmc_host_t`
/ `sdspi_device_config_t` descriptors by hand; the `SDMMC_HOST_FLAG_*` values
are `BIT(n)` macros bindgen can't fold, so they're inlined (`BIT(3)`/`BIT(5)`).
- **Conservative SD clock.** `SD_FREQ_KHZ = 10_000` (vs. the 20 MHz default),
since the EPD needed 4 MHz on these bench jumpers. Init runs at 400 kHz
regardless, so this only affects post-init throughput — revisit on a real PCB.
- **Diagnostic technique.** Raising the log ceiling + `esp_log_level_set` to read
the driver's per-command R1 bytes turned a cryptic error into a one-glance
root cause. Reusable for any esp-idf init failure.
## Where the end-to-end (clone → SD → edit → push) stands
- **Clone is out-of-band in v0.1** — the dev clones the remote onto the mounted
SD from a laptop; there is no first-clone on device
([ADR-007](../adr.md#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config),
technical doc "Provisioning"). The bench card mounts on the Mac, so that step
is not blocked.
- **Edit → save-to-SD** becomes testable the moment a compliant card mounts
(Spike 3 + the existing editor's atomic save). Closest milestone.
- **SD → push** needs **Spike 7 (gitoxide over HTTPS+PAT)**, which is unbuilt and
needs **PSRAM enabled first** (not yet done). Spike 6 (Wi-Fi + TLS) proved the
network/TLS gate. So the full loop is not testable yet even with a good card.
## Follow-ups
- [ ] Re-run with a genuine ≤32 GB card → expect clean mount + round-trip.
- [ ] Strip the debug logging once green: remove `CONFIG_LOG_MAXIMUM_LEVEL_DEBUG`
from `sdkconfig.defaults` and the `esp_log_level_set` block in `sd_fat.rs`.
- [ ] Write up Spike 3 as verified in
[`../../firmware/README.md`](../../firmware/README.md) (wiring, LFN
requirement, card-compatibility note), matching the other spikes.
- [ ] Record a **recommended-SD-card note** for v0.1 (genuine, ≤32 GB;
large/counterfeit SDXC may fail CMD59) — product doc / ADR-007.
- [ ] Settle the **shared-bus arbitration** decision (EPD lock vs. SPI3 for SD).
- [ ] Enable PSRAM, then build Spike 7 (gitoxide push) for the push leg.
## Artifacts (this session)
- `firmware/src/bin/sd_fat.rs` — the spike (mount + atomic round-trip + clear
rejection + debug logging).
- `firmware/Cargo.toml` — `[[bin]] sd_fat`.
- `firmware/justfile` — `build-sd` / `flash-sd` / `monitor-sd`.
- `firmware/sdkconfig.defaults` — `CONFIG_FATFS_LFN_HEAP=y` (keep) and
`CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y` (temporary, strip when green).

View File

@@ -0,0 +1,13 @@
# Postmortems
> Bench + bring-up debugging write-ups: what broke, how we found the root cause,
> and the decisions that came out of it. One file per incident, named
> `YYYY-MM-DD-<slug>.md`. These capture *why* a spike stalled or a design turned
> — the kind of context that's expensive to reconstruct later.
>
> Project overview: [`../../README.md`](../../README.md). Bring-up spikes:
> [`../v0.1-mvp-technical.md`](../v0.1-mvp-technical.md#hardware-bring-up-order).
| Date | Incident | Status |
| ---------- | ------------------------------------------------------------------------ | ------ |
| 2026-07-05 | [Spike 3 (SD) — card rejects CMD59 (SPI-mode CRC)](2026-07-05-spike3-sd-cmd59.md) | Paused — awaiting a compliant microSD; wiring + firmware proven |

View File

@@ -22,6 +22,13 @@ name = "wifi_tls"
path = "src/bin/wifi_tls.rs"
harness = false
# Spike 3 — SD card (FAT) over the shared SPI2 bus. Standalone bench program.
# Flash with `just flash-sd`.
[[bin]]
name = "sd_fat"
path = "src/bin/sd_fat.rs"
harness = false
[profile.release]
opt-level = "s"

View File

@@ -9,6 +9,7 @@ set dotenv-load := true
esp_env := ". ~/export-esp.sh &&"
elf := "target/xtensa-esp32s3-espidf/release/firmware"
elf_wifi := "target/xtensa-esp32s3-espidf/release/wifi_tls"
elf_sd := "target/xtensa-esp32s3-espidf/release/sd_fat"
# list recipes
default:
@@ -38,6 +39,18 @@ flash-wifi:
monitor-wifi:
espflash monitor --elf {{elf_wifi}}
# Spike 3 — build the SD/FAT spike (no .env needed)
build-sd:
{{esp_env}} cargo build --release --bin sd_fat
# Spike 3 — flash + monitor the SD/FAT spike
flash-sd:
{{esp_env}} cargo run --release --bin sd_fat
# serial monitor for the SD spike, with decoded backtraces
monitor-sd:
espflash monitor --elf {{elf_sd}}
# detect board, print chip/MAC/flash size
info:
espflash board-info

View File

@@ -13,6 +13,17 @@ CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=4096
# `std::thread::Builder::new().stack_size(XXX)` for spawning
CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT=4096
# Raise the compile-time log ceiling so the sdmmc/sdspi drivers' per-command
# DEBUG logs (the raw R1 response bytes) are built in. Runtime level stays INFO
# by default; the SD spike bumps just the sdmmc/sdspi tags to DEBUG so we can see
# exactly what each init command returns. (Diagnostic for Spike 3 init failures.)
CONFIG_LOG_MAXIMUM_LEVEL_DEBUG=y
# FatFS long filenames (Spike 3 — SD). Default is 8.3-only, which rejects the
# persistence module's atomic-save temp name (`notes.md.tmp` has two dots).
# LFN on the heap keeps the working buffer off the (small) task stack.
CONFIG_FATFS_LFN_HEAP=y
# TLS trust store (Spike 6 — Wi-Fi + TLS, the gate for Spike 7 gitoxide push).
# The certificate bundle backs esp_crt_bundle_attach so an HTTPS GET to
# api.github.com validates against real roots. FULL rather than the common

290
firmware/src/bin/sd_fat.rs Normal file
View File

@@ -0,0 +1,290 @@
//! Spike 3 — SD card (FAT) over the EPD's shared SPI2 bus.
//!
//! A small standalone bench program (separate binary from the editor firmware)
//! that proves the storage stack the persistence module will sit on:
//!
//! 1. Bring up SPI2 with the SD's four lines. Three are shared with the EPD
//! (SCK 12, MOSI 11) plus a MISO line (13) the write-only EPD never used,
//! and the SD gets its own chip-select (10); the EPD's CS is 7.
//! 2. Mount a FAT filesystem on the card at `/sd` via `esp_vfs_fat_sdspi_mount`.
//! 3. Exercise the exact atomic-save pattern the persistence module specifies
//! (ADR-007): write `*.tmp`, fsync, rename over the target, then read back
//! and byte-compare. Report the card's negotiated clock and FAT usage.
//!
//! Why SD-only (no EPD in the same pass): the EPD driver uses esp-idf-hal's
//! `SpiBusDriver`, whose constructor calls `spi_device_acquire_bus(BLOCK)` and
//! holds that *exclusive* bus lock for the driver's whole lifetime (it needs CS
//! held across a cmd→data sequence while DC toggles). While that lock is held,
//! any other device on SPI2 — i.e. the SD — blocks. So the EPD and an arbitrated
//! SD device can't both be live on one host as things stand; proving the SD
//! stack + wiring first is the useful de-risking step. The shared-bus
//! arbitration question (release/re-acquire around EPD ops, or give the SD its
//! own SPI3 — the risk-table fallback) is what this spike hands data to.
//!
//! Two esp-idf notes baked in below:
//! - The `SDSPI_HOST_DEFAULT()` / `SDSPI_DEVICE_CONFIG_DEFAULT()` C macros are
//! dropped by bindgen, so the descriptors are filled by hand. The
//! `SDMMC_HOST_FLAG_*` values are `BIT(n)` macros bindgen can't fold either,
//! so they're inlined with a reference to sd_protocol_types.h.
//! - The `.tmp` rename target (`notes.md.tmp`) is not a valid 8.3 name, and
//! FatFS defaults to 8.3-only. `CONFIG_FATFS_LFN_HEAP=y` (sdkconfig.defaults)
//! turns on long filenames — required here and by the real persistence path.
//!
//! Flash with `just flash-sd`. Needs no `.env` (unlike the Wi-Fi spike).
use std::ffi::CStr;
use std::fs;
use std::io::{Read, Write};
use std::mem::MaybeUninit;
use std::ptr;
use anyhow::{bail, Context, Result};
use esp_idf_svc::hal::delay::FreeRtos;
use esp_idf_svc::sys::{self, esp};
/// Injected by build.rs so serial output identifies the exact build.
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
// SPI2 wiring. SCK/MOSI are shared with the EPD (epd.rs: SCK 12, MOSI 11); the
// SD adds MISO 13 (EPD is write-only, never wired it) and its own CS 10.
const PIN_SCK: i32 = 12;
const PIN_MOSI: i32 = 11;
const PIN_MISO: i32 = 13;
const PIN_CS: i32 = 10;
/// The EPD's chip-select (epd.rs). It sits on this same bus; we don't drive the
/// panel here, so we pin it HIGH (deselected) instead of leaving it floating.
const EPD_CS: i32 = 7;
/// SD clock. Deliberately conservative: the EPD was validated at 4 MHz on these
/// bench jumper wires, and SDSPI's 20 MHz default is prone to CRC errors on the
/// same wiring (stub reflections off the EPD's MOSI/SCK taps) — which would look
/// like a stack failure when it's really signal integrity. 10 MHz keeps margin
/// while staying a real speed; raise toward 20 MHz once on a clean PCB.
const SD_FREQ_KHZ: i32 = 10_000;
/// Host flags from sd_protocol_types.h — `BIT(3)` / `BIT(5)`. Inlined because
/// bindgen doesn't fold the nested `BIT()` macro into a constant.
const SDMMC_HOST_FLAG_SPI: u32 = 1 << 3;
const SDMMC_HOST_FLAG_DEINIT_ARG: u32 = 1 << 5;
/// VFS mount point. `MOUNT` is the C string handed to esp-idf; `MOUNT_STR` is
/// the same path for std::fs.
const MOUNT: &CStr = c"/sd";
const MOUNT_STR: &str = "/sd";
fn main() -> Result<()> {
// Required once before any esp-idf-svc call; some runtime patches only link
// if this symbol is referenced. See esp-idf-template#71.
esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
log::info!("Typoena — Spike 3 (SD/FAT on shared SPI2), {BUILD_TAG}");
// Diagnostic: surface the sdmmc/sdspi drivers' per-command DEBUG logs (the
// raw R1 response bytes) so an init failure shows *which* command the card
// rejects and with what response — not just the final propagated error.
for tag in [c"sdmmc_sd", c"sdmmc_cmd", c"sdmmc_init", c"sdspi_transaction", c"sdspi_host"] {
unsafe { sys::esp_log_level_set(tag.as_ptr(), sys::esp_log_level_t_ESP_LOG_DEBUG) };
}
match run() {
Ok(()) => {
log::info!("✅ Spike 3 complete — mount + atomic write/fsync/rename/read-back on shared bus")
}
Err(e) => log::error!("❌ Spike 3 failed: {e:?}"),
}
// Idle instead of returning, so the result stays on the monitor.
loop {
FreeRtos::delay_ms(1000);
}
}
fn run() -> Result<()> {
let card = mount_sd().context("mounting SD over SPI2")?;
// SAFETY: `card` is a live handle returned by a successful mount.
let (max_khz, real_khz) = unsafe { ((*card).max_freq_khz, (*card).real_freq_khz) };
log::info!("card mounted at /sd — max {max_khz} kHz, negotiated {real_khz} kHz");
let (total, free) = fs_info().context("reading FAT usage")?;
log::info!(
"FAT usage — {} MiB total, {} MiB free",
total / (1024 * 1024),
free / (1024 * 1024)
);
file_roundtrip().context("atomic write/fsync/rename/read-back")?;
list_root(); // best-effort, informational
Ok(())
}
/// Init the shared SPI2 bus and mount the card. Returns the card handle (kept
/// alive for the program's lifetime; the spike never unmounts).
fn mount_sd() -> Result<*mut sys::sdmmc_card_t> {
// 0) Deselect the EPD. It shares SCK/MOSI; its CS is GPIO 7 and the panel is
// write-only (can't contend on MISO), but a floating CS while we clock the
// shared lines is a variable worth removing. Pin it HIGH.
esp!(unsafe { sys::gpio_reset_pin(EPD_CS) }).context("reset EPD CS")?;
esp!(unsafe { sys::gpio_set_direction(EPD_CS, sys::gpio_mode_t_GPIO_MODE_OUTPUT) })
.context("EPD CS as output")?;
esp!(unsafe { sys::gpio_set_level(EPD_CS, 1) }).context("EPD CS high")?;
// 1) Initialize SPI2 with the SD's lines. This is the bus the EPD also uses;
// the difference vs. epd.rs's init is the added MISO line (SD data-out).
// SAFETY: zeroed spi_bus_config_t is valid (all pins default 0); we then set
// the used pins and mark the quad lines unused (-1).
let mut bus: sys::spi_bus_config_t = unsafe { MaybeUninit::zeroed().assume_init() };
bus.__bindgen_anon_1.mosi_io_num = PIN_MOSI;
bus.__bindgen_anon_2.miso_io_num = PIN_MISO;
bus.sclk_io_num = PIN_SCK;
bus.__bindgen_anon_3.quadwp_io_num = -1;
bus.__bindgen_anon_4.quadhd_io_num = -1;
bus.max_transfer_sz = 4096;
esp!(unsafe {
sys::spi_bus_initialize(
sys::spi_host_device_t_SPI2_HOST,
&bus,
sys::spi_common_dma_t_SPI_DMA_CH_AUTO as _,
)
})
.context("spi_bus_initialize(SPI2)")?;
// 1b) Enable internal pull-ups on the SD lines. The SD spec wants ~10 kΩ
// pull-ups on the data lines; the bench jumpers have none, so MISO
// floats between response bytes and a stray bit reads back as a spurious
// R1 "illegal command" (ESP_ERR_NOT_SUPPORTED) that fails init. The
// ESP32's internal ~45 kΩ pull-ups are usually enough on short wires;
// an external 10 kΩ MISO→3V3 is the proper fix on a real board. Set
// after bus init so the SPI pin config doesn't clobber it (CS gets
// reconfigured by the mount below — harmless; MISO is the one that
// matters).
for pin in [PIN_SCK, PIN_MOSI, PIN_MISO, PIN_CS] {
esp!(unsafe { sys::gpio_set_pull_mode(pin, sys::gpio_pull_mode_t_GPIO_PULLUP_ONLY) })
.with_context(|| format!("pull-up on GPIO {pin}"))?;
}
// 2) SDSPI host descriptor — hand-rolled SDSPI_HOST_DEFAULT(). The function
// pointers are esp-idf's sdspi_host_* ops; the driver calls them to drive
// the card. `slot` picks the SPI host the device attaches to.
// SAFETY: zeroed is a valid starting point (all fn-pointer Options = None);
// we fill exactly the fields the C macro sets.
let mut host: sys::sdmmc_host_t = unsafe { MaybeUninit::zeroed().assume_init() };
host.flags = SDMMC_HOST_FLAG_SPI | SDMMC_HOST_FLAG_DEINIT_ARG;
host.slot = sys::spi_host_device_t_SPI2_HOST as i32;
host.max_freq_khz = SD_FREQ_KHZ;
host.io_voltage = 3.3;
host.driver_strength = sys::sdmmc_driver_strength_t_SDMMC_DRIVER_STRENGTH_B;
host.current_limit = sys::sdmmc_current_limit_t_SDMMC_CURRENT_LIMIT_200MA;
host.init = Some(sys::sdspi_host_init);
host.set_card_clk = Some(sys::sdspi_host_set_card_clk);
host.do_transaction = Some(sys::sdspi_host_do_transaction);
host.__bindgen_anon_1.deinit_p = Some(sys::sdspi_host_remove_device);
host.io_int_enable = Some(sys::sdspi_host_io_int_enable);
host.io_int_wait = Some(sys::sdspi_host_io_int_wait);
host.get_real_freq = Some(sys::sdspi_host_get_real_freq);
host.input_delay_phase = sys::sdmmc_delay_phase_t_SDMMC_DELAY_PHASE_0;
host.check_buffer_alignment = Some(sys::sdspi_host_check_buffer_alignment);
// 3) Device (slot) config — CS 10, no card-detect / write-protect / SDIO int.
// SAFETY: zeroed is valid; we set the host, CS, and mark the rest unused.
let mut slot: sys::sdspi_device_config_t = unsafe { MaybeUninit::zeroed().assume_init() };
slot.host_id = sys::spi_host_device_t_SPI2_HOST;
slot.gpio_cs = PIN_CS;
slot.gpio_cd = -1;
slot.gpio_wp = -1;
slot.gpio_int = -1;
// 4) Mount config. format_if_mount_failed = false is load-bearing: a mount
// hiccup must never reformat (and wipe) the user's card. allocation size
// only matters when formatting, which we've disabled.
let mount = sys::esp_vfs_fat_mount_config_t {
format_if_mount_failed: false,
max_files: 4,
allocation_unit_size: 16 * 1024,
disk_status_check_enable: false,
use_one_fat: false,
};
let mut card: *mut sys::sdmmc_card_t = ptr::null_mut();
let rc = unsafe {
sys::esp_vfs_fat_sdspi_mount(MOUNT.as_ptr(), &host, &slot, &mount, &mut card)
};
// Turn the driver's opaque error into something actionable. The one we hit
// in practice is a card that rejects CMD59 (SPI-mode CRC on/off): init gets
// through CMD0/CMD8 cleanly, then the CRC-enable step returns NOT_SUPPORTED.
// That's a card-firmware limitation (common on large/counterfeit SDXC), not
// a wiring fault — and we deliberately keep CRC required rather than run the
// user's notes over an unchecked bus, so we reject the card with guidance.
if rc == sys::ESP_ERR_NOT_SUPPORTED {
bail!(
"SD card rejected CMD59 (SPI-mode CRC). CMD0/CMD8 succeeded, so wiring is \
fine — this card's firmware just doesn't support CRC in SPI mode (common on \
large/counterfeit SDXC). Use a genuine card, ideally ≤32 GB. We keep CRC \
required on purpose: a writing device shouldn't run over an unchecked bus."
);
}
esp!(rc).context("esp_vfs_fat_sdspi_mount (card present? inserted? FAT-formatted?)")?;
Ok(card)
}
/// The persistence module's atomic save (ADR-007), proven end to end: write to
/// a temp file, fsync, rename over the target, then reopen and byte-compare.
fn file_roundtrip() -> Result<()> {
let path = format!("{MOUNT_STR}/spike3.md");
let tmp = format!("{path}.tmp"); // two dots → exercises long-filename support
let payload =
format!("typoena spike 3\n{BUILD_TAG}\nshared SPI2: SCK12 MOSI11 MISO13, SD CS10\n");
{
let mut f = fs::File::create(&tmp).context("create tmp")?;
f.write_all(payload.as_bytes()).context("write tmp")?;
f.sync_all().context("fsync tmp")?; // FatFS f_sync — flush before rename
}
fs::rename(&tmp, &path).context("rename tmp -> final")?;
let mut back = String::new();
fs::File::open(&path)
.context("reopen final")?
.read_to_string(&mut back)
.context("read back")?;
if back != payload {
bail!(
"read-back mismatch: wrote {} bytes, read {} bytes",
payload.len(),
back.len()
);
}
log::info!(
"round-trip OK — {} bytes: create {tmp} → fsync → rename {path} → read back identical",
payload.len()
);
Ok(())
}
/// FAT total/free bytes for the mount.
fn fs_info() -> Result<(u64, u64)> {
let mut total: u64 = 0;
let mut free: u64 = 0;
esp!(unsafe { sys::esp_vfs_fat_info(MOUNT.as_ptr(), &mut total, &mut free) })
.context("esp_vfs_fat_info")?;
Ok((total, free))
}
/// Log the root directory (informational — shows the card's existing content
/// and confirms our file landed).
fn list_root() {
match fs::read_dir(MOUNT_STR) {
Ok(entries) => {
log::info!("/sd contents:");
for entry in entries.flatten() {
let len = entry.metadata().map(|m| m.len()).unwrap_or(0);
log::info!(" {} ({len} B)", entry.file_name().to_string_lossy());
}
}
Err(e) => log::warn!("could not list /sd: {e}"),
}
}