Compare commits
3 Commits
9bbcf6d4ed
...
17fd663c85
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17fd663c85 | ||
|
|
25665561cd | ||
|
|
784dcb7666 |
@@ -7,13 +7,24 @@ context.
|
||||
|
||||
## Current state
|
||||
|
||||
**Spike 4 — USB 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 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
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 4 — USB host keyboard, {BUILD_TAG}");
|
||||
log::info!("Plug the keyboard into the native USB port, then type.");
|
||||
log::info!("Typoena Spike 5 — partial 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
|
||||
}
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user