Compare commits
2 Commits
54a9e31ee5
...
4bc9236bd7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bc9236bd7 | ||
|
|
9129f04d0e |
57
docs/adr.md
57
docs/adr.md
@@ -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 26–32,
|
||||
octal PSRAM 33–37, strapping 0/3/45/46, USB 19/20, RGB 38/48, EPD 4–7/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.
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user