# Typoena firmware

Rust crate targeting `xtensa-esp32s3-espidf`. See the project root
[`README.md`](../README.md) and
[`docs/v0.1-mvp-technical.md`](../docs/v0.1-mvp-technical.md) for the wider
context.

## Current state

**Modal editor (vim modes) — modes verified 2026-07-05.** The firmware is now a
small vim-style modal text editor. [`src/editor.rs`](src/editor.rs) owns the
buffer, caret, motions, and per-mode rendering; [`src/main.rs`](src/main.rs) is
the hardware loop that drains keystrokes, redraws, and picks a refresh strategy;
[`src/usb_kbd.rs`](src/usb_kbd.rs) decodes editing chords and a dual-role Caps
key. The buffer is pure ASCII, so a byte offset doubles as the caret's character
index (Tab expands to spaces on insert).

Modes (shown live in a small status strip below the text):

- **Insert** — the boot mode; keys type at the caret. `Ctrl+W` /
  `Ctrl+Backspace` delete the previous word, `Cmd+Backspace` deletes to the
  start of the line.
- **Normal** — motions `h j k l`, `w b e`, `0` `$`, `gg` `G`; edits `x`, `dd`,
  and the `d` / `c` (change) operators over motions and text objects — `ciw`,
  `daw`, `di(`, `ci"`, … (bracket pairs are nesting-aware); `i a A I o O` to
  enter insert; count prefixes like `3j`, `2dd`.
- **View** — read-only reading: `j` / `k` scroll, `space` pages, `gg` / `G`
  jump; edits are locked out.

**Caps Lock is dual-role**: tapped it is `Esc` (→ Normal); held it is `Ctrl`.
So Caps no longer types capitals — use Shift.

Rendering reuses the partial refresh from Spike 5: additive Insert typing stays
on the fast windowed path with a ~750 ms debounced caret, while caret moves,
deletes, mode switches, and View scrolling take a clean full-area partial
(~630 ms). Count prefixes collapse repeated motion into a single refresh, which
matters at this latency.

Known rough edges (deferred): no backspace auto-repeat (the keyboard is on
`SET_IDLE(0)` and only key-downs are tracked), non-sticky column on `j` / `k`,
the `$` / end-of-line block caret sits one cell past the last char, `iw` / `aw`
are whitespace-delimited (like vim's `iW` / `aW`), and `cw` isn't special-cased
to `ce`.

**Spike 6 — Wi-Fi + TLS: verified 2026-07-05.** A separate binary —
[`src/bin/wifi_tls.rs`](src/bin/wifi_tls.rs), flashed with `just flash-wifi` —
kept apart from the editor firmware. It brings up the station, syncs the clock
over SNTP (mbedtls validates the server cert against wall time, so the 1970 RTC
has to be corrected first), then does an HTTPS GET to `https://api.github.com/`
with cert-chain validation against the esp-idf certificate bundle
(`esp_crt_bundle_attach`), and logs status, a body preview, and free heap around
the handshake (TLS heap pressure is a watched risk). A validated GET is the gate
for Spike 7 (gitoxide push over HTTPS + PAT).

Bench result (WPA2-PSK AP, 2.4 GHz): associate ~3 s → DHCP → SNTP first sync →
`esp-x509-crt-bundle: Certificate validated` → `HTTPS GET … → 200`, reading real
GitHub JSON. **TLS handshake cost ≈ 35 KB heap** (265 → 229 KB, recovered
after), clean and repeatable across reboots. Note: PSRAM is **not** enabled yet
(only ~339 KB internal heap) — TLS fits, but Spike 7's gitoxide working set will
need `CONFIG_SPIRAM` turned on first.

Credentials are build-time: copy [`.env.example`](.env.example) to `.env`, set
`TW_WIFI_SSID` / `TW_WIFI_PASS`, and `just` loads them (dotenv) so `build.rs`
bakes them in. `.env` is gitignored; the light editor build (`just flash-light`)
needs none of it. `sdkconfig.defaults` gains the full certificate bundle and a bigger main
task stack for the mbedtls handshake — a one-time esp-idf reconfigure on the
next build.

**Spike 3 — SD card (FAT) on dedicated SPI3: verified 2026-07-11.** A separate
binary — [`src/bin/sd_fat.rs`](src/bin/sd_fat.rs), flashed with `just flash-sd` —
is now a thin on-device harness over the real
[`firmware::persistence`](src/persistence.rs) module: it mounts the card, reports
FAT usage, and round-trips an atomic save/load (write `*.tmp` → fsync → unlink →
rename → read-back). Per ADR-012 the SD runs on its **own SPI3 host** —
**SCK 14 · MOSI 15 · MISO 13 · SD CS 10** — leaving the EPD alone on SPI2.
Verified on the dedicated SPI3 bus 2026-07-11 (same mount + round-trip result as
the initial shared-SPI2 bring-up).

Bench result (genuine 32 GB SDHC card): mounts at 10 MHz, `29806 MiB total`,
atomic round-trip byte-identical. Two findings baked into the code:

- **Card compatibility.** A 133 GB SDXC card failed init at `CMD59` (SPI-mode
  CRC); a genuine ≤32 GB card works. We keep CRC required and reject bad cards
  with a swap-the-card message rather than run over an unchecked bus. See the
  [Spike 3 postmortem](../docs/postmortems/2026-07-05-spike3-sd-cmd59.md).
- **FatFS rename ≠ POSIX rename.** `f_rename` won't overwrite an existing
  target (returns `FR_EXIST`), so the atomic save unlinks the destination first.
  `firmware::persistence` pairs this with `*.tmp` boot-recovery
  (`Storage::recover`): if a `*.tmp` is found *alongside* the target the crash
  may have been mid-write, so it keeps the committed file and discards the tmp;
  it only promotes the tmp when the target was already unlinked. Long filenames
  (`CONFIG_FATFS_LFN_HEAP`) are required for the two-dot `*.md.tmp` name.

**Arbitration resolved (ADR-012):** the EPD driver holds an exclusive SPI2 lock
for its whole lifetime, and persistence runs on its own thread, so a shared bus
would need an EPD rewrite plus a cross-thread mutex on the save path. Instead the
SD gets its own SPI3 — the EPD stays untouched, no arbitration. Remaining before
persistence lands in `main.rs`: wire the atomic save (unlink-then-rename +
`*.tmp` boot-recovery) into a `persistence` module.

**Spike 5 — partial refresh + typing: verified 2026-07-04.** `main.rs` wires
the keyboard to the panel: [`src/usb_kbd.rs`](src/usb_kbd.rs) feeds decoded
key-downs (US layout, edge-detected) into a queue, and the main loop keeps a
wrapped, scrolling text buffer that it draws with a **partial refresh**
(`Epd::display_frame_partial`) per keystroke batch, plus a periodic full
refresh to clear ghosting. First spike where input and output run together.
Measured on the bench at 4 MHz SPI: partial refresh ~630 ms, full ~1870 ms —
the partial waveform (~490 ms, all 272 rows) dominates. Follow-up: windowed-Y
partial refresh (drive only the edited line's rows) to cut per-keystroke
latency.

**Spike 4 — USB host keyboard: verified 2026-07-04.**
[`src/usb_kbd.rs`](src/usb_kbd.rs) drives the ESP-IDF USB Host Library directly
through the raw `esp-idf-sys` bindings (no managed HID class driver), enumerates
an attached keyboard, claims the boot-keyboard interface, switches it to boot
protocol, and polls the interrupt-IN endpoint — decoding each 8-byte report into
modifiers + keycodes. Verified with a `19f5:3255` keyboard: keystrokes,
modifiers, and rollover all decode correctly.

Hardware: flash + serial over the CP2102 "UART" port (console = UART0,
independent of the USB PHY), keyboard on the native "USB" port. The keyboard
enumerated **bus-powered** — no external VBUS injection needed on this
DevKitC-1 v1.0 (keep a 5 V power cable only as a brownout fallback for
higher-power/RGB devices).

**Spike 2 — EPD: verified 2026-07-04.** The GDEY0579T93 e-paper panel is
driven through the thin dual-SSD1683 driver in [`src/epd.rs`](src/epd.rs)
(ported from GxEPD2's `GxEPD2_579_GDEY0579T93`). Verified on the bench rig over
4 MHz SPI:

- **2a — uniform fill:** clean full-panel white ↔ black refreshes, proving
  the wiring, both cascaded controllers, RAM addressing, and the full
  refresh waveform.
- **2b — graphics/text:** `epd::Frame` implements `embedded-graphics`'
  `DrawTarget`; a stroked circle straddling the master/slave seam (x = 396)
  renders round and continuous, and `FONT_10X20` text is legible — proving
  the split-and-mirror full-frame blit (`Epd::display_frame`).

Wiring: SCK 12 · DIN/MOSI 11 · CS 7 · DC 6 · RST 5 · BUSY 4, via the
DESPI-C579 breakout.

Every build is stamped by [`build.rs`](build.rs) with UTC time and
`git describe --always --dirty`; the tag is logged on serial at boot and
drawn on the panel, so the running build is always identifiable during
diagnosis.

Bring-up note: the initial symptom was per-pixel noise on the panel — a
half-seated CS jumper, not firmware. If the panel shows speckle/banding,
reseat the jumpers (CS first) before debugging code.

Next up per
[`docs/v0.1-mvp-technical.md`](../docs/v0.1-mvp-technical.md#hardware-bring-up-order):
Wi-Fi/TLS (Spike 6, implemented above), then git push (Spike 7), then SD
(Spike 3) — all verified.

**Spike 1 — Blink: verified 2026-07-04.** GPIO 2 + on-board WS2812 toggled
at 1 Hz with `blink N` on USB-serial, proving toolchain, esp-idf link, and
GPIO on real silicon. The blink code was replaced by Spike 2 in `main.rs`
(see git history: `e040a8d`).

## Quick commands

A [`justfile`](https://github.com/casey/just) wraps the common commands and
sources the espup env itself — run `just` in this directory for the list
(`build`, `flash`, `monitor`, `info`, `ports`).

## Build

Once per shell session, source the espup env (sets `LIBCLANG_PATH` and adds
the Xtensa GCC to `PATH`):

```sh
. ~/export-esp.sh
```

Then from this directory, `just build` (the nominal product build) or, for fast
iteration without git, `just build-light`:

```sh
just build         # full: firmware + git publishing (libgit2 + git2)
just build-light   # light: editor only, no git — much faster
```

(A bare `cargo build --release` with no env is equivalent to `build-light` — the
`git` feature is off by default.)

The first build is slow (the esp-idf C sources are checked out and built
under `.embuild/`; the full build also compiles libgit2 + mbedTLS). Subsequent
builds are incremental.

### Build modes — git (default) vs light

Publishing (`:sync` → git push) is expensive to build: it drags in libgit2 +
mbedTLS (compiled as an esp-idf component) and the `git2` crate. It sits behind
a switch. The nominal build turns it on (it's the product); a **light** build
leaves it off — ideal for iterating on the editor, EPD, USB, or SD without
paying for libgit2:

| Build | Command | libgit2 component | `git2` crate | `:sync` |
| ----- | ------- | ----------------- | ------------ | ------- |
| **Full / git** (default) | `just build` / `just flash` | compiled | linked | save → push |
| **Light** | `just build-light` / `just flash-light` | not compiled (empty no-op) | not linked | saves locally, skips push |

Two independent switches make this work, and the justfile flips them together:

1. **`git` Cargo feature** (`--features git`) — pulls the `git2` crate and
   turns on the `#[cfg(feature = "git")]` publish path in
   [`src/main.rs`](src/main.rs) (`publish()`). Off by default; the full recipes
   pass it.
2. **`LIBGIT2_SRC` env** — the [libgit2 component](components/libgit2/CMakeLists.txt)
   only compiles its sources when this points at the vendored tree; unset, it
   registers an *empty* component. Only the full recipes set it.

Because git code in the firmware binary is only ever compiled under
`--features git`, `just build-light` can never drag libgit2 in. (Git isn't wired
into `main.rs` yet, so the full `just build` currently just builds slower and
behaves like the light build — the seam is in place ahead of the integration.)

## Flash (when hardware is on the bench)

`cargo run --release` triggers `espflash flash --monitor` via the runner
configured in `.cargo/config.toml`. With the ESP32-S3-DevKitC-1 connected
over USB you should see:

```
[…] blink 0
[…] blink 1
[…] blink 2
…
```

at 1 Hz on the serial monitor, and — if an LED is wired from GPIO 2 → 330 Ω
→ GND — the LED blinks in lockstep.

## Provisioning an SD card

Typoena reads its config and its notes repo from the SD card — it never
cold-clones the ~566 MB repo over Wi-Fi + mbedTLS (the
[git-sync sizing decision](../docs/notes/git-sync-images-and-repo-size.md)).
Instead a Mac prepares the card over a reader, and the device only ever takes
the `open` + fast-forward path. The [`justfile`](justfile) has three entry
points, each ejecting the card when done:

```sh
just init ~/code/notes    # full prep of a fresh card: notes repo + config
just load ~/code/notes    # (re)copy just the notes repo → /sd/repo
just provision            # (re)write just the config (rotate PAT, switch Wi-Fi)
```

`init` is the once-per-card command; `load` and `provision` each refresh one
half without touching the other. Add a `/Volumes/<name>` as the last argument if
more than one removable card is mounted — auto-detect refuses on ambiguity,
since a wrong guess would let `rsync --delete` wipe the wrong disk's `repo/`.

### Config with little to type

`typoena.conf` (Wi-Fi + PAT + git identity) needs **no `.env`**. Each value runs
a ladder — `.env` if present, else derived from tools already on the machine,
else an interactive prompt with the derived value as the default:

| Value | Derived from |
| --- | --- |
| `TW_REMOTE_URL` | the source repo's `origin` (or the card's existing clone) |
| `TW_AUTHOR_NAME` / `TW_AUTHOR_EMAIL` | `git config user.name` / `user.email` |
| `TW_GH_USER` | `gh api user` |
| `TW_WIFI_SSID` | the Mac's active Wi-Fi network |
| `TW_WIFI_PASS` | the System keychain for that SSID (else prompt) |
| `TW_PAT` | **never derived** — always typed by hand |

So a first run is usually: `just init ~/code/notes`, press Enter through the
auto-filled defaults, approve the macOS Keychain dialog for the Wi-Fi password
(or type it), and paste a fine-grained PAT once. Reading a saved Wi-Fi password
triggers a macOS authorization dialog (login password / Touch ID → Allow) —
that's macOS guarding a System-keychain secret, not something the recipe can
suppress. Keeping [`.env`](.env.example) populated stays a valid override and
skips all prompts.

### Secrets on the card

FAT has no file permissions, so **physical custody of the card is the only
control** over the plaintext `TW_PAT`. Scope it to a fine-grained token with
`contents:write` on just the notes repo, so a lost card is a one-token revoke.
The PAT is never derived from `gh auth token` (a broad token on removable media
would defeat the point) and never echoed — the recipes report each value only as
`set` / `MISSING`.

## Pin choice

GPIO 2 is a safe general-purpose pin on the ESP32-S3-DevKitC-1: it's not
tied to a strapping function at boot and not muxed to the USB or PSRAM
peripherals. The blink loop also drives the on-board addressable LED —
WS2812 on GPIO 48 (GPIO 38 on DevKitC-1 v1.1 boards) — via the RMT
peripheral, so both a plain GPIO and the RMT path are exercised.

## Board pinout

The bench board follows the **ESP32-S3-DevKitC-1 v1.0** pinout — an
ESP32-S3-WROOM-1 **N16R8** module (16 MB flash, 8 MB octal PSRAM). The v1.0
revision wires the on-board WS2812 RGB LED to **GPIO 48**; v1.1 moved it to
GPIO 38, so match assignments against this diagram, not the v1.1 one.

![ESP32-S3-DevKitC-1 v1.0 pinout](docs/esp32-s3-devkitc-1-v1.0-pinout.jpg)

Source: [Espressif ESP32-S3-DevKitC-1 v1.0 user guide][devkitc-1-v1.0]. The
octal PSRAM consumes **GPIO 26–37**, so those are unavailable for peripherals.

[devkitc-1-v1.0]: https://docs.espressif.com/projects/esp-dev-kits/en/latest/esp32s3/esp32-s3-devkitc-1/user_guide_v1.0.html

## Editor / rust-analyzer

The repo-level `.zed/settings.json` configures `rust-analyzer` for this
crate:

- `cargo.target` is pinned to `xtensa-esp32s3-espidf` with
  `allTargets = false`, so RA doesn't try to also check the crate for the
  host target (which can't build `esp-idf-sys`).
- `binary.path` is pinned to the **rustup-managed** rust-analyzer
  (`stable` toolchain), not Zed's bundled one. Reason: recent Zed builds
  ship a rust-analyzer that calls `cargo metadata --lockfile-path`, which
  is still gated behind `-Z unstable-options` in cargo 1.95 and fails on
  both the `stable` and `esp` toolchains. The rustup-managed RA is
  version-locked to the cargo it ships with and avoids the flag.

If a contributor on a different machine has issues, regenerate the path:

```sh
rustup component add rust-analyzer --toolchain stable
rustup which rust-analyzer --toolchain stable
# put the printed path into .zed/settings.json under lsp.rust-analyzer.binary.path
```

Two things rust-analyzer still needs from the **environment Zed was
launched in**:

- `LIBCLANG_PATH` — required by `bindgen` inside `esp-idf-sys`.
- The Xtensa GCC on `PATH` — required by `embuild` during `cargo check`.

Both are set by `~/export-esp.sh`. The pragmatic workflow:

```sh
. ~/export-esp.sh
zed /Users/julien/jclab/typewriter   # or: open from this shell
```

If Zed is launched from Finder/Dock instead, rust-analyzer will report
`bindgen` errors on the first `esp-idf-sys` check. Close Zed, source the
env in a terminal, and relaunch from there.

## Toolchain pins

`rust-toolchain.toml` pins the channel to `esp` (installed by `espup
install`). Cargo.toml currently includes git `[patch.crates-io]` overrides
for `esp-idf-sys` / `esp-idf-hal` / `esp-idf-svc` (template default). These
follow master and may need pinning to released versions if a master commit
breaks the build.
