Adds a companion-docs header and inline ADR jumps on every load-bearing choice (ADR-001 runtime, ADR-002 UI, ADR-004 git kill-switch, ADR-006 concurrency, ADR-007 storage, ADR-009 USB-host gate). Render module also points at qfd §3 to anchor "why these functions rank top."
12 KiB
v0.1 MVP — Technical design
Scope: ships the product surface in
v0.1-mvp-product.md. No more, no less.Decisions referenced inline point at
adr.md. Tradeoff weights and the critical-performance budget live inqfd.md. Project overview:../README.md. Release sequence:roadmap.md.
Architecture
Single Rust binary on esp-idf-rs (std) —
ADR-001.
Two cores, several threads, one shared editor state behind a mutex
(ADR-006).
Wi-Fi and git work happens off the input thread so the typing path is never
blocked by I/O.
┌──────────────────────────────────────────┐
│ Core 0 (PRO_CPU) │
│ │
USB HID ───► │ usb_task ─► KeyEvent channel ──┐ │
│ │ │
│ wifi_task ◄─► esp-wifi internals │ │
│ │ │
└───────────────────────────────────────│───┘
│
┌───────────────────────────────────────▼───┐
│ Core 1 (APP_CPU) │
│ │
│ ui_task ──► editor state ──► render_task│
│ ▲ │ │ │
│ │ ▼ ▼ │
│ └── git_task ──┘ SPI ──► EPD │
│ ▲ │
│ └── SD I/O │
│ │
└───────────────────────────────────────────┘
Threads / tasks
| Task | Core | Stack | Responsibility |
|---|---|---|---|
usb_task |
0 | 8 KB | TinyUSB host loop, decode HID reports, post KeyEvents |
wifi_task |
0 | 8 KB | Provisioning AP or station mode; expose status |
ui_task |
1 | 16 KB | Consume KeyEvents, mutate editor state, enqueue render |
render_task |
1 | 12 KB | Drain render queue, do partial/full refresh on EPD |
git_task |
1 | 32 KB | Triggered by Ctrl-G; runs gitoxide commit + push |
All inter-task communication is via crossbeam-channel or std::sync::mpsc
bounded queues. The editor state is Arc<Mutex<EditorState>>; the lock is
held only for the duration of a single mutation (key application) or a single
read snapshot (render diff).
Boot sequence
1. ROM → bootloader → app_main
2. Init PSRAM allocator, set as default for large alloc
3. Mount LittleFS on internal flash → read /nvs/config.toml
├─ no config → enter PROVISIONING mode
└─ config OK → continue
4. Mount FAT on SD → verify /sd/repo exists
├─ missing → enter PROVISIONING mode (clone)
└─ present → continue
5. Init SPI bus (shared: EPD + SD on different CS)
6. Init EPD, full refresh: splash + boot log
7. Start tasks: usb, wifi (station), ui, render
8. ui_task opens /sd/repo/notes.md, places cursor, enqueues full render
9. STEADY STATE
Target boot time: ≤ 5 s to cursor (v0.1). The 3 s target is v1.0.
Hardware bring-up order
We do not try to build the whole stack and turn it on. Each spike below is a small program that proves one risk before we commit to the next layer. Spike 7 is the kill-switch for ADR-004 (gitoxide); spike 4 is the gate for ADR-009 (USB host).
- Spike 1 — Blink. Confirm toolchain, flash, and basic GPIO.
- Spike 2 — EPD. Drive the GDEY0579T93 (via DESPI-c579 breakout) over
SPI; full refresh "hello world." Validates SPI wiring, panel timings, and
— critically — whether
epd-wavesharealready supports the panel's controller (SSD1683-class) or whether we write a thin custom driver againstembedded-hal. Either way, ~300 LoC; this spike answers which. - Spike 3 — SD. Mount FAT, read/write a file. Validates SPI sharing with EPD (or separate bus if needed).
- Spike 4 — USB host. Enumerate the Nuphy as a boot-protocol HID keyboard, log keycodes over UART.
- Spike 5 — Partial refresh. Type a string letter-by-letter, partial refresh per character, measure end-to-end latency.
- Spike 6 — Wi-Fi + TLS. Connect to home Wi-Fi, do an HTTPS GET to
api.github.com, validate cert chain. - Spike 7 — gitoxide push. Smoke test: from desktop-Rust first, then on device, push a single commit to a test repo over HTTPS+PAT.
Only after spike 7 do we start integration. Any spike that fails forces a stack decision (e.g. fall back to libgit2; switch to a separate SD SPI bus).
Module breakdown
editor — text buffer + cursor
Buffer = Ropey::Ropebacked by PSRAM allocator.Cursor { line: usize, col: usize, byte_offset: usize }— single cursor, Insert mode only.apply(KeyEvent) -> EditOp— pure function from key to operation; returns the dirty range for render.- Soft wrap is computed at render time from the rope, not stored.
keymap — v0.1 keymap table
| Key | Action |
|---|---|
| printable | insert char |
Backspace |
delete char before cursor |
Enter |
insert \n |
← → ↑ ↓ |
move cursor (visual lines for ↑↓) |
Home End |
line start / end |
Ctrl-S |
save |
Ctrl-G |
save (if dirty) + commit + push |
Anything else is ignored in v0.1.
render — dirty-rect to EPD
Custom widget layer on embedded-graphics —
ADR-002.
Owns the top-ranked engineering functions (latency, refresh-region area)
in qfd.md §3.
- A render request is a
RenderOp { Lines(range) | Status(text) | FullRefresh }. - The render thread maintains a shadow of the current screen contents per region (header / edit area lines / status).
- For
Lines(range): clear the region's bounding box, draw glyphs, partial refresh that rect. - Glyph cache: rasterised mono font kept in PSRAM; one entry per (codepoint).
- After 20 partial refreshes, the next render is upgraded to a full refresh to clear ghosting. Counter persists across saves.
- A
FullRefreshis forced on save, on focus return, and on screen-clear.
persistence — SD I/O
Storage split rationale: ADR-007.
- Atomic save: write to
notes.md.tmp, fsync, rename. We accept FAT's weakness here; on power loss between rename and dir flush, the user gets the previous version, which is the documented behavior. - The file is read fully into the rope at boot. v0.1 caps file size at 256 KB; larger files refuse to open with a clear message.
wifi — provisioning + station
- Provisioning: SoftAP
typewriter-setup, captive-portal DNS, tiny HTTP server (esp-idf-svc::http). Form submits config; on success, restart in station mode. - Station: reconnect with exponential backoff (1, 2, 5, 10, 30, 60 s, hold
at 60). Status exposed via a
WifiStateatomic enum. - Time sync via SNTP — needed for commit timestamps. Failure is logged but non-fatal; commits use device uptime if SNTP fails.
git — commit + push
gitoxide choice and kill-switch:
ADR-004.
Auth model: ADR-005.
PSRAM heap during push is a top-3 watched metric — see
qfd.md §6.
gixwith the smart-HTTP transport backed byesp-idfmbedtls (via a custom transport impl, orgix-transportwithreqwest+rustls-mbedtlsif that path is cleaner — decided in spike 7).- Operations needed in v0.1:
gix::openthe existing working copy at/sd/repo- stage
notes.md(gix::indexadd) - commit with author from config, message
"wip <ISO-8601 timestamp>" - push HEAD to
origin/<current branch>
- The PAT is loaded into the Authorization header per request; never logged.
- Push errors propagate as a string back to the status line.
provisioning — first-run wizard
- Triggered when
config.tomlis absent OR/sd/repois absent. - Captive portal posts a JSON blob; device validates by:
- Connecting to the supplied Wi-Fi credentials.
- Cloning the supplied repo URL into
/sd/repousing the supplied PAT.
- Only on both successes does it persist config and reboot into steady state.
Memory plan
ESP32-S3-N16R8: 512 KB SRAM + 8 MB PSRAM. We budget conservatively.
| Region | Approx | Use |
|---|---|---|
| Internal SRAM | ~120 KB | task stacks, DMA buffers, hot code paths |
| Internal SRAM | ~80 KB | mbedtls runtime (TLS handshake working set) |
| PSRAM | ~128 KB | EPD framebuffer (792×272×1 ≈ 27 KB) + shadow + glyph cache |
| PSRAM | ~512 KB | rope buffer + edit history headroom |
| PSRAM | ~1.5 MB | gitoxide working set during push (pack delta, etc.) |
| PSRAM | rest | heap headroom |
PSRAM is the default for Box::new via a custom allocator wrapper; DMA-able
allocations must explicitly request internal SRAM (epd-waveshare needs this
for SPI buffers).
Concurrency model
Rationale and rejected alternatives: ADR-006.
- Editor state behind a single
Mutex. Holders:ui_task(writer),render_task(reader, snapshot then unlock),git_task(reader for save). - No
await/ no async runtime — std threads only. Simpler debugging on embedded; the workload doesn't justify async overhead. git_taskis spawned on demand byCtrl-G. Only one push in flight; a secondCtrl-Gwhile one is running is ignored with a status message.
Error handling
anyhow::Resultat task boundaries.- Errors that reach a task's top level are formatted and posted to the
Statuschannel. - A task never panics in v0.1 production builds (debug builds keep panics). Anything that would panic gets surfaced as a status message and the task restarts itself.
File layout
SD card (FAT):
/sd/repo/ ← git working copy
.git/
notes.md ← the only file v0.1 opens
/sd/local/ ← reserved, unused in v0.1
Internal LittleFS:
/nvs/config.toml ← Wi-Fi creds, PAT (encrypted), remote URL,
author name/email, last-known branch
PAT encryption uses an HMAC key derived from the chip's eFuse block, so a
stolen SD card alone isn't enough; an attacker would need the device. v0.1
does not implement key rotation.
Test plan
- Host-side unit tests: rope ops, keymap dispatch, dirty-rect math, config parsing. Pure-Rust, run in CI.
- Hardware-in-the-loop: a checklist run before declaring v0.1 done, matching the product doc's acceptance criteria. No automated rig yet.
- Long-run soak: 1 hour continuous typing via a USB keystroke generator (any laptop) on the bench. Watch heap, dropped keys, ghosting.
Risks and how we'll know they bit us
| Risk | Symptom we'd see | Fallback |
|---|---|---|
gix smart-HTTP push doesn't work on esp-idf-rs mbedtls |
spike 7 fails | switch to libgit2-sys (C, well-trodden) for v0.1 only |
| 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) |
Every one of these is detected by a spike before integration starts — we are not finding them at the end.