Compare commits

...

2 Commits

Author SHA1 Message Date
Julien Calixte
4bc9236bd7 docs(adr): record ADR-012 — SD on its own SPI3 host
Document the shared-bus arbitration decision: rather than rework the
proven EPD SPI layer and add a cross-thread mutex on the save path, the
SD moves to a dedicated SPI3. Update the boot sequence, risk table,
Spike 3 postmortem follow-up, hardware overview, and firmware README.
2026-07-11 11:28:22 +02:00
Julien Calixte
9129f04d0e feat(sd): move the SD spike to its own SPI3 host
The EPD's SpiBusDriver holds an exclusive SPI2 lock for its lifetime, so
an SD on SPI2 is locked out while the panel driver is alive. Give the SD
its own SPI3 (SCK 14, MOSI 15; MISO 13 / CS 10 unchanged) and drop the
now-pointless EPD-CS deselect. See ADR-012.
2026-07-11 11:28:16 +02:00
6 changed files with 108 additions and 55 deletions

View File

@@ -549,6 +549,63 @@ into [ADR-007](#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-fl
---
## ADR-012: SD on its own SPI3 host (not shared with the EPD on SPI2)
**Status:** Accepted — 2026-07-11
**Scope:** v0.1 hardware; whole project.
### Context
The EPD (SSD1683) and the SD card both want SPI. The v0.1 plan (the boot
sequence in [v0.1 technical](v0.1-mvp-technical.md#hardware-bring-up-order) and
the storage context of
[ADR-007](#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config))
assumed **one shared SPI2 bus** with a per-device chip-select. Spike 3 (verified
2026-07-11, [postmortem](postmortems/2026-07-05-spike3-sd-cmd59.md)) proved the
SD works on the SPI2 wiring, but surfaced the integration blocker: the EPD driver
uses esp-idf-hal's `SpiBusDriver`, whose constructor takes an **exclusive
`spi_device_acquire_bus(BLOCK)` and holds it for the driver's whole lifetime**
(it must keep CS asserted across a cmd→data sequence while toggling DC). While
held, no other device on that host can transact — so an SD on SPI2 is locked out
for as long as the panel driver is alive. Compounding it, persistence/git runs on
a **dedicated thread** (Spike 7) while the EPD refreshes on the main task, so SD
and EPD access are genuinely concurrent.
### Options considered
| Option | Pros | Cons |
| --- | --- | --- |
| **Shared SPI2, arbitrate** | One bus; ~2 fewer GPIOs. | Rewrite the proven EPD SPI layer to per-transaction device drivers; add a cross-thread mutex around all SPI2 access; residual "corruption on save during render" risk on the highest-value path. |
| **SD on its own SPI3** | EPD code untouched; no arbitration/mutex; each bus at its own clock; matches the risk-table fallback exactly. | ~2 extra GPIOs + traces. |
### Decision
**SD gets its own SPI3 host.** The EPD keeps SPI2 and its exclusive-lock model,
unchanged. This is the mitigation the technical-doc risk table already names
("move SD to a separate SPI peripheral — ESP32-S3 has two"). SPI3 is free (SPI0/1
are flash + PSRAM; nothing else uses SPI3).
Pins — **SD on SPI3:** SCK 14, MOSI 15, MISO 13, CS 10 (MISO/CS unchanged from
the spike; only SCK/MOSI move off the EPD-shared 12/11). **EPD stays on SPI2:**
SCK 12, MOSI 11, CS 7, DC 6, RST 5, BUSY 4.
### Consequences
- No shared-bus arbitration or mutex — the git thread's SD I/O never contends
with an EPD refresh. Removes the "corruption on save during render" risk for
the device's first value (not losing the user's writing).
- Each bus runs at its own clock (EPD ~4 MHz on jumpers; SD 10 MHz+).
- Costs ~2 extra GPIOs + traces; the pin budget has room (avoids flash 2632,
octal PSRAM 3337, strapping 0/3/45/46, USB 19/20, RGB 38/48, EPD 47/11/12).
- Supersedes the "shared SPI2, different CS" assumption in the boot sequence and
ADR-007's storage context; the `sd_fat` spike is rewired to SPI3 and its
EPD-CS-deselect step (only meaningful on a shared bus) is removed.
- The `SpiBusDriver`-holds-the-lock mechanism was read from the constructor, not
re-verified on silicon; it doesn't affect this decision (SPI3 sidesteps it),
but is the first thing to confirm if a shared bus is ever revisited.
---
## How to add a new ADR
1. Append a new `## ADR-NNN: <title>` section to this file.

View File

@@ -29,6 +29,6 @@ The board is on the bench and bring-up is largely done — per-spike results
live in [`spikes.md`](spikes.md) and
[`v0.1-mvp-technical.md`](v0.1-mvp-technical.md#hardware-bring-up-order),
with failure write-ups in [`postmortems/`](postmortems/README.md). Notable: the
keyboard runs bus-powered on the S3's native USB port, and SD wiring is
proven on shared SPI2 but blocked on a compatible ≤32 GB card
([postmortem](postmortems/2026-07-05-spike3-sd-cmd59.md)).
keyboard runs bus-powered on the S3's native USB port, and the SD/FAT stack is
verified on a 32 GB card (2026-07-11), now moving to its own SPI3 host per
ADR-012 ([postmortem](postmortems/2026-07-05-spike3-sd-cmd59.md)).

View File

@@ -190,8 +190,9 @@ it is explicitly **not recommended** and not applied.
(2026-07-11).
- [x] Enable PSRAM (done, `CONFIG_SPIRAM`) and build Spike 7 (git push) — both
complete; see [Spike 7 postmortem](2026-07-05-spike7-gix-https-push.md).
- [ ] **Still open:** settle the **shared-bus arbitration** decision (EPD lock
vs. SPI3 for SD) before wiring persistence into `main.rs`.
- [x] Settle the **shared-bus arbitration** decision → **SD on its own SPI3**
(ADR-012, 2026-07-11); spike rewired to SPI3 (SCK 14 / MOSI 15), pending a
bench re-run on the new wiring.
- [ ] **Still open:** implement the FatFS atomic-save (unlink-then-rename +
`*.tmp` boot-recovery) in the real `persistence` module.

View File

@@ -65,7 +65,7 @@ read snapshot (render diff).
4. Mount FAT on SD → verify /sd/repo and /sd/repo/notes.md exist
├─ missing → fatal: "missing /sd/repo — re-mount SD and reboot"
└─ present → continue
5. Init SPI bus (shared: EPD + SD on different CS)
5. Init two SPI buses: EPD on SPI2, SD on SPI3 (separate hosts — ADR-012)
6. Init EPD, full refresh: splash + boot log
7. Start tasks: usb, wifi (spawned in `Off` state — no radio bring-up), ui, render
8. ui_task opens /sd/repo/notes.md, places cursor, enqueues full render
@@ -89,8 +89,8 @@ spike 4 is the gate for
— critically — whether `epd-waveshare` already supports the panel's
controller (SSD1683-class) or whether we write a thin custom driver
against `embedded-hal`. Either way, ~300 LoC; this spike answers which.
3. **Spike 3 — SD.** Mount FAT, read/write a file. Validates SPI sharing with
EPD (or separate bus if needed).
3. **Spike 3 — SD.** Mount FAT, read/write a file. Verified 2026-07-11; the
shared-bus question resolved to a separate SPI3 for the SD (ADR-012).
4. **Spike 4 — USB host.** Enumerate the Nuphy as a boot-protocol HID
keyboard, log keycodes over UART.
5. **Spike 5 — Partial refresh.** Type a string letter-by-letter, partial
@@ -342,7 +342,7 @@ Mirrored as live conflicts in
| TinyUSB host drops HID reports under load | dropped keystrokes during fast typing | enable larger USB rx buffer; if still bad, fall back to BLE-HID for v0.1 |
| EPD partial refresh slower than 200 ms | typing feels laggy | reduce font size to shrink dirty area; or render multi-char bursts |
| TLS heap pressure on PSRAM | OOM during push | tune mbedtls to smaller cipher suites; force GC of glyph cache before push |
| SD + EPD on same SPI bus collide | corruption on save during render | move SD to a separate SPI peripheral (ESP32-S3 has two) |
| SD + EPD on same SPI bus collide | corruption on save during render | **ADOPTED (ADR-012):** SD on its own SPI3 host |
Every one of these is detected by a spike before integration starts — we are
not finding them at the end.

View File

@@ -68,10 +68,12 @@ next build.
**Spike 3 — SD card (FAT) on shared SPI2: verified 2026-07-11.** A separate
binary — [`src/bin/sd_fat.rs`](src/bin/sd_fat.rs), flashed with `just flash-sd`
brings up the SD card on the EPD's SPI2 bus, mounts FAT at `/sd`, and exercises
the persistence module's atomic save (write `*.tmp` → fsync → rename →
read-back). Wiring: **SCK 12 · MOSI 11** (shared with the EPD) **· MISO 13**
(new; the write-only EPD never used it) **· SD CS 10** (EPD CS is 7).
brings up the SD card, mounts FAT at `/sd`, and exercises the persistence
module's atomic save (write `*.tmp` → fsync → rename → read-back). Per ADR-012
the SD runs on its **own SPI3 host** **SCK 14 · MOSI 15 · MISO 13 · SD CS 10**
— leaving the EPD alone on SPI2. (The 2026-07-11 bench proof below ran on the
earlier shared-SPI2 wiring; the code is now rewired to SPI3, pending a re-run
after moving the SCK/MOSI jumpers to 14/15.)
Bench result (genuine 32 GB SDHC card): mounts at 10 MHz, `29806 MiB total`,
atomic round-trip byte-identical. Two findings baked into the code:
@@ -85,10 +87,13 @@ atomic round-trip byte-identical. Two findings baked into the code:
the real persistence module must add `*.tmp` boot-recovery. Long filenames
(`CONFIG_FATFS_LFN_HEAP`) are required for the two-dot `*.md.tmp` name.
Still open before persistence lands in `main.rs`: the **shared-bus arbitration**
question — the EPD driver holds an exclusive SPI2 lock for its lifetime, so the
EPD and an arbitrated SD device can't both be live on one host yet (release/
re-acquire around EPD ops, or give the SD its own SPI3). This spike ran SD-only.
**Arbitration resolved (ADR-012):** the EPD driver holds an exclusive SPI2 lock
for its whole lifetime, and persistence runs on its own thread, so a shared bus
would need an EPD rewrite plus a cross-thread mutex on the save path. Instead the
SD gets its own SPI3 — the EPD stays untouched, no arbitration. Remaining before
persistence lands in `main.rs`: re-run the spike on the SPI3 wiring, then wire
the atomic save (unlink-then-rename + `*.tmp` boot-recovery) into a `persistence`
module.
**Spike 5 — partial refresh + typing: verified 2026-07-04.** `main.rs` wires
the keyboard to the panel: [`src/usb_kbd.rs`](src/usb_kbd.rs) feeds decoded

View File

@@ -1,25 +1,27 @@
//! Spike 3 — SD card (FAT) over the EPD's shared SPI2 bus.
//! Spike 3 — SD card (FAT) on its own SPI3 host.
//!
//! 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.
//! 1. Bring up SPI3 with the SD's four lines — SCK 14, MOSI 15, MISO 13, and
//! its own chip-select (10). This is a *dedicated* bus: the EPD keeps SPI2
//! (SCK 12, MOSI 11, CS 7). See ADR-012.
//! 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.
//! Why a dedicated SPI3 (ADR-012, decided 2026-07-11): 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, no other device on the same host can
//! transact — so an SD on SPI2 would be locked out for as long as the panel
//! driver is alive, and persistence runs on its own thread (Spike 7) concurrently
//! with EPD refreshes. Rather than rewrite the proven EPD SPI layer and add a
//! cross-thread mutex on the save path, we take the risk-table fallback: the SD
//! gets SPI3 to itself. This spike still drives SD-only, but now because it *is*
//! a separate bus, not to dodge contention.
//!
//! Two esp-idf notes baked in below:
//! - The `SDSPI_HOST_DEFAULT()` / `SDSPI_DEVICE_CONFIG_DEFAULT()` C macros are
@@ -45,20 +47,16 @@ 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;
// SD wiring on its own SPI3 host (ADR-012). MISO 13 and CS 10 are unchanged from
// the original shared-bus spike; only SCK/MOSI move off the EPD-shared 12/11 onto
// dedicated pins so the two buses are fully independent.
const PIN_SCK: i32 = 14;
const PIN_MOSI: i32 = 15;
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
/// SD clock. Deliberately conservative for bench jumper wires: SDSPI's 20 MHz
/// default is prone to CRC errors on long unterminated jumpers, 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;
@@ -116,16 +114,8 @@ fn run() -> Result<()> {
/// 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).
// 1) Initialize 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: 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() };
@@ -137,12 +127,12 @@ fn mount_sd() -> Result<*mut sys::sdmmc_card_t> {
bus.max_transfer_sz = 4096;
esp!(unsafe {
sys::spi_bus_initialize(
sys::spi_host_device_t_SPI2_HOST,
sys::spi_host_device_t_SPI3_HOST,
&bus,
sys::spi_common_dma_t_SPI_DMA_CH_AUTO as _,
)
})
.context("spi_bus_initialize(SPI2)")?;
.context("spi_bus_initialize(SPI3)")?;
// 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
@@ -165,7 +155,7 @@ fn mount_sd() -> Result<*mut sys::sdmmc_card_t> {
// 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.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;
@@ -183,7 +173,7 @@ fn mount_sd() -> Result<*mut sys::sdmmc_card_t> {
// 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.host_id = sys::spi_host_device_t_SPI3_HOST;
slot.gpio_cs = PIN_CS;
slot.gpio_cd = -1;
slot.gpio_wp = -1;