The per-entry metadata() call makes FatFS re-walk the directory by path every time — ~32ms/file on the card, 35s for a 1098-file tree. esp-idf's FAT VFS always fills d_type (DT_DIR/DT_REG from the FILINFO readdir already holds, never unknown) and Rust std maps file_type() onto it stat-free, so the walk is now one readdir pass per directory.
Typoena firmware
Rust crate targeting xtensa-esp32s3-espidf. See the project root
README.md and
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 owns the
buffer, caret, motions, and per-mode rendering; src/main.rs is
the hardware loop that drains keystrokes, redraws, and picks a refresh strategy;
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+Backspacedelete the previous word,Cmd+Backspacedeletes to the start of the line. - Normal — motions
h j k l,w b e,0$,ggG; editsx,dd, and thed/c(change) operators over motions and text objects —ciw,daw,di(,ci", … (bracket pairs are nesting-aware);i a A I o Oto enter insert; count prefixes like3j,2dd. - View — read-only reading:
j/kscroll,spacepages,gg/Gjump; 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, 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 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, flashed with just flash-sd —
is now a thin on-device harness over the real
firmware::persistence 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. - FatFS rename ≠ POSIX rename.
f_renamewon't overwrite an existing target (returnsFR_EXIST), so the atomic save unlinks the destination first.firmware::persistencepairs this with*.tmpboot-recovery (Storage::recover): if a*.tmpis 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.tmpname.
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 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 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
(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::Frameimplementsembedded-graphics'DrawTarget; a stroked circle straddling the master/slave seam (x = 396) renders round and continuous, andFONT_10X20text 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 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:
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 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):
. ~/export-esp.sh
Then from this directory, just build (the nominal product build) or, for fast
iteration without git, just build-light:
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:
gitCargo feature (--features git) — pulls thegit2crate and turns on the#[cfg(feature = "git")]publish path insrc/main.rs(publish()). Off by default; the full recipes pass it.LIBGIT2_SRCenv — the libgit2 component 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).
Instead a Mac prepares the card over a reader, and the device only ever takes
the open + fast-forward path. The justfile has three entry
points, each ejecting the card when done:
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 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.
Source: Espressif ESP32-S3-DevKitC-1 v1.0 user guide. The octal PSRAM consumes GPIO 26–37, so those are unavailable for peripherals.
Editor / rust-analyzer
The repo-level .zed/settings.json configures rust-analyzer for this
crate:
cargo.targetis pinned toxtensa-esp32s3-espidfwithallTargets = false, so RA doesn't try to also check the crate for the host target (which can't buildesp-idf-sys).binary.pathis pinned to the rustup-managed rust-analyzer (stabletoolchain), not Zed's bundled one. Reason: recent Zed builds ship a rust-analyzer that callscargo metadata --lockfile-path, which is still gated behind-Z unstable-optionsin cargo 1.95 and fails on both thestableandesptoolchains. 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:
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 bybindgeninsideesp-idf-sys.- The Xtensa GCC on
PATH— required byembuildduringcargo check.
Both are set by ~/export-esp.sh. The pragmatic workflow:
. ~/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.
