Spikes 8-14 are a cross-release display/UX bench batch, not part of the v0.1 integration gate — only 8 and 9 feed v0.1. Move them out of the v0.1 technical doc into their own log (with a feeds-table and dependency notes), leaving a pointer behind; link it from the README layout map.
17 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 | On-demand station: off by default, brought up by Ctrl-G |
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. Config is compiled into the binary (`build.rs` reads env vars) — no
filesystem read needed for it. LittleFS is unused in v0.1.
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)
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
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).
Rendering spikes (post-bring-up)
The display/UX spikes (8–14) — panel layout, boot splash, theme, transient
panel, scroll indicator, line-number gutter, multi-file navigation — are not
part of the v0.1 integration gate and span several releases, so they live in
their own log: spikes.md. Spikes 8 (layout) and 9 (splash)
feed v0.1; the rest feed later releases. Run Spike 8 first — it partitions the
panel into the writing column and side panel the others draw within.
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 |
publish (save → stage → commit → push, with pull-and-retry on remote divergence) |
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) | Panel(field, text) | FullRefresh }. The side panel is multi-field (Spike 8), so the old singleStatus(text)op becomes a per-field write; the exact field enum is what Spike 8 decides. - The render thread maintains a shadow of the current screen contents per region (writing-column lines / side-panel fields).
- 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 — on-demand station
- Off by default. The radio is powered down (
esp_wifi_stop) except during a Publish and a short grace window after. This is the load-bearing battery decision: always-on station mode burns ~410 mAh/day just for the radio; on-demand is closer to ~25 mAh/day at 10 Publishes/day. See qfd.md H13. - Bring-up on
Ctrl-G.wifi_tasktransitionsOff → Associating → Connected. Association timeout: 10 s. If association fails, the local commit is preserved and the side panel's Wi-Fi field shows✗; no background retry. - Grace window of ~90 s after a Publish completes. A
Ctrl-Ginside the window reuses the connection (no second association cost). If no Publish fires before the window expires, the radio tears down. - No reconnect-backoff state machine. There is nothing to reconnect to when the user isn't trying to Publish. Single-shot try-on-demand replaces the exponential-backoff approach.
- SNTP runs on the first successful bring-up of a power session. The result is cached for up to 24 h; subsequent Publishes use the cached time for the commit message. Falls back to device uptime if SNTP has never succeeded — logged but non-fatal.
- Status exposed via a
WifiStateatomic enum (Off,Associating,Connected,Failed).
On-device provisioning (SSID, PAT rotation, etc.) is deliberately deferred to v0.9. v0.1 reads all config from the binary at build time — see "Provisioning — build-time only" below.
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 (the
gctshell function is the reference):gix::openthe existing working copy at/sd/repo- stage everything under the working copy (
git add .equivalent) - short-circuit return if nothing is staged — status: "nothing to publish"
- commit with author from config, message
"<ISO-8601 timestamp>"(nowipprefix; the timestamp is the message) - push HEAD to
origin/<current branch> - on push failure:
git pull --no-edit(merge), then retry the push once. Only surface failure to the side panel's publish-state field if the pull conflicts or the second push also fails.
- The PAT is loaded into the Authorization header per request; never logged.
- The whole sequence is atomic from the user's view — see
CONTEXT.md→ Publish.
Provisioning — build-time only (no module on device)
v0.1 has no provisioning module, no NVS config, no LittleFS mount. All config (Wi-Fi creds, remote URL, GitHub user, PAT, commit author) is supplied as environment variables at build time:
export TW_WIFI_SSID=... TW_WIFI_PASS=...
export TW_REMOTE_URL=... TW_GH_USER=... TW_PAT=...
export TW_AUTHOR_NAME=... TW_AUTHOR_EMAIL=...
cargo espflash --release
build.rs reads these (or fails the build) and emits constants the runtime
references directly. The .env file used to source these is gitignored.
The git working copy is set up out-of-band: the dev clones the remote onto the mounted SD card from their laptop. There is no "first clone on device" in v0.1.
Net savings vs. an on-device wizard: ~300–500 LoC of firmware (HTTP server, captive AP, form parsing, validation state machine, NVS read/write, TOML parser). On-device provisioning + NVS-backed config land in v0.9 when non-dev users enter the picture.
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
Mirrored as live conflicts in qfd.md §7 "Conflicts left explicitly unresolved by v0.1".
| 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.