Compare commits

..

3 Commits

Author SHA1 Message Date
Julien Calixte
17fd663c85 docs(firmware): record Spike 5 partial refresh + typing 2026-07-05 00:00:16 +02:00
Julien Calixte
25665561cd feat(firmware): type on the e-paper panel with partial refresh
Spike 5 — first spike where keyboard input and panel output run
together. usb_kbd changes from a blocking run() into start(): it brings
the USB host stack up on background threads and pushes edge-detected,
US-layout-translated key-downs onto a queue drained via next_key().
main.rs owns the panel, maintains a wrapped/scrolling text buffer, and
partial-refreshes per keystroke batch with a periodic full refresh to
clear ghosting. Logs per-refresh latency.
2026-07-05 00:00:11 +02:00
Julien Calixte
784dcb7666 feat(firmware): add partial refresh to the EPD driver
display_frame_partial writes the new image to bank 0x24, runs GxEPD2's
partial waveform (_Update_Part: 0x3C/0x80, 0x21/0x00,0x10, 0x22/0xFF,
0x20), then syncs bank 0x26 so the next partial has a correct baseline.
Like GxEPD2 for this dual-controller panel, the update covers the full
panel with the partial waveform — no windowing, since the waveform time
dominates. Much faster than a full refresh and without the flashing.
2026-07-05 00:00:02 +02:00
4 changed files with 392 additions and 174 deletions

View File

@@ -7,13 +7,24 @@ context.
## Current state
**Spike 4USB host keyboard: verified 2026-07-04.** `main.rs` runs the USB
host bring-up: [`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 logged over UART. Verified with a
`19f5:3255` keyboard: keystrokes, modifiers, and rollover all decode correctly.
**Spike 5partial 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
@@ -23,8 +34,8 @@ 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`; kept compiled but unused while
Spike 4 owns `main.rs`). Verified on the bench rig over 4 MHz SPI:
(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
@@ -48,7 +59,7 @@ 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):
partial refresh, Wi-Fi/TLS, gitoxide push; SD is deferred.
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

View File

@@ -8,10 +8,9 @@
//! Display factory demo. See `docs/v0.1-mvp-technical.md` (Spike 2) and
//! ADR-003.
//!
//! **Spike 2a scope:** hardware reset, init, uniform full-screen fill, full
//! refresh — enough to prove wiring + SPI + both controllers + refresh.
//! Text (an `embedded-graphics` `DrawTarget` + the per-quadrant blit from
//! GxEPD2's `_writeFromImage`) is Spike 2b.
//! Capabilities: hardware reset, init, uniform fill, full-frame blit via an
//! `embedded-graphics` `DrawTarget` (`Frame`), full refresh (`display_frame`),
//! and partial refresh (`display_frame_partial`) — Spikes 2 and 5.
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
@@ -44,6 +43,7 @@ impl Frame {
Self { buf: vec![0xFF; FB_BYTES] }
}
#[allow(dead_code)] // symmetric with new_white; kept as part of the API
pub fn new_black() -> Self {
Self { buf: vec![0x00; FB_BYTES] }
}
@@ -271,6 +271,27 @@ impl<'d> Epd<'d> {
Ok(())
}
/// Port of GxEPD2 `_Update_Part` — the partial-update waveform. No full
/// flashing; only pixels that differ between the "previous" (`0x26`) and
/// "current" (`0x24`) banks transition. Much faster than a full refresh
/// but leaves faint ghosting that a periodic full refresh clears. Like
/// GxEPD2 for this dual-controller panel, the update covers the whole
/// panel (windowing isn't worthwhile — the waveform time dominates, not
/// the area).
fn update_part(&mut self) -> Result<(), EspError> {
self.set_ram_area(0, 0, WIDTH / 2, HEIGHT, 0x03, 0x80)?; // slave
self.set_ram_area(0, 0, WIDTH / 2, HEIGHT, 0x03, 0x00)?; // master
self.cmd(0x3C)?; // border waveform control
self.data(&[0x80])?; // VCOM
self.cmd(0x21)?; // display update control 1
self.data(&[0x00, 0x10])?; // RED normal, cascade
self.cmd(0x22)?; // display update control 2
self.data(&[0xFF])?; // partial update
self.cmd(0x20)?; // master activation
self.wait_while_busy(2000)?; // partial is well under the full ~2.2 s
Ok(())
}
/// Fill the whole panel with one value and full-refresh.
/// `0xFF` = white, `0x00` = black. Port of GxEPD2 `clearScreen`.
pub fn clear_screen(&mut self, value: u8) -> Result<(), EspError> {
@@ -318,4 +339,18 @@ impl<'d> Epd<'d> {
self.update_full()?;
Ok(())
}
/// Show a full 792×272 framebuffer with a *partial* refresh (fast, no
/// flashing). Requires the `0x26` (previous) bank to already hold the
/// on-screen image — true after any `display_frame`, `clear_screen`, or a
/// prior `display_frame_partial`. Writes the new image to `0x24`, runs the
/// partial waveform, then syncs `0x26` to the new image so the next
/// partial update has a correct baseline.
pub fn display_frame_partial(&mut self, fb: &[u8]) -> Result<(), EspError> {
assert_eq!(fb.len(), FB_BYTES, "framebuffer must be 99 x 272 bytes");
self.write_frame_bank(0x24, fb)?; // current = new
self.update_part()?; // transition previous (0x26) -> current (0x24)
self.write_frame_bank(0x26, fb)?; // previous = new, for the next partial
Ok(())
}
}

View File

@@ -1,23 +1,162 @@
// epd is proven (Spike 2) but unused by the Spike 4 harness; keep it compiled
// so it doesn't bit-rot while USB host bring-up is in progress.
#[allow(dead_code)]
mod epd;
mod usb_kbd;
use std::time::Instant;
use embedded_graphics::mono_font::ascii::FONT_10X20;
use embedded_graphics::mono_font::MonoTextStyle;
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{PrimitiveStyle, Rectangle};
use embedded_graphics::text::{Baseline, Text};
use esp_idf_svc::hal::delay::FreeRtos;
use esp_idf_svc::hal::gpio::{AnyIOPin, PinDriver, Pull};
use esp_idf_svc::hal::peripherals::Peripherals;
use esp_idf_svc::hal::spi::config::{Config, DriverConfig};
use esp_idf_svc::hal::spi::{Dma, SpiBusDriver, SpiDriver};
use esp_idf_svc::hal::units::FromValueType;
use epd::Epd;
/// Injected by build.rs so serial output identifies the exact build.
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
/// FONT_10X20 laid out on the 792×272 panel: 10 px wide, 20 px tall.
const CW: i32 = 10;
const CH: i32 = 20;
const COLS: usize = (epd::WIDTH / 10) as usize; // 79 characters per line
const ROWS: usize = (epd::HEIGHT / 20) as usize; // 13 lines
/// Clear accumulated partial-refresh ghosting with a full refresh this often.
const FULL_REFRESH_EVERY: u32 = 20;
fn main() -> anyhow::Result<()> {
// Required once before any esp-idf-svc call; some runtime patches
// only link if this symbol is referenced. See esp-idf-template#71.
esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
log::info!("Typoena Spike 4USB host keyboard, {BUILD_TAG}");
log::info!("Plug the keyboard into the native USB port, then type.");
log::info!("Typoena Spike 5partial refresh + keyboard, {BUILD_TAG}");
// Runs forever: installs the USB Host Library and logs descriptors of any
// device that attaches. Returns only on a fatal host-library error.
usb_kbd::run()?;
Ok(())
let peripherals = Peripherals::take()?;
let pins = peripherals.pins;
// GDEY0579T93 on S3-safe GPIOs (Spike 2 wiring):
// SCK 12 · DIN/MOSI 11 · CS 7 · DC 6 · RST 5 · BUSY 4
let spi = SpiDriver::new(
peripherals.spi2,
pins.gpio12,
pins.gpio11,
None::<AnyIOPin>,
&DriverConfig::new().dma(Dma::Auto(4096)),
)?;
let bus = SpiBusDriver::new(spi, &Config::new().baudrate(4.MHz().into()))?;
let cs = PinDriver::output(pins.gpio7)?;
let dc = PinDriver::output(pins.gpio6)?;
let rst = PinDriver::output(pins.gpio5)?;
let busy = PinDriver::input(pins.gpio4, Pull::Down)?;
let mut epd = Epd::new(bus, dc, rst, cs, busy);
log::info!("EPD reset + init…");
epd.reset()?;
epd.init()?;
epd.clear_screen(0xFF)?; // white baseline; establishes the previous bank
// Bring up the USB keyboard in the background; keys arrive via next_key().
usb_kbd::start()?;
// First render is full (establishes the on-screen baseline for partials).
let mut text = String::new();
epd.display_frame(render_frame(&text).bytes())?;
let mut updates: u32 = 0;
loop {
// Drain all queued keystrokes (type-ahead absorbed during a refresh),
// apply them, then do a single refresh for the batch.
let mut keys = 0;
while let Some(k) = usb_kbd::next_key() {
apply_key(&mut text, k);
keys += 1;
}
if keys == 0 {
FreeRtos::delay_ms(8);
continue;
}
let frame = render_frame(&text);
updates += 1;
let full = updates % FULL_REFRESH_EVERY == 0;
let t0 = Instant::now();
if full {
epd.display_frame(frame.bytes())?;
} else {
epd.display_frame_partial(frame.bytes())?;
}
let ms = t0.elapsed().as_millis();
log::info!(
"{} refresh #{updates}: {ms} ms ({keys} key(s), {} chars)",
if full { "FULL" } else { "partial" },
text.chars().count(),
);
}
}
/// Apply a key event to the text buffer.
fn apply_key(text: &mut String, key: usb_kbd::Key) {
match key {
usb_kbd::Key::Char(c) => text.push(c),
usb_kbd::Key::Enter => text.push('\n'),
usb_kbd::Key::Backspace => {
text.pop();
}
}
}
/// Render the text buffer into a frame: word-wrapped at the panel width,
/// scrolled to the last `ROWS` lines, with an underline caret at the end.
fn render_frame(text: &str) -> epd::Frame {
let mut frame = epd::Frame::new_white();
let style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On); // black ink
// Break into display lines on '\n' and at the column limit; tabs expand
// to 4 spaces.
let mut lines: Vec<String> = vec![String::new()];
for ch in text.chars() {
if ch == '\n' {
lines.push(String::new());
continue;
}
let (glyph, count) = if ch == '\t' { (' ', 4) } else { (ch, 1) };
for _ in 0..count {
if lines.last().unwrap().chars().count() >= COLS {
lines.push(String::new());
}
lines.last_mut().unwrap().push(glyph);
}
}
// Scroll: show only the last ROWS lines.
let start = lines.len().saturating_sub(ROWS);
let shown = &lines[start..];
for (row, line) in shown.iter().enumerate() {
Text::with_baseline(line, Point::new(0, row as i32 * CH), style, Baseline::Top)
.draw(&mut frame)
.unwrap();
}
// Underline caret at the end of the last visible line.
if let Some(last) = shown.last() {
let col = last.chars().count().min(COLS - 1) as i32;
let row = shown.len() as i32 - 1;
Rectangle::new(
Point::new(col * CW, row * CH + CH - 2),
Size::new(CW as u32, 2),
)
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.draw(&mut frame)
.unwrap();
}
frame
}

View File

@@ -1,27 +1,28 @@
//! Spike 4 — USB host: read keycodes from a USB HID boot keyboard.
//! USB host HID boot keyboard → key-event queue.
//!
//! Drives the ESP-IDF USB Host Library directly through the raw `esp-idf-sys`
//! bindings (the convenience HID class driver is a managed component that isn't
//! vendored in mainline, and a boot keyboard doesn't need it). On attach it:
//! 1. opens the device and dumps its device/config descriptors,
//! 2. claims the boot-keyboard interface,
//! 3. sends SET_PROTOCOL(boot) + SET_IDLE(0) control transfers,
//! 4. polls the interrupt-IN endpoint and decodes each 8-byte boot report
//! into modifiers + keycodes, logged over UART.
//! vendored in mainline, and a boot keyboard doesn't need it). `start()`
//! installs the host stack, spawns the daemon + client event pumps on their own
//! threads, and returns immediately; decoded key-down events are pushed onto a
//! queue the caller drains with `next_key()`. This keeps the USB pumps off the
//! main thread so the main thread can own the e-paper panel.
//!
//! The boot-keyboard parameters below were confirmed by Increment 1's
//! enumeration of the bench keyboard (VID:PID 19f5:3255): interface 0 is
//! class 03 / subclass 01 / protocol 01 with interrupt-IN endpoint 0x81,
//! wMaxPacketSize 8. A general driver would parse these from the config
//! descriptor; for the spike we target the confirmed layout.
//! On attach it opens the device, dumps its descriptors, claims the boot
//! keyboard interface (interface 0 / EP 0x81 / 8-byte reports, confirmed by
//! Spike 4's enumeration of VID:PID 19f5:3255), switches it to boot protocol,
//! and polls the interrupt-IN endpoint. Each report is edge-detected against
//! the previous one so a held key yields a single key-down, then translated
//! through a US QWERTY layout.
//!
//! Logging goes over the CP2102 UART bridge (console = UART0), which is
//! independent of the USB PHY, so installing the host library does not
//! disturb the serial monitor.
//! Logging goes over the CP2102 UART bridge (console = UART0), independent of
//! the USB PHY, so the host library and the serial monitor coexist.
use std::collections::VecDeque;
use std::ffi::c_void;
use std::ptr;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering};
use std::sync::{Mutex, OnceLock};
use esp_idf_svc::sys::esp;
use esp_idf_svc::sys::{
@@ -37,7 +38,15 @@ use esp_idf_svc::sys::{
usb_transfer_t, EspError, ESP_INTR_FLAG_LEVEL1,
};
/// Boot-keyboard parameters, confirmed by Increment 1's enumeration.
/// A decoded key-down event.
#[derive(Debug, Clone, Copy)]
pub enum Key {
Char(char),
Enter,
Backspace,
}
/// Boot-keyboard parameters, confirmed by Spike 4's enumeration.
const KBD_INTERFACE: u8 = 0;
const KBD_ALT_SETTING: u8 = 0;
const KBD_EP_IN: u8 = 0x81;
@@ -49,8 +58,7 @@ const SET_PROTOCOL_BOOT: [u8; 8] = [0x21, 0x0b, 0x00, 0x00, KBD_INTERFACE, 0x00,
const SET_IDLE_INFINITE: [u8; 8] = [0x21, 0x0a, 0x00, 0x00, KBD_INTERFACE, 0x00, 0x00, 0x00];
/// Address of a freshly-attached device, published by the client event
/// callback (a C function pointer, so it can't capture state) and consumed by
/// the main loop. 0 means "nothing pending".
/// callback and consumed by the client loop. 0 means "nothing pending".
static NEW_DEV_ADDR: AtomicU8 = AtomicU8::new(0);
/// Set when the open device is unplugged.
static DEV_GONE: AtomicBool = AtomicBool::new(false);
@@ -58,8 +66,103 @@ static DEV_GONE: AtomicBool = AtomicBool::new(false);
static CTRL_DONE: AtomicBool = AtomicBool::new(false);
static CTRL_STATUS: AtomicU32 = AtomicU32::new(0);
/// Queue of decoded key-down events, drained by the main thread. A plain
/// mutex-guarded queue rather than a channel because `mpsc::Sender` is not
/// `Sync` and so can't live in a `static`.
static KEY_QUEUE: OnceLock<Mutex<VecDeque<Key>>> = OnceLock::new();
/// Keycodes held in the previous report, for key-down edge detection. Only
/// ever touched from the single client thread's `report_cb`.
static PREV_KEYS: Mutex<[u8; 6]> = Mutex::new([0; 6]);
/// Pop the next decoded key-down event, if any.
pub fn next_key() -> Option<Key> {
KEY_QUEUE.get()?.lock().unwrap().pop_front()
}
/// Install the USB Host Library and spawn the daemon + client event pumps.
/// Returns once the stack is up; key events then arrive via `next_key()`.
pub fn start() -> Result<(), EspError> {
// Internal PHY (skip_phy_setup = false), root port powered on install,
// default full-speed peripheral (BIT0 — the S3 has a single USB-OTG).
let mut host_config: usb_host_config_t = unsafe { core::mem::zeroed() };
host_config.intr_flags = ESP_INTR_FLAG_LEVEL1 as i32;
host_config.peripheral_map = 1 << 0;
esp!(unsafe { usb_host_install(&host_config) })?;
log::info!("USB Host Library installed; waiting for a keyboard…");
let _ = KEY_QUEUE.set(Mutex::new(VecDeque::new()));
// The daemon pump services enumeration and root-port events. It must run
// continuously or an attach never completes.
std::thread::Builder::new()
.stack_size(4096)
.name("usb_host_daemon".into())
.spawn(|| loop {
let mut event_flags: u32 = 0;
unsafe { usb_host_lib_handle_events(u32::MAX, &mut event_flags) };
})
.expect("spawn usb host daemon thread");
// The client pump registers the client, handles attach/detach, and (via
// report_cb, called from within its handle_events) decodes key events.
std::thread::Builder::new()
.stack_size(8192)
.name("usb_client".into())
.spawn(client_loop)
.expect("spawn usb client thread");
Ok(())
}
/// Client event pump: register the client and service device attach/detach
/// forever. Runs on its own thread.
fn client_loop() {
let mut client_config: usb_host_client_config_t = unsafe { core::mem::zeroed() };
client_config.max_num_event_msg = 5;
client_config.__bindgen_anon_1.async_.client_event_callback = Some(client_event_cb);
client_config.__bindgen_anon_1.async_.callback_arg = ptr::null_mut();
let mut client: usb_host_client_handle_t = ptr::null_mut();
let err = unsafe { usb_host_client_register(&client_config, &mut client) };
if err != 0 {
log::error!("usb_host_client_register failed: {err}");
return;
}
let mut open_dev: usb_device_handle_t = ptr::null_mut();
let mut report_xfer: *mut usb_transfer_t = ptr::null_mut();
loop {
// Blocks until a client event; the callbacks (attach/detach, control
// completion, interrupt reports) all fire from within here.
unsafe { usb_host_client_handle_events(client, u32::MAX) };
let addr = NEW_DEV_ADDR.swap(0, Ordering::SeqCst);
if addr != 0 {
match setup_keyboard(client, addr) {
Ok((dev, xfer)) => {
open_dev = dev;
report_xfer = xfer;
}
Err(e) => log::error!("keyboard setup failed: {e:?}"),
}
}
if DEV_GONE.swap(false, Ordering::SeqCst) && !open_dev.is_null() {
log::info!("keyboard unplugged; releasing interface and closing");
// Order per the USB Host Library: free transfers, release
// interfaces, then close the device.
if !report_xfer.is_null() {
unsafe { usb_host_transfer_free(report_xfer) };
report_xfer = ptr::null_mut();
}
unsafe { usb_host_interface_release(client, open_dev, KBD_INTERFACE) };
unsafe { usb_host_device_close(client, open_dev) };
open_dev = ptr::null_mut();
*PREV_KEYS.lock().unwrap() = [0; 6];
}
}
}
/// Client event callback — runs inside `usb_host_client_handle_events`. Keep
/// it minimal: stash what happened and let the main loop do the FFI work.
/// it minimal: stash what happened and let the client loop do the FFI work.
unsafe extern "C" fn client_event_cb(msg: *const usb_host_client_event_msg_t, _arg: *mut c_void) {
let msg = unsafe { &*msg };
#[allow(non_upper_case_globals)]
@@ -83,16 +186,16 @@ unsafe extern "C" fn ctrl_cb(transfer: *mut usb_transfer_t) {
CTRL_DONE.store(true, Ordering::SeqCst);
}
/// Interrupt-IN completion callback: decode the boot report and resubmit to
/// keep polling. Runs inside `usb_host_client_handle_events`. On any
/// non-completed status (e.g. the device was unplugged and the transfer was
/// canceled) it stops resubmitting.
/// Interrupt-IN completion callback: decode the boot report into key-down
/// events and resubmit to keep polling. Runs inside the client loop's
/// `usb_host_client_handle_events`. On any non-completed status (e.g. the
/// device was unplugged and the transfer canceled) it stops resubmitting.
unsafe extern "C" fn report_cb(transfer: *mut usb_transfer_t) {
let t = unsafe { &mut *transfer };
if t.status == usb_transfer_status_t_USB_TRANSFER_STATUS_COMPLETED {
let n = (t.actual_num_bytes as usize).min(BOOT_REPORT_LEN);
let report = unsafe { core::slice::from_raw_parts(t.data_buffer, n) };
decode_boot_report(report);
handle_report(report);
let err = unsafe { usb_host_transfer_submit(transfer) };
if err != 0 {
log::error!("interrupt resubmit failed: {err}");
@@ -102,72 +205,70 @@ unsafe extern "C" fn report_cb(transfer: *mut usb_transfer_t) {
}
}
/// Install the USB Host Library, spawn the daemon event pump, register a
/// client, and service attach/detach forever. Does not return under normal
/// operation.
pub fn run() -> Result<(), EspError> {
// Internal PHY (skip_phy_setup = false), root port powered on install,
// default full-speed peripheral (BIT0 — the S3 has a single USB-OTG).
let mut host_config: usb_host_config_t = unsafe { core::mem::zeroed() };
host_config.intr_flags = ESP_INTR_FLAG_LEVEL1 as i32;
host_config.peripheral_map = 1 << 0;
esp!(unsafe { usb_host_install(&host_config) })?;
log::info!("USB Host Library installed; waiting for a device…");
/// Edge-detect key-downs in an 8-byte boot report and enqueue translated keys.
/// Layout: [modifiers, reserved, key1..key6]; 0 means "no key".
fn handle_report(report: &[u8]) {
if report.len() < 3 {
return;
}
let shift = report[0] & 0x22 != 0; // LShift (0x02) | RShift (0x20)
let current = &report[2..];
// The daemon pump services enumeration and root-port events. It must run
// continuously or an attach never completes. Own thread, blocking forever.
std::thread::Builder::new()
.stack_size(4096)
.name("usb_host_daemon".into())
.spawn(|| loop {
let mut event_flags: u32 = 0;
unsafe { usb_host_lib_handle_events(u32::MAX, &mut event_flags) };
})
.expect("spawn usb host daemon thread");
// Register the client that receives device attach/detach callbacks.
let mut client_config: usb_host_client_config_t = unsafe { core::mem::zeroed() };
client_config.max_num_event_msg = 5;
client_config.__bindgen_anon_1.async_.client_event_callback = Some(client_event_cb);
client_config.__bindgen_anon_1.async_.callback_arg = ptr::null_mut();
let mut client: usb_host_client_handle_t = ptr::null_mut();
esp!(unsafe { usb_host_client_register(&client_config, &mut client) })?;
let mut open_dev: usb_device_handle_t = ptr::null_mut();
let mut report_xfer: *mut usb_transfer_t = ptr::null_mut();
loop {
// Blocks until a client event; the callbacks (attach/detach, control
// completion, interrupt reports) all fire from within here.
unsafe { usb_host_client_handle_events(client, u32::MAX) };
let addr = NEW_DEV_ADDR.swap(0, Ordering::SeqCst);
if addr != 0 {
match setup_keyboard(client, addr) {
Ok((dev, xfer)) => {
open_dev = dev;
report_xfer = xfer;
}
Err(e) => log::error!("keyboard setup failed: {e:?}"),
}
let mut prev = PREV_KEYS.lock().unwrap();
for &k in current {
if k == 0 || prev.contains(&k) {
continue; // not pressed, or already held last report
}
if DEV_GONE.swap(false, Ordering::SeqCst) && !open_dev.is_null() {
log::info!("device unplugged; releasing interface and closing");
// Order per the USB Host Library: free transfers, release
// interfaces, then close the device.
if !report_xfer.is_null() {
unsafe { usb_host_transfer_free(report_xfer) };
report_xfer = ptr::null_mut();
if let Some(key) = translate(k, shift) {
log::info!("key: {key:?}");
if let Some(q) = KEY_QUEUE.get() {
q.lock().unwrap().push_back(key);
}
unsafe { usb_host_interface_release(client, open_dev, KBD_INTERFACE) };
unsafe { usb_host_device_close(client, open_dev) };
open_dev = ptr::null_mut();
}
}
let mut next = [0u8; 6];
for (slot, &k) in next.iter_mut().zip(current.iter()) {
*slot = k;
}
*prev = next;
}
/// Translate a HID keyboard usage ID to a key event using a US QWERTY layout.
fn translate(usage: u8, shift: bool) -> Option<Key> {
let key = match usage {
0x04..=0x1d => {
let base = b'a' + (usage - 0x04);
Key::Char(if shift { base.to_ascii_uppercase() } else { base } as char)
}
0x1e..=0x27 => {
const UNSHIFTED: [char; 10] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
const SHIFTED: [char; 10] = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'];
let i = (usage - 0x1e) as usize;
Key::Char(if shift { SHIFTED[i] } else { UNSHIFTED[i] })
}
0x28 => Key::Enter,
0x2a => Key::Backspace,
0x2b => Key::Char('\t'),
0x2c => Key::Char(' '),
0x2d => Key::Char(if shift { '_' } else { '-' }),
0x2e => Key::Char(if shift { '+' } else { '=' }),
0x2f => Key::Char(if shift { '{' } else { '[' }),
0x30 => Key::Char(if shift { '}' } else { ']' }),
0x31 => Key::Char(if shift { '|' } else { '\\' }),
0x33 => Key::Char(if shift { ':' } else { ';' }),
0x34 => Key::Char(if shift { '"' } else { '\'' }),
0x35 => Key::Char(if shift { '~' } else { '`' }),
0x36 => Key::Char(if shift { '<' } else { ',' }),
0x37 => Key::Char(if shift { '>' } else { '.' }),
0x38 => Key::Char(if shift { '?' } else { '/' }),
_ => return None,
};
Some(key)
}
/// Open a newly-attached device, dump its descriptors, claim the keyboard
/// interface, put it in boot protocol, and start polling for reports.
/// Returns the device handle and the in-flight report transfer.
fn setup_keyboard(
client: usb_host_client_handle_t,
addr: u8,
@@ -184,7 +285,7 @@ fn setup_keyboard(
control_request(client, dev, &SET_IDLE_INFINITE, "SET_IDLE(0)")?;
let xfer = start_report_polling(dev)?;
log::info!("polling EP {KBD_EP_IN:#04x} — type on the keyboard");
log::info!("polling EP {KBD_EP_IN:#04x} — keyboard ready");
Ok((dev, xfer))
}
@@ -193,7 +294,7 @@ fn open_and_dump(
client: usb_host_client_handle_t,
addr: u8,
) -> Result<usb_device_handle_t, EspError> {
log::info!("device attached at address {addr}; opening");
log::info!("keyboard attached at address {addr}; opening");
let mut dev: usb_device_handle_t = ptr::null_mut();
esp!(unsafe { usb_host_device_open(client, addr, &mut dev) })?;
@@ -276,71 +377,3 @@ fn start_report_polling(dev: usb_device_handle_t) -> Result<*mut usb_transfer_t,
esp!(unsafe { usb_host_transfer_submit(xfer) })?;
Ok(xfer)
}
/// Decode an 8-byte HID boot keyboard report into modifiers + keycodes and log
/// it. Layout: [modifiers, reserved, key1..key6]; 0 means "no key".
fn decode_boot_report(report: &[u8]) {
if report.len() < 3 {
return;
}
let modifiers = report[0];
const MOD_NAMES: [(u8, &str); 8] = [
(0x01, "LCtrl"),
(0x02, "LShift"),
(0x04, "LAlt"),
(0x08, "LGui"),
(0x10, "RCtrl"),
(0x20, "RShift"),
(0x40, "RAlt"),
(0x80, "RGui"),
];
let mods: Vec<&str> = MOD_NAMES
.iter()
.filter(|(bit, _)| modifiers & bit != 0)
.map(|(_, name)| *name)
.collect();
let keys: Vec<String> = report[2..]
.iter()
.filter(|&&k| k != 0)
.map(|&k| keycode_name(k))
.collect();
if mods.is_empty() && keys.is_empty() {
log::info!("report: (all keys released)");
} else {
log::info!("report: mods=[{}] keys=[{}]", mods.join("+"), keys.join(" "));
}
}
/// Map a HID keyboard usage ID to a readable label. Covers the common range;
/// anything else falls back to hex.
fn keycode_name(k: u8) -> String {
match k {
0x04..=0x1d => ((b'a' + (k - 0x04)) as char).to_string(),
0x1e..=0x26 => ((b'1' + (k - 0x1e)) as char).to_string(), // 1-9
0x27 => "0".into(),
0x28 => "Enter".into(),
0x29 => "Esc".into(),
0x2a => "Backspace".into(),
0x2b => "Tab".into(),
0x2c => "Space".into(),
0x2d => "-".into(),
0x2e => "=".into(),
0x2f => "[".into(),
0x30 => "]".into(),
0x31 => "\\".into(),
0x33 => ";".into(),
0x34 => "'".into(),
0x36 => ",".into(),
0x37 => ".".into(),
0x38 => "/".into(),
0x39 => "CapsLock".into(),
0x3a..=0x45 => format!("F{}", k - 0x3a + 1), // F1-F12
0x4f => "Right".into(),
0x50 => "Left".into(),
0x51 => "Down".into(),
0x52 => "Up".into(),
_ => format!("0x{k:02x}"),
}
}