Read notes verbatim and insert the final newline only when it is missing,
instead of stripping the terminator on load and appending it unconditionally
on save. The editor's `rows = #\n + 1` model then renders a file's POSIX
terminator as a visible trailing blank line — what a writer expects: open a
note and see (and land the caret on) the empty line the newline stands for.
Supersedes the strip-on-load / unconditional-append handling that shipped
with the prefs work (c535864), which kept the buffer newline-free and hid the
terminator. Load + save are now an identity round-trip for any device-written
file (all end in '\n'); files stay git-clean (exactly one terminator); and a
trailing blank line the writer leaves is mirrored, never doubled.
- load_path: read verbatim (drop the strip)
- save_path: guarded final-newline (drop the unconditional append)
- Prefs::to_toml: ends in a newline again — the guarded save leaves exactly
one, so the prefs file is byte-identical to before and its device-verified
round-trip still holds
- sd_fat spike: payload ends in '\n' so its exact-equality round-trip holds
372 lines
18 KiB
Rust
372 lines
18 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` — the boot default note. Thin wrapper over
|
|
/// [`Storage::load_path`].
|
|
pub fn load(&self) -> Result<String> {
|
|
self.load_path(NOTES)
|
|
}
|
|
|
|
/// Read an arbitrary file under `/sd` into a `String`. Returns an empty string
|
|
/// if the file doesn't exist yet (a `:e` of a not-yet-created name, or a fresh
|
|
/// repo). Refuses a file larger than [`MAX_FILE_BYTES`] rather than loading it.
|
|
///
|
|
/// The multi-file (v0.5) load path: the editor names the file, the host reads
|
|
/// it here and hands the text back through `Editor::install_loaded`.
|
|
pub fn load_path(&self, path: &str) -> Result<String> {
|
|
match fs::metadata(path) {
|
|
Ok(m) if m.len() > MAX_FILE_BYTES => bail!(
|
|
"{path} is {} KiB — over the {} KiB limit; open it on a computer to split it",
|
|
m.len() / 1024,
|
|
MAX_FILE_BYTES / 1024
|
|
),
|
|
// Read the file verbatim. The editor's `rows = #\n + 1` model renders a
|
|
// trailing '\n' as an empty last line, and we *want* that: a note ends
|
|
// with a visible blank line that reflects its POSIX terminator. Since
|
|
// `save_path` guarantees that terminator, this load and that save form an
|
|
// identity round-trip for any device-written file (which always ends in
|
|
// '\n') — no strip needed, and none wanted.
|
|
Ok(_) => fs::read_to_string(path).with_context(|| format!("reading {path}")),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
|
|
Err(e) => Err(e).with_context(|| format!("stat {path}")),
|
|
}
|
|
}
|
|
|
|
/// Atomically persist `contents` to `notes.md`. Thin wrapper over
|
|
/// [`Storage::save_path`].
|
|
pub fn save(&self, contents: &str) -> Result<()> {
|
|
self.save_path(NOTES, contents)
|
|
}
|
|
|
|
/// Atomically persist `contents` to an arbitrary file under `/sd`: write the
|
|
/// tmp, fsync, unlink the target, rename over it. See the module docs for why
|
|
/// the unlink is mandatory on FAT. Boot recovery ([`Storage::recover`]) still
|
|
/// only covers the default `notes.md`; per-file recovery for the other v0.5
|
|
/// buffers is deferred to the v0.9 crash-safety work — the atomic swap here
|
|
/// already protects each individual save.
|
|
pub fn save_path(&self, path: &str, contents: &str) -> Result<()> {
|
|
let tmp = format!("{path}.tmp");
|
|
{
|
|
let mut f = fs::File::create(&tmp)
|
|
.with_context(|| format!("create {tmp} (does its directory exist?)"))?;
|
|
f.write_all(contents.as_bytes())
|
|
.with_context(|| format!("write {tmp}"))?;
|
|
// Insert a final newline only if the buffer lacks one (POSIX text
|
|
// convention; keeps git from flagging "No newline at end of file").
|
|
// `load_path` reads verbatim, so this is the sole place the terminator is
|
|
// guaranteed — and because it's guarded, the file mirrors the buffer's
|
|
// trailing newlines exactly: one visible trailing blank line stays one,
|
|
// never doubled. A buffer that already ends in '\n' passes through as-is.
|
|
if !contents.ends_with('\n') {
|
|
f.write_all(b"\n")
|
|
.with_context(|| format!("write final newline to {tmp}"))?;
|
|
}
|
|
// FatFS f_sync — flush the tmp fully before it can replace the target.
|
|
f.sync_all().with_context(|| format!("fsync {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(path) {
|
|
Ok(()) => {}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => return Err(e).with_context(|| format!("unlink {path} before rename")),
|
|
}
|
|
fs::rename(&tmp, path).with_context(|| format!("rename {tmp} -> {path}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Unlink a file under `/sd` (`:delete`). Tolerates a missing target — an
|
|
/// already-gone file is a success, so the call is idempotent. Also clears a
|
|
/// stray `{path}.tmp` best-effort, so a crash-interrupted save can't leave the
|
|
/// file half-present after a delete. For a Tracked file this leaves the
|
|
/// working copy short one file; the next publish's `add --all` stages it.
|
|
pub fn delete_path(&self, path: &str) -> Result<()> {
|
|
let _ = fs::remove_file(format!("{path}.tmp"));
|
|
match fs::remove_file(path) {
|
|
Ok(()) => Ok(()),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(e) => Err(e).with_context(|| format!("unlink {path}")),
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
}
|