Files
typewriter/docs/v0.1-mvp-technical.md
Julien Calixte 421270cdf2 docs(hardware): switch display to GDEY0579T93 with DESPI-c579
Strip-aspect 5.79" panel (792×272) replaces the page-shaped 7.5" choice.
Reflows the v0.1 screen layout, framebuffer memory budget, and spike 2
acceptance to validate the SSD1683-class controller path or fall back to
a ~300 LoC custom embedded-hal driver.
2026-05-14 12:27:33 +02:00

11 KiB
Raw Blame History

v0.1 MVP — Technical design

Scope: ships the product surface in 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 KeyEvents
wifi_task 0 8 KB Provisioning AP or station mode; expose status
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. 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 <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 ~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.