From 68d69e4982eb09e185eab49321434a06d7b60543 Mon Sep 17 00:00:00 2001 From: Jerry Xie Date: Thu, 27 Aug 2026 14:34:16 -0500 Subject: [PATCH] test(hidi2c): add unit tests for target service Add host-side unit tests for the HID-I2C target service. Coverage includes command-header parsing, SET_REPORT and GET_REPORT handling, the reset/interrupt handshake, and TimeoutBus timeout and recovery behavior, plus HID descriptor construction and oversize-rejection checks. Introduce a shared test_support module with a generic recording HidDevice mock so the descriptor and service tests no longer each define their own near-identical mock. Annotate the command byte arrays with HID-over-I2C wire-format comments. Add dev-dependencies (tokio, embassy-time, critical-section) needed to run the host async tests. Assisted-by: GitHub Copilot:claude-opus-4.8 --- Cargo.lock | 2 + hidi2c-target-service/Cargo.toml | 5 + .../src/device_descriptor.rs | 125 +++++ hidi2c-target-service/src/lib.rs | 3 + hidi2c-target-service/src/service.rs | 472 +++++++++++++++++- hidi2c-target-service/src/test_support.rs | 155 ++++++ 6 files changed, 754 insertions(+), 8 deletions(-) create mode 100644 hidi2c-target-service/src/test_support.rs diff --git a/Cargo.lock b/Cargo.lock index dd56ca124..68574e5e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1080,6 +1080,7 @@ dependencies = [ name = "hidi2c-target-service" version = "0.1.0" dependencies = [ + "critical-section", "defmt 0.3.100", "embassy-futures", "embassy-sync", @@ -1091,6 +1092,7 @@ dependencies = [ "log", "num_enum", "odp-service-common", + "tokio", "typenum", "zerocopy", ] diff --git a/hidi2c-target-service/Cargo.toml b/hidi2c-target-service/Cargo.toml index bbb07c1ac..4450293dd 100644 --- a/hidi2c-target-service/Cargo.toml +++ b/hidi2c-target-service/Cargo.toml @@ -25,6 +25,11 @@ odp-service-common.workspace = true typenum.workspace = true zerocopy = { workspace = true, features = ["derive"] } +[dev-dependencies] +critical-section = { workspace = true, features = ["std"] } +embassy-time = { workspace = true, features = ["std", "generic-queue-8"] } +tokio = { workspace = true, features = ["rt", "macros", "time"] } + [features] defmt = ["dep:defmt", "embedded-mcu-hal/defmt", "embedded-services/defmt"] log = ["dep:log", "embedded-services/log"] diff --git a/hidi2c-target-service/src/device_descriptor.rs b/hidi2c-target-service/src/device_descriptor.rs index 8f0fe6a9a..f29ff6e44 100644 --- a/hidi2c-target-service/src/device_descriptor.rs +++ b/hidi2c-target-service/src/device_descriptor.rs @@ -161,3 +161,128 @@ impl DeviceDescriptor { }) } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::test_support::{descriptor_device, hardware_version_info}; + + const IMPLICIT_DESCRIPTOR: &[u8] = &[ + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x02, // Usage (Mouse) + 0xa1, 0x01, // Collection (Application) + 0x75, 0x08, // Report Size (8) + 0x95, 0x01, // Report Count (1) + 0x81, 0x02, // Input + 0x91, 0x02, // Output + 0xb1, 0x02, // Feature + 0xc0, // End Collection + ]; + + const EXPLICIT_DESCRIPTOR: &[u8] = &[ + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x02, // Usage (Mouse) + 0xa1, 0x01, // Collection (Application) + 0x85, 0x01, // Report ID (1) + 0x75, 0x08, // Report Size (8) + 0x95, 0x01, // Report Count (1) + 0x81, 0x02, // Input + 0x91, 0x02, // Output + 0xb1, 0x02, // Feature + 0xc0, // End Collection + ]; + + const TWO_BYTE_INPUT_DESCRIPTOR: &[u8] = &[ + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x02, // Usage (Mouse) + 0xa1, 0x01, // Collection (Application) + 0x75, 0x08, // Report Size (8) + 0x95, 0x02, // Report Count (2) + 0x81, 0x02, // Input + 0xc0, // End Collection + ]; + + const TWO_BYTE_OUTPUT_DESCRIPTOR: &[u8] = &[ + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x02, // Usage (Mouse) + 0xa1, 0x01, // Collection (Application) + 0x75, 0x08, // Report Size (8) + 0x95, 0x02, // Report Count (2) + 0x91, 0x02, // Output + 0xc0, // End Collection + ]; + + const TWO_BYTE_FEATURE_DESCRIPTOR: &[u8] = &[ + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x02, // Usage (Mouse) + 0xa1, 0x01, // Collection (Application) + 0x75, 0x08, // Report Size (8) + 0x95, 0x02, // Report Count (2) + 0xb1, 0x02, // Feature + 0xc0, // End Collection + ]; + + #[tokio::test] + async fn descriptor_uses_implicit_report_framing() { + let descriptor = + DeviceDescriptor::new(&descriptor_device(IMPLICIT_DESCRIPTOR), hardware_version_info()).unwrap(); + + assert_eq!( + descriptor.w_hid_desc_length, + core::mem::size_of::() as u16 + ); + assert_eq!(descriptor.bcd_version, 0x0100); + assert_eq!(descriptor.w_report_desc_length, IMPLICIT_DESCRIPTOR.len() as u16); + assert_eq!(descriptor.w_max_input_length, 3); + assert_eq!(descriptor.w_max_output_length, 3); + assert_eq!(descriptor.w_vendor_id, 0x1234); + assert_eq!(descriptor.w_product_id, 0x5678); + assert_eq!(descriptor.w_version_id, 0x0100); + } + + #[tokio::test] + async fn descriptor_accounts_for_explicit_report_id() { + let descriptor = + DeviceDescriptor::new(&descriptor_device(EXPLICIT_DESCRIPTOR), hardware_version_info()).unwrap(); + + assert_eq!(descriptor.w_max_input_length, 4); + assert_eq!(descriptor.w_max_output_length, 4); + } + + #[tokio::test] + async fn descriptor_rejects_oversized_input_report() { + let result = DeviceDescriptor::new(&descriptor_device(TWO_BYTE_INPUT_DESCRIPTOR), hardware_version_info()); + + assert_eq!( + result, + Err(DeviceDescriptorError::InputReportTooLarge { actual: 2, max: 1 }) + ); + } + + #[tokio::test] + async fn descriptor_rejects_oversized_output_report() { + let result = DeviceDescriptor::new(&descriptor_device(TWO_BYTE_OUTPUT_DESCRIPTOR), hardware_version_info()); + + assert_eq!( + result, + Err(DeviceDescriptorError::OutputReportTooLarge { actual: 2, max: 1 }) + ); + } + + #[tokio::test] + async fn descriptor_rejects_oversized_feature_report() { + let result = DeviceDescriptor::new(&descriptor_device(TWO_BYTE_FEATURE_DESCRIPTOR), hardware_version_info()); + + assert_eq!( + result, + Err(DeviceDescriptorError::FeatureReportTooLarge { actual: 2, max: 1 }) + ); + } + + #[tokio::test] + async fn vendor_id_rejects_zero() { + assert!(VendorId::new(0).is_none()); + assert_eq!(VendorId::new(1).unwrap().value(), 1); + } +} diff --git a/hidi2c-target-service/src/lib.rs b/hidi2c-target-service/src/lib.rs index 5b5ab7745..9838b9b14 100644 --- a/hidi2c-target-service/src/lib.rs +++ b/hidi2c-target-service/src/lib.rs @@ -18,6 +18,9 @@ pub use constrained_hid_device::ConstrainedHidDevice; mod service; pub use service::{Runner, Service, TimeoutSettings}; +#[cfg(test)] +mod test_support; + use embedded_services::{error, info, trace, warn}; /// HID-I2C register addresses as specified in section 5.1 of the HID-I2C spec. diff --git a/hidi2c-target-service/src/service.rs b/hidi2c-target-service/src/service.rs index 770da99fa..3d880cf8b 100644 --- a/hidi2c-target-service/src/service.rs +++ b/hidi2c-target-service/src/service.rs @@ -5,7 +5,7 @@ use embassy_time::{Duration, with_timeout}; use embedded_mcu_hal::i2c::target::asynch::I2c as I2cTargetAsync; use embedded_mcu_hal::i2c::target::{ReadStatus, Request, WriteStatus}; use embedded_services::relay::hid; -use embedded_services::relay::hid::{GetHidReportType, HidError, HidReport, SetHidReport}; +use embedded_services::relay::hid::{GetHidReport, GetHidReportType, HidError, HidReport, SetHidReport}; use zerocopy::IntoBytes; /// HID-I2C Command Opcode as specified in section 7.1.1 of the HID-I2C spec @@ -605,6 +605,7 @@ impl< trace!("Processing get report command"); let (report_type, report_id, _data) = Self::get_io_command_report_header(data, command_byte).await?; + let report_ids_implicit = hid_device.report_descriptor().report_ids_implicit(); // TODO - here, if the report ID is invalid, we're supposed to return a zero-length report. We should know from the // report descriptor whether the report ID is valid or not, but we don't yet have the report descriptor parsing @@ -614,11 +615,23 @@ impl< hid_device .process_get_report(report_type.try_into()?, report_id, async |report| { - // Note: per HID spec, the length field needs to include its own length (2 bytes) + let (report_id, report_data) = match &report { + GetHidReport::Input(report) | GetHidReport::Feature(report) => (report.id(), report.data()), + }; let len_header = (report.data().len() as u16 + device_descriptor::HID_REPORT_HEADER_SIZE_BYTES) - .to_le_bytes(); - bus.write_unterminated(&len_header).await?; - bus.write(report.data()).await?; + + if report_ids_implicit { + 0 + } else { + device_descriptor::HID_REPORT_ID_SIZE_BYTES + }; + let [size_low, size_high] = len_header.to_le_bytes(); + let header_slice: &[u8] = if report_ids_implicit { + &[size_low, size_high] + } else { + &[size_low, size_high, report_id.0] + }; + bus.write_unterminated(header_slice).await?; + bus.write(report_data).await?; Ok::<(), Error>(()) }) .await??; @@ -630,13 +643,25 @@ impl< trace!("Processing set report command"); let (report_type, report_id, data) = Self::get_io_command_report_header(data, command_byte).await?; - let (&len_header, data) = data + let (&len_header, mut data) = data .split_first_chunk::<{ core::mem::size_of::() }>() .ok_or(Error::Protocol(ProtocolError::InvalidSize))?; - // Note: per HID spec, the length field relayed over the wire needs to include its own length (2 bytes) + let report_id_size = if hid_device.report_descriptor().report_ids_implicit() { + 0 + } else { + let (&wire_report_id, remaining) = + data.split_first().ok_or(Error::Protocol(ProtocolError::InvalidSize))?; + if wire_report_id != report_id.0 { + return Err(Error::Protocol(ProtocolError::InvalidData)); + } + data = remaining; + device_descriptor::HID_REPORT_ID_SIZE_BYTES + }; + + // The wire length includes its own field and the report ID, when one is present. let report_size = (u16::from_le_bytes(len_header) - .checked_sub(device_descriptor::HID_REPORT_HEADER_SIZE_BYTES)) + .checked_sub(device_descriptor::HID_REPORT_HEADER_SIZE_BYTES + report_id_size)) .ok_or(Error::Protocol(ProtocolError::InvalidSize))? as usize; let report_data = data @@ -751,3 +776,434 @@ impl Default for TimeoutSettings { } } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + extern crate std; + + use super::*; + use crate::test_support::{RecordingHidDevice, hardware_version_info, recording_device}; + use core::convert::Infallible; + use embedded_mcu_hal::i2c::target::{ErrorType, ReadStatus, WriteStatus}; + use embedded_services::relay::hid::{HidDevicePowerState, ReportId}; + use std::{collections::VecDeque, vec, vec::Vec}; + + struct NoopBus; + + impl ErrorType for NoopBus { + type Error = Infallible; + } + + impl I2cTargetAsync for NoopBus { + async fn recover(&mut self) -> Result<(), Self::Error> { + Ok(()) + } + + async fn listen(&mut self) -> Result { + core::future::pending().await + } + + async fn respond_to_read(&mut self, _buf: &[u8]) -> Result { + core::future::pending().await + } + + async fn respond_to_write(&mut self, _buf: &mut [u8]) -> Result { + core::future::pending().await + } + } + + struct NoopPin; + + impl embedded_hal::digital::ErrorType for NoopPin { + type Error = Infallible; + } + + impl embedded_hal::digital::OutputPin for NoopPin { + fn set_low(&mut self) -> Result<(), Self::Error> { + Ok(()) + } + + fn set_high(&mut self) -> Result<(), Self::Error> { + Ok(()) + } + } + + fn timeout_bus() -> TimeoutBus { + TimeoutBus { + bus: NoopBus, + timeout_settings: TimeoutSettings::default(), + } + } + + struct IncomingWrite { + data: Vec, + status: WriteStatus, + } + + #[derive(Default)] + struct ScriptedBus { + incoming_writes: VecDeque, + read_statuses: VecDeque, + outgoing_reads: Vec>, + recover_count: usize, + } + + impl ErrorType for ScriptedBus { + type Error = Infallible; + } + + impl I2cTargetAsync for ScriptedBus { + async fn recover(&mut self) -> Result<(), Self::Error> { + self.recover_count += 1; + Ok(()) + } + + async fn listen(&mut self) -> Result { + core::future::pending().await + } + + async fn respond_to_read(&mut self, buf: &[u8]) -> Result { + let Some(status) = self.read_statuses.pop_front() else { + return core::future::pending().await; + }; + self.outgoing_reads.push(buf.to_vec()); + Ok(status) + } + + async fn respond_to_write(&mut self, buf: &mut [u8]) -> Result { + let Some(write) = self.incoming_writes.pop_front() else { + return core::future::pending().await; + }; + for (destination, source) in buf.iter_mut().zip(write.data.iter()) { + *destination = *source; + } + Ok(write.status) + } + } + + fn scripted_timeout_bus(bus: ScriptedBus) -> TimeoutBus { + TimeoutBus { + bus, + timeout_settings: TimeoutSettings { + device_response_timeout: Duration::from_millis(20), + data_read_timeout: Duration::from_millis(20), + }, + } + } + + #[tokio::test] + async fn command_header_parses_inline_report_id() { + // 0x23: report type nibble 0x2 = Output, report ID nibble 0x3 (inline, < 0xF). + let header = HidI2cReportCommandHeader::try_from_command_byte(0x23).unwrap(); + + assert!(matches!(header.report_type, HidI2cReportType::Output)); + assert_eq!(header.report_id, Some(hid::ReportId(3))); + } + + #[tokio::test] + async fn command_header_marks_extended_report_id() { + // 0x3f: report type nibble 0x3 = Feature, report ID nibble 0xF = extended (real ID in a following byte). + let header = HidI2cReportCommandHeader::try_from_command_byte(0x3f).unwrap(); + + assert!(matches!(header.report_type, HidI2cReportType::Feature)); + assert_eq!(header.report_id, None); + } + + #[tokio::test] + async fn command_header_rejects_reserved_report_type() { + // 0x03: report type nibble 0x0 is reserved/invalid. + assert!(matches!( + HidI2cReportCommandHeader::try_from_command_byte(0x03), + Err(ProtocolError::InvalidReportType) + )); + } + + #[tokio::test] + async fn set_power_command_updates_device() { + let mut bus = timeout_bus(); + let mut device = recording_device(); + + Runner::::process_command( + // Command register (little-endian): low byte 0x01 = power state Sleep, high byte = SetPower opcode. + &[0x01, Opcode::SetPower as u8], + &mut bus, + &mut device, + ) + .await + .unwrap(); + + assert!(matches!(device.power_state, Some(HidDevicePowerState::Sleep))); + } + + #[tokio::test] + async fn reset_command_requests_device_reset() { + let mut bus = timeout_bus(); + let mut device = recording_device(); + + let result = Runner::::process_command( + // Command register (little-endian): low byte is unused for Reset, high byte = Reset opcode. + &[0x00, Opcode::Reset as u8], + &mut bus, + &mut device, + ) + .await; + + assert!(matches!(result, Err(Error::Device(HidError::TriggerReset)))); + } + + #[tokio::test] + async fn set_output_report_forwards_payload() { + let mut bus = timeout_bus(); + let mut device = recording_device(); + let command = [ + 0x23, // command low byte: report type Output (0x2), inline report ID 3 + Opcode::SetReport as u8, // command high byte: SetReport opcode + HidI2cRegister::Data as u8, // data register address, low byte (0x06) + 0x00, // data register address, high byte -> 0x0006 + 0x06, // length field, low byte + 0x00, // length field, high byte -> 6 total bytes + 0x03, // report ID echoed in the data payload (must match header) + 0xaa, // report payload + 0xbb, + 0xcc, + ]; + + Runner::::process_command(&command, &mut bus, &mut device) + .await + .unwrap(); + + assert_eq!(device.report_id, Some(ReportId(3))); + assert_eq!( + device.report_data.get(..device.report_len), + Some(&[0xaa, 0xbb, 0xcc][..]) + ); + assert!(!device.feature_report); + } + + #[tokio::test] + async fn set_feature_report_accepts_extended_report_id() { + let mut bus = timeout_bus(); + let mut device = recording_device(); + let command = [ + 0x3f, // command low byte: report type Feature (0x3), report ID nibble 0xF = extended + Opcode::SetReport as u8, // command high byte: SetReport opcode + 0x21, // extended report ID (0x21) + HidI2cRegister::Data as u8, // data register address, low byte (0x06) + 0x00, // data register address, high byte -> 0x0006 + 0x04, // length field, low byte + 0x00, // length field, high byte -> 4 total bytes + 0x21, // report ID echoed in the data payload (must match header) + 0x5a, // report payload + ]; + + Runner::::process_command(&command, &mut bus, &mut device) + .await + .unwrap(); + + assert_eq!(device.report_id, Some(ReportId(0x21))); + assert_eq!(device.report_data.get(..device.report_len), Some(&[0x5a][..])); + assert!(device.feature_report); + } + + #[tokio::test] + async fn set_report_rejects_length_smaller_than_header() { + let mut bus = timeout_bus(); + let mut device = recording_device(); + let command = [ + 0x23, // command low byte: report type Output (0x2), inline report ID 3 + Opcode::SetReport as u8, // command high byte: SetReport opcode + HidI2cRegister::Data as u8, // data register address, low byte (0x06) + 0x00, // data register address, high byte -> 0x0006 + 0x01, // length field, low byte + 0x00, // length field, high byte -> 1, too small to hold the header -> InvalidSize + ]; + + let result = + Runner::::process_command(&command, &mut bus, &mut device).await; + + assert!(matches!(result, Err(Error::Protocol(ProtocolError::InvalidSize)))); + } + + #[tokio::test] + async fn set_report_rejects_mismatched_wire_report_id() { + let mut bus = timeout_bus(); + let mut device = recording_device(); + let command = [ + 0x23, // command low byte: report type Output (0x2), inline report ID 3 + Opcode::SetReport as u8, // command high byte: SetReport opcode + HidI2cRegister::Data as u8, // data register address, low byte (0x06) + 0x00, // data register address, high byte -> 0x0006 + 0x04, // length field, low byte + 0x00, // length field, high byte -> 4 total bytes + 0x04, // report ID in data payload = 4, mismatches header's 3 -> InvalidData + 0x5a, // report payload + ]; + + let result = + Runner::::process_command(&command, &mut bus, &mut device).await; + + assert!(matches!(result, Err(Error::Protocol(ProtocolError::InvalidData)))); + } + + #[tokio::test] + async fn get_report_rejects_output_report_type() { + let mut bus = timeout_bus(); + let mut device = recording_device(); + let command = [ + 0x21, // command low byte: report type Output (0x2), report ID 1 + Opcode::GetReport as u8, // command high byte: GetReport opcode (Output reports can't be read -> InvalidReportType) + HidI2cRegister::Data as u8, // data register address, low byte (0x06) + 0x00, // data register address, high byte -> 0x0006 + ]; + + let result = + Runner::::process_command(&command, &mut bus, &mut device).await; + + assert!(matches!(result, Err(Error::Protocol(ProtocolError::InvalidReportType)))); + } + + #[tokio::test] + async fn get_feature_report_includes_explicit_report_id() { + let mut bus = scripted_timeout_bus(ScriptedBus { + read_statuses: VecDeque::from([ReadStatus::Complete(3), ReadStatus::Complete(1)]), + ..Default::default() + }); + let mut device = recording_device(); + let command = [ + 0x3f, // command low byte: report type Feature (0x3), report ID nibble 0xF = extended + Opcode::GetReport as u8, // command high byte: GetReport opcode + 0x21, // extended report ID (0x21) + HidI2cRegister::Data as u8, // data register address, low byte (0x06) + 0x00, // data register address, high byte -> 0x0006 + ]; + + Runner::::process_command(&command, &mut bus, &mut device) + .await + .unwrap(); + + assert_eq!( + bus.bus.outgoing_reads.first().map(Vec::as_slice), + Some(&[0x04, 0x00, 0x21][..]) + ); + assert_eq!(bus.bus.outgoing_reads.get(1).map(Vec::as_slice), Some(&[0x5a][..])); + } + + #[tokio::test] + async fn reset_asserts_interrupt_and_first_read_acknowledges_completion() { + let bus = ScriptedBus { + read_statuses: VecDeque::from([ReadStatus::Complete(2)]), + ..Default::default() + }; + let mut resources = Resources::default(); + let (_service, mut runner) = Service::new( + &mut resources, + bus, + NoopPin, + recording_device(), + hardware_version_info(), + TimeoutSettings { + device_response_timeout: Duration::from_millis(20), + data_read_timeout: Duration::from_millis(20), + }, + ) + .await + .unwrap(); + + runner.reset().await; + + assert!(runner.pending_reset); + assert!(runner.attn_pin.asserted()); + assert_eq!(runner.hid_device.reset_count, 1); + assert_eq!(runner.hid_device.power_state, Some(HidDevicePowerState::On)); + + runner.reply_with_input_report().await.unwrap(); + + assert!(!runner.pending_reset); + assert!(!runner.attn_pin.asserted()); + assert_eq!( + runner.bus.bus.outgoing_reads.first().map(Vec::as_slice), + Some(&[0x00, 0x00][..]) + ); + } + + #[tokio::test] + async fn timeout_bus_reads_host_payload() { + let mut bus = scripted_timeout_bus(ScriptedBus { + incoming_writes: VecDeque::from([IncomingWrite { + data: vec![0x10, 0x20, 0x30], + status: WriteStatus::Stopped(3), + }]), + ..Default::default() + }); + let mut buffer = [0; 4]; + + let payload = bus.read(&mut buffer).await.unwrap(); + + assert_eq!(payload, &[0x10, 0x20, 0x30]); + assert_eq!(bus.bus.recover_count, 0); + } + + #[tokio::test] + async fn timeout_bus_drains_oversized_host_write() { + let mut bus = scripted_timeout_bus(ScriptedBus { + incoming_writes: VecDeque::from([ + IncomingWrite { + data: vec![0x10, 0x20], + status: WriteStatus::BufferFull(2), + }, + IncomingWrite { + data: vec![0x30, 0x40], + status: WriteStatus::Stopped(2), + }, + ]), + ..Default::default() + }); + let mut buffer = [0; 2]; + + let result = bus.read(&mut buffer).await; + + assert!(matches!(result, Err(Error::Protocol(ProtocolError::InvalidData)))); + assert!(bus.bus.incoming_writes.is_empty()); + assert_eq!(bus.bus.recover_count, 0); + } + + #[tokio::test] + async fn timeout_bus_uses_zeroes_when_host_reads_past_response() { + let mut bus = scripted_timeout_bus(ScriptedBus { + read_statuses: VecDeque::from([ReadStatus::NeedMore(2), ReadStatus::Complete(3)]), + ..Default::default() + }); + + bus.write(&[0xaa, 0xbb]).await.unwrap(); + + assert_eq!(bus.bus.outgoing_reads.len(), 2); + assert_eq!( + bus.bus.outgoing_reads.first().map(Vec::as_slice), + Some(&[0xaa, 0xbb][..]) + ); + assert_eq!(bus.bus.outgoing_reads.get(1).map(Vec::as_slice), Some(&[0; 8][..])); + assert_eq!(bus.bus.recover_count, 0); + } + + #[tokio::test] + async fn timeout_bus_recovers_after_host_write_timeout() { + let mut bus = scripted_timeout_bus(ScriptedBus::default()); + let mut buffer = [0; 4]; + + let result = bus.read(&mut buffer).await; + + assert!(matches!(result, Err(Error::Protocol(ProtocolError::Timeout)))); + assert_eq!(bus.bus.recover_count, 1); + } + + #[tokio::test] + async fn timeout_bus_recovers_after_host_read_timeout() { + let mut bus = scripted_timeout_bus(ScriptedBus::default()); + + let result = bus.write(&[0xaa]).await; + + assert!(matches!(result, Err(Error::Protocol(ProtocolError::Timeout)))); + assert_eq!(bus.bus.recover_count, 1); + } +} diff --git a/hidi2c-target-service/src/test_support.rs b/hidi2c-target-service/src/test_support.rs new file mode 100644 index 000000000..a831421d6 --- /dev/null +++ b/hidi2c-target-service/src/test_support.rs @@ -0,0 +1,155 @@ +//! Shared test-only mocks for the HID-I2C target service. + +#![allow(clippy::unwrap_used)] + +use crate::{HardwareVersionInfo, ProductId, VendorId, VersionId}; +use core::marker::PhantomData; +use embedded_services::relay::hid::{ + GetHidReport, GetHidReportType, HidDevice, HidDevicePowerState, HidError, HidReport, HidReportDescriptor, ReportId, + SetHidReport, +}; +use generic_array::ArrayLength; + +/// Report descriptor with explicit output + feature report IDs, used by the wire-format tests. +pub const MOUSE_DESCRIPTOR: &[u8] = &[ + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x02, // Usage (Mouse) + 0xa1, 0x01, // Collection (Application) + 0x85, 0x03, // Report ID (3) + 0x75, 0x08, // Report Size (8) + 0x95, 0x01, // Report Count (1) + 0x91, 0x02, // Output + 0x85, 0x21, // Report ID (33) + 0xb1, 0x02, // Feature + 0xc0, // End Collection +]; + +/// A HID device mock that records the most recent command it received so tests can assert on it. +/// +/// It is generic over the report-size / report-count / descriptor-length parameters so it can serve +/// both the descriptor-sizing tests (which need small maxima to exercise the oversize-rejection paths) +/// and the wire-format tests (which need room for multi-byte reports). +pub struct MockHidDevice +where + In: ArrayLength, + Out: ArrayLength, + Feat: ArrayLength, +{ + descriptor: HidReportDescriptor<'static>, + pub power_state: Option, + pub report_id: Option, + pub report_data: [u8; 8], + pub report_len: usize, + pub feature_report: bool, + pub reset_count: usize, + _phantom: PhantomData<(In, Out, Feat)>, +} + +impl MockHidDevice +where + In: ArrayLength, + Out: ArrayLength, + Feat: ArrayLength, +{ + pub fn new(descriptor: &'static [u8]) -> Self { + Self { + descriptor: HidReportDescriptor::new(descriptor).unwrap(), + power_state: None, + report_id: None, + report_data: [0; 8], + report_len: 0, + feature_report: false, + reset_count: 0, + _phantom: PhantomData, + } + } +} + +impl HidDevice + for MockHidDevice +where + In: ArrayLength, + Out: ArrayLength, + Feat: ArrayLength, +{ + type InputReportMaxSize = In; + type OutputReportMaxSize = Out; + type FeatureReportMaxSize = Feat; + + const MAX_REPORT_COUNT: u8 = REPORT_COUNT; + const MAX_DESCRIPTOR_LEN: usize = DESC_LEN; + + fn report_descriptor(&self) -> &HidReportDescriptor<'_> { + &self.descriptor + } + + async fn process_get_report( + &mut self, + _report_type: GetHidReportType, + report_id: ReportId, + process_report: impl AsyncFnOnce(GetHidReport<'_>) -> R, + ) -> Result { + Ok(process_report(GetHidReport::Feature(HidReport::new(report_id, &[0x5a]))).await) + } + + async fn set_report(&mut self, report: &SetHidReport<'_>) -> Result<(), HidError> { + self.report_id = Some(report.id()); + self.report_len = report.data().len(); + self.report_data + .get_mut(..self.report_len) + .ok_or(HidError::TriggerReset)? + .copy_from_slice(report.data()); + self.feature_report = matches!(report, SetHidReport::Feature(_)); + Ok(()) + } + + async fn wait_for_input_report(&mut self) { + core::future::pending().await + } + + fn has_pending_input_report(&mut self) -> bool { + false + } + + async fn process_next_input_report( + &mut self, + process_report: impl AsyncFnOnce(HidReport<'_>) -> R, + ) -> Result { + Ok(process_report(HidReport::new(ReportId(0), &[])).await) + } + + async fn set_power_state(&mut self, state: HidDevicePowerState) -> Result<(), HidError> { + self.power_state = Some(state); + Ok(()) + } + + async fn reset(&mut self) { + self.reset_count += 1; + self.power_state = Some(HidDevicePowerState::On); + } +} + +/// Wire-format recording device: 8-byte report maxima with explicit output + feature reports. +pub type RecordingHidDevice = MockHidDevice; + +/// Descriptor-sizing device: single-byte report maxima to exercise the oversize-rejection paths. +pub type DescriptorHidDevice = MockHidDevice; + +/// Constructs a [`RecordingHidDevice`] backed by [`MOUSE_DESCRIPTOR`]. +pub fn recording_device() -> RecordingHidDevice { + MockHidDevice::new(MOUSE_DESCRIPTOR) +} + +/// Constructs a [`DescriptorHidDevice`] backed by the provided report descriptor. +pub fn descriptor_device(descriptor: &'static [u8]) -> DescriptorHidDevice { + MockHidDevice::new(descriptor) +} + +/// A fixed set of hardware identifiers used across the descriptor and service tests. +pub fn hardware_version_info() -> HardwareVersionInfo { + HardwareVersionInfo { + vendor_id: VendorId::new(0x1234).unwrap(), + product_id: ProductId(0x5678), + version_id: VersionId(0x0100), + } +}