# 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. ## Architecture Single Rust binary on `esp-idf-rs` (std). Two cores, several threads, one shared editor state behind a mutex. 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 | Provisioning AP or station mode; expose status | | `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 | 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>`; 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. 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. Validates SPI sharing with EPD (or separate bus if needed). 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 — 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::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` | save (if dirty) + commit + push | Anything else is ignored in v0.1. ### `render` — dirty-rect to EPD - 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 `FullRefresh` is forced on save, on focus return, and on screen-clear. ### `persistence` — SD I/O - 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 `WifiState` atomic 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 - `gix` with the smart-HTTP transport backed by `esp-idf` mbedtls (via a custom transport impl, or `gix-transport` with `reqwest`+`rustls-mbedtls` if that path is cleaner — decided in spike 7). - Operations needed in v0.1: - `gix::open` the existing working copy at `/sd/repo` - stage `notes.md` (`gix::index` add) - commit with author from config, message `"wip "` - push HEAD to `origin/` - 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.toml` is absent OR `/sd/repo` is absent. - Captive portal posts a JSON blob; device validates by: 1. Connecting to the supplied Wi-Fi credentials. 2. Cloning the supplied repo URL into `/sd/repo` using 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 - 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 | 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.