8.9 KiB
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 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, gitoxide push; SD is deferred.
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:
cargo build --release
The first build is slow (the esp-idf C sources are checked out and built
under .embuild/). Subsequent builds are incremental.
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.
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.
