From 490e2930e354cd410f3de5cf87e3f56e307794bd Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Thu, 14 May 2026 11:49:50 +0200 Subject: [PATCH] docs(v0.1): add MVP product and technical design specs --- README.md | 3 +- docs/v0.1-mvp-product.md | 184 ++++++++++++++++++++++++++++ docs/v0.1-mvp-technical.md | 241 +++++++++++++++++++++++++++++++++++++ 3 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 docs/v0.1-mvp-product.md create mode 100644 docs/v0.1-mvp-technical.md diff --git a/README.md b/README.md index 106a2f3..41cf88a 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,8 @@ Frequent releases. Each version is a usable artifact, not a checkpoint. ### v0.1 — MVP: "it writes, it pushes" — [ ] -The minimum thing that justifies the hardware existing. +The minimum thing that justifies the hardware existing. Full design: +[product](docs/v0.1-mvp-product.md) · [technical](docs/v0.1-mvp-technical.md). - [ ] ESP32-S3 boots, e-ink shows splash + boot log - [ ] USB host enumerates the Nuphy, key events reach the editor diff --git a/docs/v0.1-mvp-product.md b/docs/v0.1-mvp-product.md new file mode 100644 index 0000000..f7ce3e6 --- /dev/null +++ b/docs/v0.1-mvp-product.md @@ -0,0 +1,184 @@ +# v0.1 MVP — Product design + +> Scope: the minimum hardware-justifying release. If you take any of this +> away, the device stops being a typewriter. + +## One-line summary + +Boot the device, type into one file, hit `Ctrl-S` to save, hit `Ctrl-G` to +push it to GitHub. That's the entire product surface. + +## Target user + +Me. Solo. v0.1 is not for anyone else — it's the bring-up vehicle that proves +the loop closes end to end. + +## User stories + +1. **First run.** I plug in the device for the first time. It walks me through + Wi-Fi setup, asks for a GitHub Personal Access Token and a repository URL, + and stores them. I never want to do this step twice. + +2. **Write.** I open the lid (= plug in power). Within a few seconds the + screen shows my `notes.md` file. I start typing. What I type appears on + screen with sub-second latency. + +3. **Save.** I hit `Ctrl-S`. The file on SD is updated. A short status flash + confirms it. + +4. **Push.** I hit `Ctrl-G`. The device commits and pushes to GitHub. The + status line shows progress; if it succeeds, the commit hash is briefly + shown. If it fails, I see why. + +5. **Recover.** Power gets pulled mid-edit. I plug it back in. My file is at + the last saved state — I lose unsaved changes, but the file isn't + corrupted. + +That's it. No other stories ship in v0.1. + +## First-run provisioning flow + +``` +[POWER ON, no config in NVS] + │ + ▼ + Splash: "typewriter v0.1 — first-run setup" + │ + ▼ + Wi-Fi: device starts AP "typewriter-setup" + Screen shows: SSID, password, http://192.168.4.1 + │ + ▼ + User connects from phone/laptop, enters: + • home Wi-Fi SSID + password + • git remote URL (HTTPS) + • GitHub username + • GitHub PAT (with `repo` scope) + • commit author name + email + │ + ▼ + Device validates: connects to Wi-Fi, clones the repo into /sd/repo + │ + ▼ + Screen: "ready. opening notes.md" + │ + ▼ + [STEADY STATE] +``` + +If the clone fails (bad PAT, no `notes.md` in the repo, etc.), the screen +shows the error and offers retry. The repo is expected to already exist on +GitHub and contain (or be willing to contain) `notes.md` — v0.1 does not +create remote repos. + +## Steady-state flow + +``` +[POWER ON, config present] + │ + ▼ + Boot (~3 s): Wi-Fi reconnect in background + │ + ▼ + Open /sd/repo/notes.md, place cursor at end of file + │ + ▼ + [EDITING] ← keypresses, partial refresh per edit + │ + ├── Ctrl-S → write file, full refresh, status: "saved 14:02" + │ + └── Ctrl-G → save if dirty, then: + git add notes.md + git commit -m "wip " + git push + status: "pushed abc1234" or "push failed: " +``` + +## Screen layout + +Waveshare 7.5" V2 — 800×480, 1-bit. + +``` +┌──────────────────────────────────────────────────────────────┐ +│ notes.md ● 14:02:33 │ ← header (1 line) +├──────────────────────────────────────────────────────────────┤ +│ │ +│ Lorem ipsum dolor sit amet, consectetur adipiscing elit. │ +│ Sed do eiusmod tempor incididunt ut labore et dolore magna │ +│ aliqua. │ +│ │ ← edit area +│ The quick brown fox jumps over the lazy▎ │ (cursor block) +│ │ +│ ... │ +│ │ +├──────────────────────────────────────────────────────────────┤ +│ INSERT • unsaved • Wi-Fi ✓ │ ← status (1 line) +└──────────────────────────────────────────────────────────────┘ +``` + +- Header: filename, dirty indicator (●/○), wall clock if Wi-Fi has set time. +- Status: mode (always `INSERT` in v0.1), save state, Wi-Fi state. After a + `Ctrl-S` / `Ctrl-G`, status briefly shows the action result (saved at / + pushed `abc1234` / error message) then returns to default. +- Cursor: solid block at the insertion point. **No blink.** + +Fonts: one mono font, embedded in firmware. Probably IBM Plex Mono at a size +that gives ~70 columns and ~25 lines of edit area. + +## Error UX + +| Failure | What the user sees | +|---|---| +| No SD card on boot | "no SD card — insert one and reboot" | +| SD card mounted but `/sd/repo` missing | drop back into first-run setup | +| Wi-Fi won't connect | status shows `Wi-Fi ✗`; editing still works; `Ctrl-G` shows "no network" | +| `Ctrl-G` push rejected (auth, non-fast-forward, etc.) | status shows reason; commit stays local; user can retry | +| File write fails | status flashes "save failed"; buffer stays dirty | +| Keyboard disconnects | header shows `⌨ ✗`; editing pauses; reconnects automatically | + +No modal dialogs. Errors live in the status line. The editor is never blocked +from accepting keystrokes (except during the partial-refresh frame itself). + +## Out of scope for v0.1 + +Explicitly **not** in this release, to keep it shippable: + +- Vim modes (Normal/Visual). Everything is Insert. +- Line numbers. +- Multiple files / file palette. +- Branches. Always on whatever branch was cloned (default). +- `git pull`. Manual recovery if the remote diverges. +- Conflict handling. +- Commit message editing — always `"wip "`. +- Battery. Wall power only. +- Sleep / lid-close behaviour. +- Font selection. +- Settings UI after first-run (re-flash or wipe NVS to reconfigure). +- Markdown rendering (headings, bold, etc.) — plain text rendering of MD. +- Search. + +## Acceptance criteria + +v0.1 ships when **all** of these pass on real hardware: + +- [ ] First-run setup completes in under 5 minutes including typing in a PAT. +- [ ] After a cold boot with valid config, cursor is ready in ≤ 5 s. +- [ ] Typing a 1000-word paragraph never drops a keystroke and never lags + more than 300 ms behind the keyboard. +- [ ] `Ctrl-S` durably writes the file (verified by power-cycling immediately + after the status confirms save — file is intact). +- [ ] `Ctrl-G` produces a commit visible on github.com within 30 s on a + typical home Wi-Fi. +- [ ] Pulling power during typing never corrupts the file; the previous saved + state is recoverable. +- [ ] One hour of continuous typing without crash, freeze, or memory + exhaustion. + +## Non-goals as success criteria + +These are deliberately *not* measured in v0.1: + +- Boot time below 3 s (Phase 1 target). +- Battery life (no battery yet). +- Push latency under any specific threshold beyond "the user doesn't think + it's stuck." diff --git a/docs/v0.1-mvp-technical.md b/docs/v0.1-mvp-technical.md new file mode 100644 index 0000000..26ea05b --- /dev/null +++ b/docs/v0.1-mvp-technical.md @@ -0,0 +1,241 @@ +# 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 7.5" panel with `epd-waveshare`; full refresh + "hello world." Validates SPI wiring, panel timings. +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 | ~256 KB | EPD framebuffer (800×480×1 = 48 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.