Files
typewriter/docs/v0.1-mvp-technical.md
Julien Calixte 9c9ad63793 docs: record Spike 7 outcome, gix HTTPS-push kill-switch fired
Postmortem + ADR-004 outcome note + risk-table update: gitoxide has no
HTTP(S) push, so v0.1 falls back to libgit2; the on-device libgit2 <->
mbedtls cross-compile is the next gate.
2026-07-05 19:38:48 +02:00

17 KiB
Raw Blame History

v0.1 MVP — Technical design

Scope: ships the product surface in v0.1-mvp-product.md. No more, no less.

Decisions referenced inline point at adr.md. Tradeoff weights and the critical-performance budget live in qfd.md. Project overview: ../README.md. Release sequence: roadmap.md.

Architecture

Single Rust binary on esp-idf-rs (std) — ADR-001. Two cores, several threads, one shared editor state behind a mutex (ADR-006). 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 On-demand station: off by default, brought up by Ctrl-G
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. 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 SPI bus (shared: EPD + SD on different CS)
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.

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); spike 4 is the gate for ADR-009 (USB host).

  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).

Rendering spikes (post-bring-up)

The display/UX spikes (814) — 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 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-graphicsADR-002. Owns the top-ranked engineering functions (latency, refresh-region area) in qfd.md §3.

  • 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.

  • 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 — 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.
  • 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

gitoxide choice and kill-switch: ADR-004. Auth model: ADR-005. PSRAM heap during push is a top-3 watched metric — see qfd.md §6.

  • 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 (the gct shell function is the reference):
    • gix::open the existing working copy at /sd/repo
    • stage everything under the working copy (git add . equivalent)
    • short-circuit return if nothing is staged — status: "nothing to publish"
    • commit with author from config, message "<ISO-8601 timestamp>" (no wip prefix; the timestamp is the message)
    • push HEAD to origin/<current branch>
    • 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 loaded into the Authorization header per request; never logged.
  • The whole sequence is atomic from the user's view — see CONTEXT.md → Publish.

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:

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: ~300500 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 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

Rationale and rejected alternatives: ADR-006.

  • 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".

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.
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.