From 25566c067a7cd15236aafea0242d24258470162b Mon Sep 17 00:00:00 2001 From: Kurtis Dinelle Date: Thu, 25 Jun 2026 16:39:08 -0700 Subject: [PATCH] dev-qemu: Add minimal tasks for completing HID handshake --- platform/dev-qemu/Cargo.lock | 17 +++ platform/dev-qemu/Cargo.toml | 15 ++- platform/dev-qemu/src/board.rs | 21 +++- platform/dev-qemu/src/hid.rs | 191 +++++++++++++++++++++++++++++++++ platform/dev-qemu/src/main.rs | 6 ++ 5 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 platform/dev-qemu/src/hid.rs diff --git a/platform/dev-qemu/Cargo.lock b/platform/dev-qemu/Cargo.lock index 7a3aa39..9b9ae18 100644 --- a/platform/dev-qemu/Cargo.lock +++ b/platform/dev-qemu/Cargo.lock @@ -272,6 +272,10 @@ dependencies = [ "defmt-semihosting", "embassy-executor 0.10.0", "embassy-qemu-riscv", + "embassy-sync 0.8.0", + "embedded-mcu-hal 0.3.0", + "embedded-services", + "hid-service", "platform-common", "riscv-rt", "semihosting", @@ -713,6 +717,19 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hid-service" +version = "0.1.0" +source = "git+https://github.com/OpenDevicePartnership/embedded-services?branch=main#62d4ea9a87588c6096e1c2f149ac3263064cbde9" +dependencies = [ + "defmt 0.3.100", + "embassy-sync 0.8.0", + "embassy-time", + "embedded-hal 1.0.0", + "embedded-hal-async", + "embedded-services", +] + [[package]] name = "ident_case" version = "1.0.1" diff --git a/platform/dev-qemu/Cargo.toml b/platform/dev-qemu/Cargo.toml index a641ae2..00af45a 100644 --- a/platform/dev-qemu/Cargo.toml +++ b/platform/dev-qemu/Cargo.toml @@ -36,10 +36,11 @@ suspicious = "deny" style = "deny" # The `embassy_executor::main` macro expands to code referencing `riscv_rt`, -# but cargo-machete cannot see through the macro. Keep the direct dependency -# and tell machete to ignore it. +# and `embedded_services::define_static_buffer!` expands to code referencing +# `embassy_sync`, but cargo-machete cannot see through either macro. Keep the +# direct dependencies and tell machete to ignore them. [package.metadata.cargo-machete] -ignored = ["riscv-rt"] +ignored = ["riscv-rt", "embassy-sync"] [dependencies] riscv-rt = "0.17.0" @@ -53,12 +54,20 @@ embassy-executor = { version = "0.10.0", features = [ "defmt", ] } +embedded-services = { git = "https://github.com/OpenDevicePartnership/embedded-services", branch = "main", features = [ + "defmt", +] } uart-service = { git = "https://github.com/OpenDevicePartnership/embedded-services", branch = "main", features = [ "defmt", ] } +hid-service = { git = "https://github.com/OpenDevicePartnership/embedded-services", branch = "main", features = [ + "defmt", +] } platform-common = { path = "../platform-common", features = ["mock"] } +embedded-mcu-hal = { git = "https://github.com/OpenDevicePartnership/embedded-mcu" } static_cell = "2.1.0" +embassy-sync = "0.8.0" defmt = "0.3.6" defmt-semihosting = "0.3.0" semihosting = { version = "0.1", features = ["panic-handler"] } diff --git a/platform/dev-qemu/src/board.rs b/platform/dev-qemu/src/board.rs index a8fb254..a9a087f 100644 --- a/platform/dev-qemu/src/board.rs +++ b/platform/dev-qemu/src/board.rs @@ -1,19 +1,29 @@ +use embassy_qemu_riscv::gpio::{Level, Output}; +use embassy_qemu_riscv::i2c::target::{self, Async as I2cAsync, I2c}; use embassy_qemu_riscv::uart::{buffered, Async}; use embassy_qemu_riscv::{bind_interrupts, peripherals, uart}; use platform_common::board::BoardIo; use static_cell::StaticCell; +// 7-bit I2C address the EC will respond to +const I2C_ADDR: u8 = 0x2C; + bind_interrupts!(struct Irqs { UART0 => uart::buffered::InterruptHandler; + I2C_TARGET => target::InterruptHandler; }); /// Board IO for the dev-qemu platform. /// -/// This minimal development board provides a UART interface -/// for ODP service communication. +/// This minimal development board provides a UART interface for ODP service +/// communication plus an I2C target and GPIO line (for HIDI2C service). pub struct Board { /// UART for ODP service communication. pub uart: buffered::Uart<'static, Async>, + /// I2C target acting as the HID-over-I2C device endpoint. + pub i2c: I2c<'static, I2cAsync>, + /// Interrupt line the HID device drives to signal the host (active low). + pub gpio: Output<'static>, } impl BoardIo for Board { @@ -26,6 +36,11 @@ impl BoardIo for Board { let uart = buffered::Uart::new_async(p.UART0, Irqs, rx_buf, Default::default()).expect("Failed to initialize UART"); - Board { uart } + let i2c = I2c::new_async(p.I2C_TARGET, Irqs, I2C_ADDR); + + // Start high (since this is an active-low signal) + let gpio = Output::new(p.GPIO0, Level::High); + + Board { uart, i2c, gpio } } } diff --git a/platform/dev-qemu/src/hid.rs b/platform/dev-qemu/src/hid.rs new file mode 100644 index 0000000..fd96e92 --- /dev/null +++ b/platform/dev-qemu/src/hid.rs @@ -0,0 +1,191 @@ +//! Minimal HID-over-I2C device. +//! +//! Just enough to satisfy a host (e.g. Windows) performing the initial +//! HID-over-I2C handshake: serve the HID descriptor and report descriptor, +//! acknowledge `RESET` (asserting the interrupt so the host reads the all-zero +//! reset sentinel), and ack the remaining setup commands. +//! +//! This also demonstrates the basic setup of a HID-over-I2C device on the QEMU EC platform +//! for further development/testing. + +use core::borrow::BorrowMut; +use defmt::error; +use embassy_qemu_riscv::gpio::Output; +use embassy_qemu_riscv::i2c::target::{Async, I2c}; +use embedded_mcu_hal::i2c::target::Request as TargetRequest; +use embedded_services::hid::{self, Descriptor, DeviceId, RegisterFile, Request, Response}; +use embedded_services::{comms, define_static_buffer}; +use hid_service::i2c::{Command, Host, HostConfig, I2cSlaveAsync}; +use static_cell::StaticCell; + +// HID device ID used to route requests between the I2C host bridge and the device. +const HID_DEVICE_ID: DeviceId = DeviceId(0); + +// Vendor / product / version IDs reported in the HID descriptor. +const HID_VID: u16 = 0x045E; // MSFT +const HID_PID: u16 = 0x0002; +const HID_VERSION: u16 = 0x0100; + +// Minimal vendor-defined report descriptor: a single 1-byte input report. +// +// It carries no useful data; it only needs to be a well-formed HID report +// descriptor so the host can parse it and finish enumerating the device. +#[rustfmt::skip] +const REPORT_DESCRIPTOR: &[u8] = &[ + 0x06, 0x00, 0xFF, // Usage Page (Vendor Defined 0xFF00) + 0x09, 0x01, // Usage (0x01) + 0xA1, 0x01, // Collection (Application) + 0x09, 0x01, // Usage (0x01) + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x00, // Logical Maximum (255) + 0x75, 0x08, // Report Size (8) + 0x95, 0x01, // Report Count (1) + 0x81, 0x02, // Input (Data, Variable, Absolute) + 0xC0, // End Collection +]; + +// Max input report length advertised in the descriptor: 2-byte length prefix + 1 data byte. +const INPUT_MAX_LEN: usize = 3; + +// Build the HID descriptor from the device register layout. +fn descriptor(regs: &RegisterFile) -> Descriptor { + Descriptor { + w_hid_desc_length: hid::DESCRIPTOR_LEN as u16, + bcd_version: HID_VERSION, + w_report_desc_length: REPORT_DESCRIPTOR.len() as u16, + w_report_desc_register: regs.report_desc_reg, + w_input_register: regs.input_reg, + w_max_input_length: INPUT_MAX_LEN as u16, + w_output_register: regs.output_reg, + w_max_output_length: 0, + w_command_register: regs.command_reg, + w_data_register: regs.data_reg, + w_vendor_id: HID_VID, + w_product_id: HID_PID, + w_version_id: HID_VERSION, + } +} + +struct HidI2cSlave(I2c<'static, Async>); +impl I2cSlaveAsync for HidI2cSlave { + type Error = core::convert::Infallible; + + async fn listen(&mut self) -> Result { + loop { + match self.0.listen().await { + TargetRequest::Read(_) => return Ok(Command::Read), + TargetRequest::Write(_) => return Ok(Command::Write), + _ => continue, + } + } + } + + async fn respond_to_write(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> { + let _ = self.0.respond_to_write(buf).await; + Ok(()) + } + + async fn respond_to_read(&mut self, buf: &[u8]) -> Result<(), Self::Error> { + let _ = self.0.respond_to_read(buf).await; + Ok(()) + } +} + +#[embassy_executor::task] +pub async fn host_task(i2c: I2c<'static, Async>) { + define_static_buffer!(host_buf, u8, [0u8; 256]); + let buf = host_buf::get_mut().expect("HID host buffer must not already be borrowed"); + + static HOST: StaticCell> = StaticCell::new(); + let host = HOST.init(Host::new(HID_DEVICE_ID, HidI2cSlave(i2c), buf, HostConfig::default())); + + comms::register_endpoint(host, &host.tp) + .await + .expect("HID host endpoint already registered"); + + loop { + match host.process().await { + Ok(()) => {} + Err(hid_service::Error::Bus(_)) => error!("HID host I2C bus error"), + Err(hid_service::Error::Hid(e)) => error!("HID host error: {:?}", e), + Err(hid_service::Error::Buffer(e)) => error!("HID host buffer error: {:?}", e), + } + } +} + +#[embassy_executor::task] +pub async fn device_task(mut hid_int: Output<'static>) { + let regs = RegisterFile::default(); + + static DEVICE: StaticCell = StaticCell::new(); + let device = DEVICE.init(hid::Device::new(HID_DEVICE_ID, regs)); + hid::register_device(device) + .await + .expect("HID device already registered"); + + // Static response buffers: the encoded HID descriptor, the report descriptor, + // and an always-zero input report (doubles as the reset sentinel). + define_static_buffer!(hid_desc_buf, u8, [0u8; hid::DESCRIPTOR_LEN]); + define_static_buffer!(report_desc_buf, u8, [0u8; REPORT_DESCRIPTOR.len()]); + define_static_buffer!(input_buf, u8, [0u8; INPUT_MAX_LEN]); + + { + let mut owned = hid_desc_buf::get_mut() + .expect("HID descriptor buffer must not already be borrowed") + .borrow_mut() + .expect("HID descriptor buffer borrow"); + let buf: &mut [u8] = owned.borrow_mut(); + descriptor(®s) + .encode_into_slice(buf) + .expect("HID descriptor fits its buffer"); + } + { + let mut owned = report_desc_buf::get_mut() + .expect("report descriptor buffer must not already be borrowed") + .borrow_mut() + .expect("report descriptor buffer borrow"); + let buf: &mut [u8] = owned.borrow_mut(); + buf.copy_from_slice(REPORT_DESCRIPTOR); + } + + loop { + match device.wait_request().await { + Request::Descriptor => { + let _ = device + .send_response(Some(Response::Descriptor(hid_desc_buf::get()))) + .await; + } + Request::ReportDescriptor => { + let _ = device + .send_response(Some(Response::ReportDescriptor(report_desc_buf::get()))) + .await; + } + Request::InputReport => { + // Hand back the empty/reset sentinel and deassert the interrupt + let _ = device + .send_response(Some(Response::InputReport(input_buf::get()))) + .await; + hid_int.set_high(); + } + Request::Command(cmd) => match cmd { + hid::Command::Reset => { + // Ack the reset, then assert the interrupt so the host reads the reset sentinel + let _ = device.send_response(None).await; + hid_int.set_low(); + } + hid::Command::GetReport { .. } => { + let _ = device + .send_response(Some(Response::InputReport(input_buf::get()))) + .await; + } + // Other commands: nothing to do, just ack + _ => { + let _ = device.send_response(None).await; + } + }, + Request::OutputReport(..) => { + let _ = device.send_response(None).await; + } + } + } +} diff --git a/platform/dev-qemu/src/main.rs b/platform/dev-qemu/src/main.rs index b57d1ed..eb58914 100644 --- a/platform/dev-qemu/src/main.rs +++ b/platform/dev-qemu/src/main.rs @@ -2,6 +2,7 @@ #![no_std] mod board; +mod hid; use board::Board; use defmt::info; @@ -30,4 +31,9 @@ async fn main(spawner: Spawner) { let relay = platform_common::mock::init(spawner).await; spawner.spawn(uart_service(board.uart, relay).expect("Failed to spawn UART service task")); + + // Bring up a minimal HID-over-I2C device so a host (e.g. Windows) can + // complete its initial HID handshake against the EC + spawner.spawn(hid::host_task(board.i2c).expect("Failed to spawn HID host task")); + spawner.spawn(hid::device_task(board.gpio).expect("Failed to spawn HID device task")); }