From 9c338571f0ce729d72a34d4883052f7082a46275 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 11 Jul 2026 12:02:15 +0200 Subject: [PATCH] feat(persistence): add SD mount + atomic save/load module Graduate the proven Spike 3 storage stack into firmware::persistence so the editor and the spike share one implementation. Storage::mount brings up the dedicated SPI3 bus (ADR-012) and mounts FAT with format_if_mount_failed=false (never reformat the user's card). save() does the FAT-safe atomic write (tmp -> fsync -> unlink -> rename); recover() reconciles a leftover *.tmp at boot, keeping the committed file when the crash point is ambiguous and only promoting the tmp when the target was already unlinked. load() caps reads at 256 KiB. --- firmware/src/lib.rs | 11 +- firmware/src/persistence.rs | 322 ++++++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 firmware/src/persistence.rs diff --git a/firmware/src/lib.rs b/firmware/src/lib.rs index c3932d9..dd0f5ef 100644 --- a/firmware/src/lib.rs +++ b/firmware/src/lib.rs @@ -2,9 +2,12 @@ //! //! The editor binary (`src/main.rs`) and the spike binaries under `src/bin/` //! are each separate crate roots; anything they need to share lives here and is -//! reached as `firmware::…`. Currently that is just the resilient Wi-Fi -//! bring-up, extracted from three duplicated `connect_wifi` copies so the retry -//! logic lives in exactly one place and the eventual editor integration reuses -//! it instead of growing a fourth copy. +//! reached as `firmware::…`: +//! +//! - [`net`] — resilient Wi-Fi bring-up, extracted from three duplicated +//! `connect_wifi` copies so the retry logic lives in exactly one place. +//! - [`persistence`] — SD mount + atomic save/load, graduated from the Spike 3 +//! bench binary so the editor and the spike share one implementation. pub mod net; +pub mod persistence; diff --git a/firmware/src/persistence.rs b/firmware/src/persistence.rs new file mode 100644 index 0000000..d18d8a2 --- /dev/null +++ b/firmware/src/persistence.rs @@ -0,0 +1,322 @@ +//! SD-card persistence — mount, atomic save, crash recovery. +//! +//! The editor's notes live at `/sd/repo/notes.md` on a FAT filesystem on the +//! microSD card. This module owns bringing that card up and reading/writing the +//! buffer safely across power loss. It is the graduation of the Spike 3 bench +//! binary (`src/bin/sd_fat.rs`): that spike proved the raw stack on hardware +//! (verified 2026-07-11); the proven bits now live here so the editor and the +//! spike share one implementation instead of the spike being a dead-end proof. +//! +//! ## Storage split (ADR-007) +//! +//! FAT-on-SD holds the git working copy (`/sd/repo/`) and local scratch +//! (`/sd/local/`); device config is compiled into the binary in v0.1. The repo +//! is provisioned host-side (`just init` / `just load` copy a clone onto the +//! card) and opened — not cloned — on device, so this module never creates the +//! repo directory: a missing `/sd/repo` means the card wasn't provisioned, which +//! the boot path surfaces as a fatal "re-run `just init`" rather than silently +//! papering over. +//! +//! ## Dedicated SPI3 bus (ADR-012) +//! +//! The card sits on its own SPI3 host (SCK 14, MOSI 15, MISO 13, CS 10). The EPD +//! keeps SPI2. The EPD driver holds an exclusive `spi_device_acquire_bus` lock +//! for its whole lifetime, so a shared bus would lock the SD out; giving the SD +//! its own host sidesteps that for ~2 GPIOs. See the `mount` docs and ADR-012. +//! +//! ## Atomic save + crash recovery (the load-bearing part) +//! +//! FAT gives weak power-loss guarantees, so a save is: write `notes.md.tmp`, +//! `fsync`, unlink the target, rename the tmp over it. On FAT that unlink is +//! mandatory — FatFS's `f_rename` returns `FR_EXIST` on an existing destination +//! (it does *not* replace like POSIX `rename(2)`; Spike 3 finding). That unlink +//! opens a small window where the target is gone while the complete new content +//! sits in the tmp. [`Storage::recover`] closes the loop at boot — see its docs +//! for the exact case analysis, which is subtler than "promote the tmp." + +use std::fs; +use std::io::Write as _; +use std::mem::MaybeUninit; +use std::path::Path; +use std::ptr; + +use anyhow::{bail, Context, Result}; +use esp_idf_svc::sys::{self, esp}; + +/// SD wiring on its own SPI3 host (ADR-012). +const PIN_SCK: i32 = 14; +const PIN_MOSI: i32 = 15; +const PIN_MISO: i32 = 13; +const PIN_CS: i32 = 10; + +/// SD clock. Conservative for bench jumper wires: SDSPI's 20 MHz default is +/// prone to CRC errors on long unterminated jumpers, which look like a stack +/// failure when they're really signal integrity. 10 MHz keeps margin; raise +/// toward 20 MHz on a clean PCB. Init always runs at 400 kHz regardless. +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; + +/// FAT mount point. +pub const MOUNT: &str = "/sd"; +/// Git working copy — provisioned host-side, opened on device. +pub const REPO_DIR: &str = "/sd/repo"; +/// The one file v0.1 opens. +pub const NOTES: &str = "/sd/repo/notes.md"; +/// Staging name for the atomic save. Two dots → needs long-filename support +/// (`CONFIG_FATFS_LFN_HEAP=y`, set in sdkconfig.defaults). +const NOTES_TMP: &str = "/sd/repo/notes.md.tmp"; + +/// Largest file [`Storage::load`] will read into the buffer. v0.1 caps notes at +/// 256 KiB; a larger file refuses to open with a clear message rather than +/// exhausting the rope. Saving is *not* capped — never refuse to persist the +/// user's work once it's in the buffer. +pub const MAX_FILE_BYTES: u64 = 256 * 1024; + +/// The C mount point (`/sd\0`) for the esp-idf FFI calls. +const MOUNT_C: &std::ffi::CStr = c"/sd"; + +/// A mounted SD card. Holds the live card handle for its lifetime; v0.1 never +/// unmounts (the card stays up for the whole power session). Not `Send` — the +/// handle lives on the task that mounted it (the ui/main task). The git thread +/// reaches `/sd/repo` through plain `std::fs`; FatFS's per-volume reentrancy +/// lock serialises the two, so no extra mutex is needed here. +pub struct Storage { + card: *mut sys::sdmmc_card_t, +} + +/// What [`Storage::recover`] did with a leftover `*.tmp` at boot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Recovery { + /// No `*.tmp` present — clean shutdown last time. + Clean, + /// `*.tmp` and the target both present: the crash could have landed + /// mid-write, so the tmp is untrustworthy. Kept the committed target, + /// discarded the tmp. The in-flight (unsaved) edit is lost — the documented + /// "you get the previous version" behaviour. + DiscardedTmp, + /// Only `*.tmp` present: the target had already been unlinked, so the tmp is + /// the newest complete, fsync'd copy. Promoted it to the target. + PromotedTmp, +} + +impl Storage { + /// Bring up SPI3 and mount the FAT filesystem at `/sd`, then run crash + /// recovery ([`Storage::recover`]) so storage is in a consistent state + /// before the caller reads anything. + /// + /// `format_if_mount_failed` is **false**: this is the user's card with their + /// writing on it, so a transient mount hiccup must never trigger a reformat. + /// (The Spike 3 bench binary sets it true for convenience on blank cards; + /// this path must not.) + pub fn mount() -> Result { + // 1) SPI3 with the SD's four lines. Dedicated bus (ADR-012) — no EPD + // deselect needed: the panel is on SPI2 and can't contend here. + // SAFETY: a zeroed spi_bus_config_t is valid (all pins default 0); we + // 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_SPI3_HOST, + &bus, + sys::spi_common_dma_t_SPI_DMA_CH_AUTO as _, + ) + }) + .context("spi_bus_initialize(SPI3)")?; + + // 1b) Internal pull-ups on the SD lines. The SD spec wants ~10 kΩ + // pull-ups; bench jumpers have none, so MISO floats between response + // bytes and a stray bit reads back as a spurious R1 "illegal + // command" 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. + 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() (bindgen + // drops the macro). The fn pointers are esp-idf's sdspi_host_* ops. + // SAFETY: zeroed is a valid start (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_SPI3_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 / 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_SPI3_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 — see method docs. + 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_C.as_ptr(), &host, &slot, &mount, &mut card) + }; + + // Turn the driver's opaque error into something actionable. The one we + // hit in practice: a card that rejects CMD59 (SPI-mode CRC on/off) after + // CMD0/CMD8 succeed. That's a card-firmware limitation (common on + // large/counterfeit SDXC), not a wiring fault — and we keep CRC required + // rather than run the user's notes over an unchecked bus. + 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?)")?; + + let storage = Storage { card }; + let (max_khz, real_khz) = storage.negotiated_khz(); + log::info!("SD mounted at {MOUNT} — max {max_khz} kHz, negotiated {real_khz} kHz"); + + match storage.recover().context("boot crash recovery")? { + Recovery::Clean => {} + Recovery::DiscardedTmp => log::warn!( + "recovery: found {NOTES_TMP} alongside {NOTES} — last save didn't finish; \ + kept the committed file, discarded the incomplete tmp" + ), + Recovery::PromotedTmp => log::warn!( + "recovery: found {NOTES_TMP} with no {NOTES} — promoted the tmp (it is the \ + newest complete copy)" + ), + } + Ok(storage) + } + + /// The card's ceiling and negotiated SPI clock, in kHz (`(max, real)`). + /// `real` is what SDSPI settled on after init and is the speed reads/writes + /// actually run at — worth logging on the bench where wiring caps it. + pub fn negotiated_khz(&self) -> (i32, i32) { + // SAFETY: `card` is a live handle for the lifetime of `self` (the mount + // is never torn down while a `Storage` exists). + unsafe { + ( + (*self.card).max_freq_khz as i32, + (*self.card).real_freq_khz as i32, + ) + } + } + + /// Total / free bytes on the FAT volume. + pub fn usage(&self) -> Result<(u64, u64)> { + let mut total: u64 = 0; + let mut free: u64 = 0; + esp!(unsafe { sys::esp_vfs_fat_info(MOUNT_C.as_ptr(), &mut total, &mut free) }) + .context("esp_vfs_fat_info")?; + Ok((total, free)) + } + + /// Whether the working copy exists. A missing `/sd/repo` means the card + /// wasn't provisioned (`just init`); the boot path treats that as fatal. + pub fn repo_present(&self) -> bool { + Path::new(REPO_DIR).is_dir() + } + + /// Read `notes.md` into a `String`. Returns an empty string if the file + /// doesn't exist yet (fresh, but provisioned, repo). Refuses a file larger + /// than [`MAX_FILE_BYTES`] rather than loading it. + pub fn load(&self) -> Result { + match fs::metadata(NOTES) { + Ok(m) if m.len() > MAX_FILE_BYTES => bail!( + "{NOTES} is {} KiB — over the {} KiB v0.1 limit; open it on a computer to split it", + m.len() / 1024, + MAX_FILE_BYTES / 1024 + ), + Ok(_) => fs::read_to_string(NOTES).with_context(|| format!("reading {NOTES}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(e) => Err(e).with_context(|| format!("stat {NOTES}")), + } + } + + /// Atomically persist `contents` to `notes.md`: write the tmp, fsync, + /// unlink the target, rename over it. See the module docs for why the unlink + /// is mandatory on FAT and [`Storage::recover`] for the crash window it opens. + pub fn save(&self, contents: &str) -> Result<()> { + { + let mut f = fs::File::create(NOTES_TMP) + .with_context(|| format!("create {NOTES_TMP} (is {REPO_DIR} present?)"))?; + f.write_all(contents.as_bytes()) + .with_context(|| format!("write {NOTES_TMP}"))?; + // FatFS f_sync — flush the tmp fully before it can replace the target. + f.sync_all().with_context(|| format!("fsync {NOTES_TMP}"))?; + } + // FatFS f_rename won't overwrite, so unlink the target first (tolerate a + // missing target: the first-ever save has nothing to remove). + match fs::remove_file(NOTES) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("unlink {NOTES} before rename")), + } + fs::rename(NOTES_TMP, NOTES).with_context(|| format!("rename {NOTES_TMP} -> {NOTES}"))?; + Ok(()) + } + + /// Reconcile a leftover `notes.md.tmp` at boot. The save sequence is + /// write-tmp → fsync → unlink-target → rename, so a lingering tmp means the + /// last save was interrupted. Which way to recover depends on whether the + /// target survived: + /// + /// - **tmp + target both present** — the crash could have been *during* the + /// tmp write (before fsync completed), so the tmp may be partial. The + /// target is the last fully-committed version. Keep it, delete the tmp. + /// Promoting a possibly-partial tmp over good data would be data loss. + /// - **tmp only, target absent** — the target was already unlinked, so we + /// crashed between unlink and rename. The tmp is the newest complete, + /// fsync'd copy and the only one left. Promote it (rename over the target). + /// - **neither / target only** — nothing to do. + /// + /// Idempotent and safe to call on every mount; a no-op when `/sd/repo` + /// doesn't exist (no tmp can be there). + fn recover(&self) -> Result { + if fs::metadata(NOTES_TMP).is_err() { + return Ok(Recovery::Clean); + } + if fs::metadata(NOTES).is_ok() { + fs::remove_file(NOTES_TMP) + .with_context(|| format!("discard stale {NOTES_TMP}"))?; + Ok(Recovery::DiscardedTmp) + } else { + fs::rename(NOTES_TMP, NOTES) + .with_context(|| format!("promote {NOTES_TMP} -> {NOTES}"))?; + Ok(Recovery::PromotedTmp) + } + } +}