docs(v0.1): add MVP product and technical design specs
This commit is contained in:
@@ -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
|
||||
|
||||
184
docs/v0.1-mvp-product.md
Normal file
184
docs/v0.1-mvp-product.md
Normal file
@@ -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 <timestamp>"
|
||||
git push
|
||||
status: "pushed abc1234" or "push failed: <reason>"
|
||||
```
|
||||
|
||||
## 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 <timestamp>"`.
|
||||
- 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."
|
||||
241
docs/v0.1-mvp-technical.md
Normal file
241
docs/v0.1-mvp-technical.md
Normal file
@@ -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<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.
|
||||
|
||||
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 <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.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.
|
||||
Reference in New Issue
Block a user