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.
323 lines
16 KiB
Rust
323 lines
16 KiB
Rust
//! 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<Self> {
|
|
// 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<String> {
|
|
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<Recovery> {
|
|
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)
|
|
}
|
|
}
|
|
}
|