# v0.1 MVP — Technical design > Scope: ships the product surface in [`v0.1-mvp-product.md`](v0.1-mvp-product.md). > No more, no less. > > Decisions referenced inline point at [`adr.md`](adr.md). Tradeoff weights > and the critical-performance budget live in [`qfd.md`](qfd.md). Project > overview: [`../README.md`](../README.md). Release sequence: > [`macroplan.md`](macroplan.md). ## Architecture Single Rust binary on `esp-idf-rs` (std) — [ADR-001](adr.md#adr-001-language-and-runtime--rust-on-esp-idf-rs-std). Two cores, several threads, one shared editor state behind a mutex ([ADR-006](adr.md#adr-006-concurrency--stdthread--channels-no-async-runtime)). 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 `KeyEvent`s | | `wifi_task` | 0 | 8 KB | On-demand station: off by default, brought up by `Ctrl-G` | | `ui_task` | 1 | 16 KB | Consume `KeyEvent`s, mutate editor state, enqueue render | | `render_task` | 1 | 12 KB | Drain render queue, do partial/full refresh on EPD | | `git_task` | 1 | 96 KB | Triggered by `Ctrl-G`; runs libgit2 commit + push | All inter-task communication is via `crossbeam-channel` or `std::sync::mpsc` bounded queues. The editor state is `Arc>`; 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 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 9. STEADY STATE ``` Target boot time: ≤ 5 s to cursor (v0.1). The 3 s target is v1.0. Measured cold boot is **4258 ms** (2026-07-11) — under the v0.1 gate; the full breakdown and why ≤ 3 s is hard (one ~1.9 s full refresh is unavoidable at cold boot) is in [`notes/boot-time-budget.md`](notes/boot-time-budget.md). ## 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)](adr.md#adr-004-git-implementation--gitoxide-gix); spike 4 is the gate for [ADR-009 (USB host)](adr.md#adr-009-keyboard-transport--usb-host-tinyusb). 1. **Spike 1 — Blink.** Confirm toolchain, flash, and basic GPIO. 2. **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-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. 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 refresh per character, measure end-to-end latency. 6. **Spike 6 — Wi-Fi + TLS.** Connect to home Wi-Fi, do an HTTPS GET to `api.github.com`, validate cert chain. 7. **Spike 7 — git push.** Smoke test: from desktop-Rust first, then on device, push a single commit to a test repo over HTTPS+PAT. **Done** — the gitoxide fall-back to **libgit2** (`git2`) fired here; push is proven on hardware ([postmortem](postmortems/2026-07-05-spike7-gix-https-push.md)). 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.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::Rope` backed 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](adr.md#adr-002-ui-strategy--custom-widgets-on-embedded-graphics-not-ratatui). Owns the top-ranked engineering functions (latency, refresh-region area) in [qfd.md §3](qfd.md#3-house-of-quality--whats--hows). - A render request is a `RenderOp { Lines(range) | Panel(field, text) | FullRefresh }`. The **side panel** is multi-field (Spike 8), so the old single `Status(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 `FullRefresh` is forced on save, on focus return, and on screen-clear. ### `persistence` — SD I/O Storage split rationale: [ADR-007](adr.md#adr-007-storage-split--fat-on-sd-for-working-copy-littlefs-on-flash-for-config). Implemented in [`firmware::persistence`](../firmware/src/persistence.rs) (`Storage::{mount, load, save, recover}`), graduated from Spike 3 and hardware-verified 2026-07-11. - Atomic save: write to `notes.md.tmp`, fsync, unlink the target (FatFS `f_rename` won't overwrite — ADR-007), rename. On power loss the user gets the previous version, the documented behavior. Boot recovery reconciles a leftover `*.tmp`: kept-target-if-both-present, promote-if-target-gone (see ADR-007 for why "just promote" is unsafe). - 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. Saving is not capped — the buffer is always persistable. - **Wired into `main.rs`** (2026-07-11): the editor boot-loads the note via `Editor::with_text(Storage::load())` — a missing card / repo / unreadable note halts boot with the reason painted on the panel — and `:w`/`:sync` persist through `Storage::save`, inline on the UI loop (a small-file write is tens of ms). Save errors are logged and the RAM buffer kept, so a card pulled mid-session doesn't lose work. The git-push half of `:sync` is **not** wired yet: it awaits `git_sync` being graduated into a module and run on the dedicated git thread (see `git` below), so `:sync` currently just saves. ### `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](qfd.md#6-critical-performance-budget). - **Bring-up on `Ctrl-G`.** `wifi_task` transitions `Off → 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-G` inside 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 `WifiState` atomic 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 Library + kill-switch: [ADR-004](adr.md#adr-004-git-implementation--gitoxide-gix) — the gitoxide bet was **retired in spike 7** (gix has no HTTP(S) push at all), so the module is built on **libgit2 via the `git2` crate**. Auth + transport are settled: **HTTPS + PAT over esp-idf's mbedTLS** ([ADR-005](adr.md#adr-005-auth--https--github-personal-access-token)) — no custom transport and no `reqwest`/`rustls` layer are needed; libgit2's own smart-HTTP client speaks TLS through the mbedTLS stream it is compiled against. The full `init → commit → push` path is **proven on hardware** — see the [spike 7 postmortem](postmortems/2026-07-05-spike7-gix-https-push.md). PSRAM heap during push is a top-3 watched metric — see [qfd.md §6](qfd.md#6-critical-performance-budget). - libgit2 **1.9.4** is vendored as an esp-idf component (`firmware/components/libgit2`) built with `GIT_HTTPS + GIT_MBEDTLS`; `git2` binds it in system mode. A short `esp_stubs.c` supplies the POSIX calls picolibc/FATFS lack (`utimes` existence-gate, `p_rename` replace, uid/symlink). - The server cert chain is verified against an embedded GitHub root bundle (`GIT_OPT_SET_SSL_CERT_LOCATIONS`); the `certificate_check` callback returns PASSTHROUGH so libgit2's own verdict stands → **fail-closed** on an untrusted / MITM cert. A product should refresh those roots (or reuse esp-idf's bundle) at provisioning. - Runs on a **dedicated ~96 KB thread**, not the shared UI/main stack: libgit2's init→push chain nests ~10 `GIT_PATH_MAX` (4 KB) buffers (~67 KB measured). - Operations needed in v0.1 (the [`gct` shell function](../CONTEXT.md#user-facing-actions) is the reference): - open a **persistent clone** at `/sd/repo` (spike 7 proved the flow on a fresh per-boot init pushing a throwaway `device/` branch, to isolate the transport from history reconciliation; the product keeps one clone and fast-forwards — storage is [ADR-007](adr.md), still SD-vs-flash-FAT while SD is blocked) - stage everything under the working copy (`git add --all`, incl. deletions) - short-circuit return if nothing is staged — status: "nothing to publish" - commit with author from config, message `""` (no `wip` prefix; the timestamp _is_ the message) - push HEAD to `origin/` (normally a clean fast-forward) - 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 handed to libgit2's credential callback (`USER_PASS_PLAINTEXT`); never logged, never persisted to the working copy. - The whole sequence is atomic from the user's view — see [`CONTEXT.md` → Publish](../CONTEXT.md#user-facing-actions). ### 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: ```sh 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 | libgit2 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](adr.md#adr-006-concurrency--stdthread--channels-no-async-runtime). - 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_task` is spawned on demand by `Ctrl-G`. Only one push in flight; a second `Ctrl-G` while one is running is ignored with a status message. ## Error handling - `anyhow::Result` at task boundaries. - Errors that reach a task's top level are formatted and posted to the `Status` channel. - 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"](qfd.md#7-tradeoffs-and-their-why-linked-to-adrs). | Risk | Symptom we'd see | Fallback | | ---------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------- | | ~~`gix` smart-HTTP push doesn't work on `esp-idf-rs` mbedtls~~ **fired 2026-07-05**: gix has no HTTP(S) push at all (only `file://`/`ssh://`) | spike 7 (desktop) | **switched to `libgit2` (`git2`)** — mechanics proven on desktop; on-device libgit2/mbedtls cross-compile is the new gate. See [postmortem](postmortems/2026-07-05-spike7-gix-https-push.md). | | 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 | **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.