Files
typewriter/firmware
Julien Calixte 8b71e0d9ec feat(firmware): read keycodes from a USB HID boot keyboard
Spike 4. Drive the ESP-IDF USB Host Library directly through the raw
esp-idf-sys bindings (the convenience HID class driver is a managed
component not vendored in mainline, and a boot keyboard doesn't need
it). On attach, src/usb_kbd.rs enumerates the device and dumps its
descriptors, claims the boot-keyboard interface, sends SET_PROTOCOL(boot)
and SET_IDLE(0), then polls the interrupt-IN endpoint and decodes each
8-byte report into modifiers + keycodes logged over UART.

main.rs becomes the Spike 4 harness; the epd module is kept compiled
(allow(dead_code)) so it doesn't bit-rot. Verified on hardware with a
19f5:3255 keyboard: letters, digits, modifiers, and rollover all decode.
2026-07-04 19:28:53 +02:00
..

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

Spike 2 — EPD: verified 2026-07-04. main.rs drives the GDEY0579T93 e-paper panel 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::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 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: USB host (keyboard), partial refresh, 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.

ESP32-S3-DevKitC-1 v1.0 pinout

Source: Espressif ESP32-S3-DevKitC-1 v1.0 user guide. The octal PSRAM consumes GPIO 2637, so those are unavailable for peripherals.

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:

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:

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