mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-28 16:41:52 +08:00
feat(hid): 添加 Consumer Control 多媒体按键和多平台键盘布局
- 新增 Consumer Control HID 支持(播放/暂停、音量控制等) - 虚拟键盘支持 Windows/Mac/Android 三种布局切换 - 移除键盘 LED 反馈以节省 USB 端点(从 2 减至 1) - InfoBar 优化:按键名称友好显示,移除未实现的 Num/Scroll 指示器 - 更新 HID 模块文档
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::types::{KeyboardEvent, MouseEvent};
|
||||
use super::types::{ConsumerEvent, KeyboardEvent, MouseEvent};
|
||||
use crate::error::Result;
|
||||
|
||||
/// Default CH9329 baud rate
|
||||
@@ -94,6 +94,14 @@ pub trait HidBackend: Send + Sync {
|
||||
/// Send a mouse event
|
||||
async fn send_mouse(&self, event: MouseEvent) -> Result<()>;
|
||||
|
||||
/// Send a consumer control event (multimedia keys)
|
||||
/// Default implementation returns an error (not supported)
|
||||
async fn send_consumer(&self, _event: ConsumerEvent) -> Result<()> {
|
||||
Err(crate::error::AppError::BadRequest(
|
||||
"Consumer control not supported by this backend".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Reset all inputs (release all keys/buttons)
|
||||
async fn reset(&self) -> Result<()>;
|
||||
|
||||
|
||||
45
src/hid/consumer.rs
Normal file
45
src/hid/consumer.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
//! USB HID Consumer Control Usage codes
|
||||
//!
|
||||
//! Reference: USB HID Usage Tables 1.12, Section 15 (Consumer Page 0x0C)
|
||||
|
||||
/// Consumer Control Usage codes for multimedia keys
|
||||
pub mod usage {
|
||||
// Transport Controls
|
||||
pub const PLAY_PAUSE: u16 = 0x00CD;
|
||||
pub const STOP: u16 = 0x00B7;
|
||||
pub const NEXT_TRACK: u16 = 0x00B5;
|
||||
pub const PREV_TRACK: u16 = 0x00B6;
|
||||
|
||||
// Volume Controls
|
||||
pub const MUTE: u16 = 0x00E2;
|
||||
pub const VOLUME_UP: u16 = 0x00E9;
|
||||
pub const VOLUME_DOWN: u16 = 0x00EA;
|
||||
}
|
||||
|
||||
/// Check if a usage code is valid
|
||||
pub fn is_valid_usage(usage: u16) -> bool {
|
||||
matches!(
|
||||
usage,
|
||||
usage::PLAY_PAUSE
|
||||
| usage::STOP
|
||||
| usage::NEXT_TRACK
|
||||
| usage::PREV_TRACK
|
||||
| usage::MUTE
|
||||
| usage::VOLUME_UP
|
||||
| usage::VOLUME_DOWN
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_usage_codes() {
|
||||
assert!(is_valid_usage(usage::PLAY_PAUSE));
|
||||
assert!(is_valid_usage(usage::MUTE));
|
||||
assert!(is_valid_usage(usage::VOLUME_UP));
|
||||
assert!(!is_valid_usage(0x0000));
|
||||
assert!(!is_valid_usage(0xFFFF));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! - Byte 0: Message type
|
||||
//! - 0x01: Keyboard event
|
||||
//! - 0x02: Mouse event
|
||||
//! - 0x03: Consumer control event (multimedia keys)
|
||||
//! - Remaining bytes: Event data
|
||||
//!
|
||||
//! Keyboard event (type 0x01):
|
||||
@@ -29,9 +30,13 @@
|
||||
//! - Bytes 2-3: X coordinate (i16 LE for relative, u16 LE for absolute)
|
||||
//! - Bytes 4-5: Y coordinate (i16 LE for relative, u16 LE for absolute)
|
||||
//! - Byte 6: Button (0=left, 1=middle, 2=right) or Scroll delta (i8)
|
||||
//!
|
||||
//! Consumer control event (type 0x03):
|
||||
//! - Bytes 1-2: Usage code (u16 LE)
|
||||
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use super::types::ConsumerEvent;
|
||||
use super::{
|
||||
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent, MouseEventType,
|
||||
};
|
||||
@@ -39,6 +44,7 @@ use super::{
|
||||
/// Message types
|
||||
pub const MSG_KEYBOARD: u8 = 0x01;
|
||||
pub const MSG_MOUSE: u8 = 0x02;
|
||||
pub const MSG_CONSUMER: u8 = 0x03;
|
||||
|
||||
/// Keyboard event types
|
||||
pub const KB_EVENT_DOWN: u8 = 0x00;
|
||||
@@ -56,6 +62,7 @@ pub const MS_EVENT_SCROLL: u8 = 0x04;
|
||||
pub enum HidChannelEvent {
|
||||
Keyboard(KeyboardEvent),
|
||||
Mouse(MouseEvent),
|
||||
Consumer(ConsumerEvent),
|
||||
}
|
||||
|
||||
/// Parse a binary HID message from DataChannel
|
||||
@@ -70,6 +77,7 @@ pub fn parse_hid_message(data: &[u8]) -> Option<HidChannelEvent> {
|
||||
match msg_type {
|
||||
MSG_KEYBOARD => parse_keyboard_message(&data[1..]),
|
||||
MSG_MOUSE => parse_mouse_message(&data[1..]),
|
||||
MSG_CONSUMER => parse_consumer_message(&data[1..]),
|
||||
_ => {
|
||||
warn!("Unknown HID message type: 0x{:02X}", msg_type);
|
||||
None
|
||||
@@ -173,6 +181,20 @@ fn parse_mouse_message(data: &[u8]) -> Option<HidChannelEvent> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Parse consumer control message payload
|
||||
fn parse_consumer_message(data: &[u8]) -> Option<HidChannelEvent> {
|
||||
if data.len() < 2 {
|
||||
warn!("Consumer message too short: {} bytes", data.len());
|
||||
return None;
|
||||
}
|
||||
|
||||
let usage = u16::from_le_bytes([data[0], data[1]]);
|
||||
|
||||
debug!("Parsed consumer: usage=0x{:04X}", usage);
|
||||
|
||||
Some(HidChannelEvent::Consumer(ConsumerEvent { usage }))
|
||||
}
|
||||
|
||||
/// Encode a keyboard event to binary format (for sending to client if needed)
|
||||
pub fn encode_keyboard_event(event: &KeyboardEvent) -> Vec<u8> {
|
||||
let event_type = match event.event_type {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
pub mod backend;
|
||||
pub mod ch9329;
|
||||
pub mod consumer;
|
||||
pub mod datachannel;
|
||||
pub mod keymap;
|
||||
pub mod monitor;
|
||||
@@ -24,7 +25,8 @@ pub use backend::{HidBackend, HidBackendType};
|
||||
pub use monitor::{HidHealthMonitor, HidHealthStatus, HidMonitorConfig};
|
||||
pub use otg::LedState;
|
||||
pub use types::{
|
||||
KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent, MouseEventType,
|
||||
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardModifiers, MouseButton, MouseEvent,
|
||||
MouseEventType,
|
||||
};
|
||||
|
||||
/// HID backend information
|
||||
@@ -199,6 +201,33 @@ impl HidController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send consumer control event (multimedia keys)
|
||||
pub async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
|
||||
let backend = self.backend.read().await;
|
||||
match backend.as_ref() {
|
||||
Some(b) => {
|
||||
match b.send_consumer(event).await {
|
||||
Ok(_) => {
|
||||
if self.monitor.is_error().await {
|
||||
let backend_type = self.backend_type.read().await;
|
||||
self.monitor.report_recovered(backend_type.name_str()).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
if let AppError::HidError { ref backend, ref reason, ref error_code } = e {
|
||||
if error_code != "eagain_retry" {
|
||||
self.monitor.report_error(backend, None, reason, error_code).await;
|
||||
}
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => Err(AppError::BadRequest("HID backend not available".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset all keys (release all pressed keys)
|
||||
pub async fn reset(&self) -> Result<()> {
|
||||
let backend = self.backend.read().await;
|
||||
|
||||
224
src/hid/otg.rs
224
src/hid/otg.rs
@@ -18,17 +18,19 @@
|
||||
//! See: https://github.com/raspberrypi/linux/issues/4373
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
|
||||
use parking_lot::Mutex;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::os::unix::io::AsFd;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
use super::backend::HidBackend;
|
||||
use super::keymap;
|
||||
use super::types::{KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType};
|
||||
use super::types::{ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::otg::{HidDevicePaths, wait_for_hid_devices};
|
||||
|
||||
@@ -38,6 +40,7 @@ enum DeviceType {
|
||||
Keyboard,
|
||||
MouseRelative,
|
||||
MouseAbsolute,
|
||||
ConsumerControl,
|
||||
}
|
||||
|
||||
/// Keyboard LED state
|
||||
@@ -79,7 +82,7 @@ impl LedState {
|
||||
}
|
||||
}
|
||||
|
||||
/// OTG HID backend with 3 devices
|
||||
/// OTG HID backend with 4 devices
|
||||
///
|
||||
/// This backend opens HID device files created by OtgService.
|
||||
/// It does NOT manage the USB gadget itself - that's handled by OtgService.
|
||||
@@ -99,12 +102,16 @@ pub struct OtgBackend {
|
||||
mouse_rel_path: PathBuf,
|
||||
/// Absolute mouse device path (/dev/hidg2)
|
||||
mouse_abs_path: PathBuf,
|
||||
/// Consumer control device path (/dev/hidg3)
|
||||
consumer_path: PathBuf,
|
||||
/// Keyboard device file
|
||||
keyboard_dev: Mutex<Option<File>>,
|
||||
/// Relative mouse device file
|
||||
mouse_rel_dev: Mutex<Option<File>>,
|
||||
/// Absolute mouse device file
|
||||
mouse_abs_dev: Mutex<Option<File>>,
|
||||
/// Consumer control device file
|
||||
consumer_dev: Mutex<Option<File>>,
|
||||
/// Current keyboard state
|
||||
keyboard_state: Mutex<KeyboardReport>,
|
||||
/// Current mouse button state
|
||||
@@ -125,8 +132,8 @@ pub struct OtgBackend {
|
||||
eagain_count: AtomicU8,
|
||||
}
|
||||
|
||||
/// Threshold for consecutive EAGAIN errors before reporting offline
|
||||
const EAGAIN_OFFLINE_THRESHOLD: u8 = 3;
|
||||
/// Write timeout in milliseconds (same as JetKVM's hidWriteTimeout)
|
||||
const HID_WRITE_TIMEOUT_MS: i32 = 500;
|
||||
|
||||
impl OtgBackend {
|
||||
/// Create OTG backend from device paths provided by OtgService
|
||||
@@ -138,9 +145,11 @@ impl OtgBackend {
|
||||
keyboard_path: paths.keyboard,
|
||||
mouse_rel_path: paths.mouse_relative,
|
||||
mouse_abs_path: paths.mouse_absolute,
|
||||
consumer_path: paths.consumer.unwrap_or_else(|| PathBuf::from("/dev/hidg3")),
|
||||
keyboard_dev: Mutex::new(None),
|
||||
mouse_rel_dev: Mutex::new(None),
|
||||
mouse_abs_dev: Mutex::new(None),
|
||||
consumer_dev: Mutex::new(None),
|
||||
keyboard_state: Mutex::new(KeyboardReport::default()),
|
||||
mouse_buttons: AtomicU8::new(0),
|
||||
led_state: parking_lot::RwLock::new(LedState::default()),
|
||||
@@ -177,6 +186,39 @@ impl OtgBackend {
|
||||
self.eagain_count.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Write data to HID device with timeout (JetKVM style)
|
||||
///
|
||||
/// Uses poll() to wait for device to be ready for writing.
|
||||
/// If timeout expires, silently drops the data (acceptable for mouse movement).
|
||||
/// Returns Ok(true) if write succeeded, Ok(false) if timed out (silently dropped).
|
||||
fn write_with_timeout(&self, file: &mut File, data: &[u8]) -> std::io::Result<bool> {
|
||||
let mut pollfd = [PollFd::new(file.as_fd(), PollFlags::POLLOUT)];
|
||||
|
||||
match poll(&mut pollfd, PollTimeout::from(HID_WRITE_TIMEOUT_MS as u16)) {
|
||||
Ok(1) => {
|
||||
// Device ready, check for errors
|
||||
if let Some(revents) = pollfd[0].revents() {
|
||||
if revents.contains(PollFlags::POLLERR) || revents.contains(PollFlags::POLLHUP) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::BrokenPipe,
|
||||
"Device error or hangup",
|
||||
));
|
||||
}
|
||||
}
|
||||
// Write the data
|
||||
file.write_all(data)?;
|
||||
Ok(true)
|
||||
}
|
||||
Ok(0) => {
|
||||
// Timeout - silently drop (JetKVM behavior)
|
||||
trace!("HID write timeout, dropping data");
|
||||
Ok(false)
|
||||
}
|
||||
Ok(_) => Ok(false),
|
||||
Err(e) => Err(std::io::Error::new(std::io::ErrorKind::Other, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the UDC name for state checking
|
||||
pub fn set_udc_name(&self, udc: &str) {
|
||||
*self.udc_name.write() = Some(udc.to_string());
|
||||
@@ -247,6 +289,7 @@ impl OtgBackend {
|
||||
DeviceType::Keyboard => (&self.keyboard_path, &self.keyboard_dev),
|
||||
DeviceType::MouseRelative => (&self.mouse_rel_path, &self.mouse_rel_dev),
|
||||
DeviceType::MouseAbsolute => (&self.mouse_abs_path, &self.mouse_abs_dev),
|
||||
DeviceType::ConsumerControl => (&self.consumer_path, &self.consumer_dev),
|
||||
};
|
||||
|
||||
// Check if device path exists
|
||||
@@ -342,7 +385,7 @@ impl OtgBackend {
|
||||
///
|
||||
/// This method ensures the device is open before writing, and handles
|
||||
/// ESHUTDOWN errors by closing the device handle for later reconnection.
|
||||
/// EAGAIN errors are treated as temporary - device stays open.
|
||||
/// Uses write_with_timeout to avoid blocking on busy devices.
|
||||
fn send_keyboard_report(&self, report: &KeyboardReport) -> Result<()> {
|
||||
// Ensure device is ready
|
||||
self.ensure_device(DeviceType::Keyboard)?;
|
||||
@@ -350,13 +393,18 @@ impl OtgBackend {
|
||||
let mut dev = self.keyboard_dev.lock();
|
||||
if let Some(ref mut file) = *dev {
|
||||
let data = report.to_bytes();
|
||||
match file.write_all(&data) {
|
||||
Ok(_) => {
|
||||
match self.write_with_timeout(file, &data) {
|
||||
Ok(true) => {
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
self.reset_error_count();
|
||||
trace!("Sent keyboard report: {:02X?}", data);
|
||||
Ok(())
|
||||
}
|
||||
Ok(false) => {
|
||||
// Timeout - silently dropped (JetKVM behavior)
|
||||
self.log_throttled_error("HID keyboard write timeout, dropped");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let error_code = e.raw_os_error();
|
||||
|
||||
@@ -370,26 +418,9 @@ impl OtgBackend {
|
||||
Err(Self::io_error_to_hid_error(e, "Failed to write keyboard report"))
|
||||
}
|
||||
Some(11) => {
|
||||
// EAGAIN - temporary busy, track consecutive count
|
||||
self.log_throttled_error("HID keyboard busy (EAGAIN)");
|
||||
let count = self.eagain_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
if count >= EAGAIN_OFFLINE_THRESHOLD {
|
||||
// Exceeded threshold, report as offline
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
Err(AppError::HidError {
|
||||
backend: "otg".to_string(),
|
||||
reason: format!("Device busy ({} consecutive EAGAIN)", count),
|
||||
error_code: "eagain".to_string(),
|
||||
})
|
||||
} else {
|
||||
// Within threshold, return retry error (won't trigger offline event)
|
||||
Err(AppError::HidError {
|
||||
backend: "otg".to_string(),
|
||||
reason: "Device temporarily busy".to_string(),
|
||||
error_code: "eagain_retry".to_string(),
|
||||
})
|
||||
}
|
||||
// EAGAIN after poll - should be rare, silently drop
|
||||
trace!("Keyboard EAGAIN after poll, dropping");
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
@@ -413,7 +444,7 @@ impl OtgBackend {
|
||||
///
|
||||
/// This method ensures the device is open before writing, and handles
|
||||
/// ESHUTDOWN errors by closing the device handle for later reconnection.
|
||||
/// EAGAIN errors are treated as temporary - device stays open.
|
||||
/// Uses write_with_timeout to avoid blocking on busy devices.
|
||||
fn send_mouse_report_relative(&self, buttons: u8, dx: i8, dy: i8, wheel: i8) -> Result<()> {
|
||||
// Ensure device is ready
|
||||
self.ensure_device(DeviceType::MouseRelative)?;
|
||||
@@ -421,13 +452,17 @@ impl OtgBackend {
|
||||
let mut dev = self.mouse_rel_dev.lock();
|
||||
if let Some(ref mut file) = *dev {
|
||||
let data = [buttons, dx as u8, dy as u8, wheel as u8];
|
||||
match file.write_all(&data) {
|
||||
Ok(_) => {
|
||||
match self.write_with_timeout(file, &data) {
|
||||
Ok(true) => {
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
self.reset_error_count();
|
||||
trace!("Sent relative mouse report: {:02X?}", data);
|
||||
Ok(())
|
||||
}
|
||||
Ok(false) => {
|
||||
// Timeout - silently dropped (JetKVM behavior)
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let error_code = e.raw_os_error();
|
||||
|
||||
@@ -440,26 +475,8 @@ impl OtgBackend {
|
||||
Err(Self::io_error_to_hid_error(e, "Failed to write mouse report"))
|
||||
}
|
||||
Some(11) => {
|
||||
// EAGAIN - temporary busy, track consecutive count
|
||||
self.log_throttled_error("HID relative mouse busy (EAGAIN)");
|
||||
let count = self.eagain_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
if count >= EAGAIN_OFFLINE_THRESHOLD {
|
||||
// Exceeded threshold, report as offline
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
Err(AppError::HidError {
|
||||
backend: "otg".to_string(),
|
||||
reason: format!("Device busy ({} consecutive EAGAIN)", count),
|
||||
error_code: "eagain".to_string(),
|
||||
})
|
||||
} else {
|
||||
// Within threshold, return retry error (won't trigger offline event)
|
||||
Err(AppError::HidError {
|
||||
backend: "otg".to_string(),
|
||||
reason: "Device temporarily busy".to_string(),
|
||||
error_code: "eagain_retry".to_string(),
|
||||
})
|
||||
}
|
||||
// EAGAIN after poll - should be rare, silently drop
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
@@ -483,7 +500,7 @@ impl OtgBackend {
|
||||
///
|
||||
/// This method ensures the device is open before writing, and handles
|
||||
/// ESHUTDOWN errors by closing the device handle for later reconnection.
|
||||
/// EAGAIN errors are treated as temporary - device stays open.
|
||||
/// Uses write_with_timeout to avoid blocking on busy devices.
|
||||
fn send_mouse_report_absolute(&self, buttons: u8, x: u16, y: u16, wheel: i8) -> Result<()> {
|
||||
// Ensure device is ready
|
||||
self.ensure_device(DeviceType::MouseAbsolute)?;
|
||||
@@ -498,12 +515,16 @@ impl OtgBackend {
|
||||
(y >> 8) as u8,
|
||||
wheel as u8,
|
||||
];
|
||||
match file.write_all(&data) {
|
||||
Ok(_) => {
|
||||
match self.write_with_timeout(file, &data) {
|
||||
Ok(true) => {
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
self.reset_error_count();
|
||||
Ok(())
|
||||
}
|
||||
Ok(false) => {
|
||||
// Timeout - silently dropped (JetKVM behavior)
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let error_code = e.raw_os_error();
|
||||
|
||||
@@ -516,26 +537,8 @@ impl OtgBackend {
|
||||
Err(Self::io_error_to_hid_error(e, "Failed to write mouse report"))
|
||||
}
|
||||
Some(11) => {
|
||||
// EAGAIN - temporary busy, track consecutive count
|
||||
self.log_throttled_error("HID absolute mouse busy (EAGAIN)");
|
||||
let count = self.eagain_count.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
if count >= EAGAIN_OFFLINE_THRESHOLD {
|
||||
// Exceeded threshold, report as offline
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
Err(AppError::HidError {
|
||||
backend: "otg".to_string(),
|
||||
reason: format!("Device busy ({} consecutive EAGAIN)", count),
|
||||
error_code: "eagain".to_string(),
|
||||
})
|
||||
} else {
|
||||
// Within threshold, return retry error (won't trigger offline event)
|
||||
Err(AppError::HidError {
|
||||
backend: "otg".to_string(),
|
||||
reason: "Device temporarily busy".to_string(),
|
||||
error_code: "eagain_retry".to_string(),
|
||||
})
|
||||
}
|
||||
// EAGAIN after poll - should be rare, silently drop
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
@@ -555,6 +558,66 @@ impl OtgBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send consumer control report (2 bytes: usage_lo, usage_hi)
|
||||
///
|
||||
/// Sends a consumer control usage code and then releases it (sends 0x0000).
|
||||
fn send_consumer_report(&self, usage: u16) -> Result<()> {
|
||||
// Ensure device is ready
|
||||
self.ensure_device(DeviceType::ConsumerControl)?;
|
||||
|
||||
let mut dev = self.consumer_dev.lock();
|
||||
if let Some(ref mut file) = *dev {
|
||||
// Send the usage code
|
||||
let data = [(usage & 0xFF) as u8, (usage >> 8) as u8];
|
||||
match self.write_with_timeout(file, &data) {
|
||||
Ok(true) => {
|
||||
trace!("Sent consumer report: {:02X?}", data);
|
||||
// Send release (0x0000)
|
||||
let release = [0u8, 0u8];
|
||||
let _ = self.write_with_timeout(file, &release);
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
self.reset_error_count();
|
||||
Ok(())
|
||||
}
|
||||
Ok(false) => {
|
||||
// Timeout - silently dropped
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let error_code = e.raw_os_error();
|
||||
match error_code {
|
||||
Some(108) => {
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
debug!("Consumer control ESHUTDOWN, closing for recovery");
|
||||
*dev = None;
|
||||
Err(Self::io_error_to_hid_error(e, "Failed to write consumer report"))
|
||||
}
|
||||
Some(11) => {
|
||||
// EAGAIN after poll - silently drop
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
warn!("Consumer control write error: {}", e);
|
||||
Err(Self::io_error_to_hid_error(e, "Failed to write consumer report"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(AppError::HidError {
|
||||
backend: "otg".to_string(),
|
||||
reason: "Consumer control device not opened".to_string(),
|
||||
error_code: "not_opened".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Send consumer control event
|
||||
pub fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
|
||||
self.send_consumer_report(event.usage)
|
||||
}
|
||||
|
||||
/// Read keyboard LED state (non-blocking)
|
||||
pub fn read_led_state(&self) -> Result<Option<LedState>> {
|
||||
let mut dev = self.keyboard_dev.lock();
|
||||
@@ -635,6 +698,15 @@ impl HidBackend for OtgBackend {
|
||||
warn!("Absolute mouse device not found: {}", self.mouse_abs_path.display());
|
||||
}
|
||||
|
||||
// Open consumer control device (optional, may not exist on older setups)
|
||||
if self.consumer_path.exists() {
|
||||
let file = Self::open_device(&self.consumer_path)?;
|
||||
*self.consumer_dev.lock() = Some(file);
|
||||
info!("Consumer control device opened: {}", self.consumer_path.display());
|
||||
} else {
|
||||
debug!("Consumer control device not found: {}", self.consumer_path.display());
|
||||
}
|
||||
|
||||
// Mark as online if all devices opened successfully
|
||||
self.online.store(true, Ordering::Relaxed);
|
||||
|
||||
@@ -751,6 +823,7 @@ impl HidBackend for OtgBackend {
|
||||
*self.keyboard_dev.lock() = None;
|
||||
*self.mouse_rel_dev.lock() = None;
|
||||
*self.mouse_abs_dev.lock() = None;
|
||||
*self.consumer_dev.lock() = None;
|
||||
|
||||
// Gadget cleanup is handled by OtgService, not here
|
||||
|
||||
@@ -762,6 +835,10 @@ impl HidBackend for OtgBackend {
|
||||
self.mouse_abs_path.exists()
|
||||
}
|
||||
|
||||
async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
|
||||
self.send_consumer_report(event.usage)
|
||||
}
|
||||
|
||||
fn screen_resolution(&self) -> Option<(u32, u32)> {
|
||||
*self.screen_resolution.read()
|
||||
}
|
||||
@@ -789,6 +866,7 @@ impl Drop for OtgBackend {
|
||||
*self.keyboard_dev.lock() = None;
|
||||
*self.mouse_rel_dev.lock() = None;
|
||||
*self.mouse_abs_dev.lock() = None;
|
||||
*self.consumer_dev.lock() = None;
|
||||
debug!("OtgBackend dropped, device files closed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +255,14 @@ impl MouseEvent {
|
||||
pub enum HidEvent {
|
||||
Keyboard(KeyboardEvent),
|
||||
Mouse(MouseEvent),
|
||||
Consumer(ConsumerEvent),
|
||||
}
|
||||
|
||||
/// Consumer control event (multimedia keys)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConsumerEvent {
|
||||
/// Consumer control usage code (e.g., 0x00CD for Play/Pause)
|
||||
pub usage: u16,
|
||||
}
|
||||
|
||||
/// USB HID keyboard report (8 bytes)
|
||||
|
||||
@@ -122,6 +122,13 @@ async fn handle_binary_message(data: &[u8], state: &AppState) -> Result<(), Stri
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
HidChannelEvent::Consumer(consumer_event) => {
|
||||
state
|
||||
.hid
|
||||
.send_consumer(consumer_event)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -5,14 +5,14 @@ use tracing::debug;
|
||||
|
||||
use super::configfs::{create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file};
|
||||
use super::function::{FunctionMeta, GadgetFunction};
|
||||
use super::report_desc::{KEYBOARD_WITH_LED, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
|
||||
use super::report_desc::{CONSUMER_CONTROL, KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
|
||||
use crate::error::Result;
|
||||
|
||||
/// HID function type
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HidFunctionType {
|
||||
/// Keyboard with LED feedback support
|
||||
/// Uses 2 endpoints: IN (reports) + OUT (LED status)
|
||||
/// Keyboard (no LED feedback)
|
||||
/// Uses 1 endpoint: IN
|
||||
Keyboard,
|
||||
/// Relative mouse (traditional mouse movement)
|
||||
/// Uses 1 endpoint: IN
|
||||
@@ -20,33 +20,39 @@ pub enum HidFunctionType {
|
||||
/// Absolute mouse (touchscreen-like positioning)
|
||||
/// Uses 1 endpoint: IN
|
||||
MouseAbsolute,
|
||||
/// Consumer control (multimedia keys)
|
||||
/// Uses 1 endpoint: IN
|
||||
ConsumerControl,
|
||||
}
|
||||
|
||||
impl HidFunctionType {
|
||||
/// Get endpoints required for this function type
|
||||
pub fn endpoints(&self) -> u8 {
|
||||
match self {
|
||||
HidFunctionType::Keyboard => 2, // IN + OUT for LED
|
||||
HidFunctionType::Keyboard => 1,
|
||||
HidFunctionType::MouseRelative => 1,
|
||||
HidFunctionType::MouseAbsolute => 1,
|
||||
HidFunctionType::ConsumerControl => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get HID protocol
|
||||
pub fn protocol(&self) -> u8 {
|
||||
match self {
|
||||
HidFunctionType::Keyboard => 1, // Keyboard
|
||||
HidFunctionType::MouseRelative => 2, // Mouse
|
||||
HidFunctionType::MouseAbsolute => 2, // Mouse
|
||||
HidFunctionType::Keyboard => 1, // Keyboard
|
||||
HidFunctionType::MouseRelative => 2, // Mouse
|
||||
HidFunctionType::MouseAbsolute => 2, // Mouse
|
||||
HidFunctionType::ConsumerControl => 0, // None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get HID subclass
|
||||
pub fn subclass(&self) -> u8 {
|
||||
match self {
|
||||
HidFunctionType::Keyboard => 1, // Boot interface
|
||||
HidFunctionType::MouseRelative => 1, // Boot interface
|
||||
HidFunctionType::MouseAbsolute => 0, // No boot interface (absolute not in boot protocol)
|
||||
HidFunctionType::Keyboard => 1, // Boot interface
|
||||
HidFunctionType::MouseRelative => 1, // Boot interface
|
||||
HidFunctionType::MouseAbsolute => 0, // No boot interface
|
||||
HidFunctionType::ConsumerControl => 0, // No boot interface
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,15 +62,17 @@ impl HidFunctionType {
|
||||
HidFunctionType::Keyboard => 8,
|
||||
HidFunctionType::MouseRelative => 4,
|
||||
HidFunctionType::MouseAbsolute => 6,
|
||||
HidFunctionType::ConsumerControl => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get report descriptor
|
||||
pub fn report_desc(&self) -> &'static [u8] {
|
||||
match self {
|
||||
HidFunctionType::Keyboard => KEYBOARD_WITH_LED,
|
||||
HidFunctionType::Keyboard => KEYBOARD,
|
||||
HidFunctionType::MouseRelative => MOUSE_RELATIVE,
|
||||
HidFunctionType::MouseAbsolute => MOUSE_ABSOLUTE,
|
||||
HidFunctionType::ConsumerControl => CONSUMER_CONTROL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +82,7 @@ impl HidFunctionType {
|
||||
HidFunctionType::Keyboard => "Keyboard",
|
||||
HidFunctionType::MouseRelative => "Relative Mouse",
|
||||
HidFunctionType::MouseAbsolute => "Absolute Mouse",
|
||||
HidFunctionType::ConsumerControl => "Consumer Control",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +126,15 @@ impl HidFunction {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a consumer control function
|
||||
pub fn consumer_control(instance: u8) -> Self {
|
||||
Self {
|
||||
instance,
|
||||
func_type: HidFunctionType::ConsumerControl,
|
||||
name: format!("hid.usb{}", instance),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get function path in gadget
|
||||
fn function_path(&self, gadget_path: &Path) -> PathBuf {
|
||||
gadget_path.join("functions").join(self.name())
|
||||
@@ -155,16 +173,6 @@ impl GadgetFunction for HidFunction {
|
||||
write_file(&func_path.join("subclass"), &self.func_type.subclass().to_string())?;
|
||||
write_file(&func_path.join("report_length"), &self.func_type.report_length().to_string())?;
|
||||
|
||||
// For keyboard, enable OUT endpoint for LED feedback
|
||||
// no_out_endpoint: 0 = enable OUT endpoint, 1 = disable
|
||||
if matches!(self.func_type, HidFunctionType::Keyboard) {
|
||||
let no_out_path = func_path.join("no_out_endpoint");
|
||||
if no_out_path.exists() || func_path.exists() {
|
||||
// Try to write, ignore error if file doesn't exist yet
|
||||
let _ = write_file(&no_out_path, "0");
|
||||
}
|
||||
}
|
||||
|
||||
// Write report descriptor
|
||||
write_bytes(&func_path.join("report_desc"), self.func_type.report_desc())?;
|
||||
|
||||
@@ -205,7 +213,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_hid_function_types() {
|
||||
assert_eq!(HidFunctionType::Keyboard.endpoints(), 2);
|
||||
assert_eq!(HidFunctionType::Keyboard.endpoints(), 1);
|
||||
assert_eq!(HidFunctionType::MouseRelative.endpoints(), 1);
|
||||
assert_eq!(HidFunctionType::MouseAbsolute.endpoints(), 1);
|
||||
|
||||
|
||||
@@ -118,6 +118,15 @@ impl OtgGadgetManager {
|
||||
Ok(device_path)
|
||||
}
|
||||
|
||||
/// Add consumer control function (multimedia keys)
|
||||
pub fn add_consumer_control(&mut self) -> Result<PathBuf> {
|
||||
let func = HidFunction::consumer_control(self.hid_instance);
|
||||
let device_path = func.device_path();
|
||||
self.add_function(Box::new(func))?;
|
||||
self.hid_instance += 1;
|
||||
Ok(device_path)
|
||||
}
|
||||
|
||||
/// Add MSD function (returns MsdFunction handle for LUN configuration)
|
||||
pub fn add_msd(&mut self) -> Result<MsdFunction> {
|
||||
let func = MsdFunction::new(self.msd_instance);
|
||||
|
||||
@@ -31,5 +31,5 @@ pub use function::{FunctionMeta, GadgetFunction};
|
||||
pub use hid::{HidFunction, HidFunctionType};
|
||||
pub use manager::{wait_for_hid_devices, OtgGadgetManager};
|
||||
pub use msd::{MsdFunction, MsdLunConfig};
|
||||
pub use report_desc::{KEYBOARD_WITH_LED, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
|
||||
pub use report_desc::{KEYBOARD, MOUSE_ABSOLUTE, MOUSE_RELATIVE};
|
||||
pub use service::{HidDevicePaths, OtgService, OtgServiceState};
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
//! HID Report Descriptors
|
||||
|
||||
/// Keyboard HID Report Descriptor with LED output support
|
||||
/// Keyboard HID Report Descriptor (no LED output - saves 1 endpoint)
|
||||
/// Report format (8 bytes input):
|
||||
/// [0] Modifier keys (8 bits)
|
||||
/// [1] Reserved
|
||||
/// [2-7] Key codes (6 keys)
|
||||
/// LED output (1 byte):
|
||||
/// Bit 0: Num Lock
|
||||
/// Bit 1: Caps Lock
|
||||
/// Bit 2: Scroll Lock
|
||||
/// Bit 3: Compose
|
||||
/// Bit 4: Kana
|
||||
pub const KEYBOARD_WITH_LED: &[u8] = &[
|
||||
pub const KEYBOARD: &[u8] = &[
|
||||
0x05, 0x01, // Usage Page (Generic Desktop)
|
||||
0x09, 0x06, // Usage (Keyboard)
|
||||
0xA1, 0x01, // Collection (Application)
|
||||
@@ -28,17 +22,6 @@ pub const KEYBOARD_WITH_LED: &[u8] = &[
|
||||
0x95, 0x01, // Report Count (1)
|
||||
0x75, 0x08, // Report Size (8)
|
||||
0x81, 0x01, // Input (Constant) - Reserved byte
|
||||
// LED output (5 bits)
|
||||
0x95, 0x05, // Report Count (5)
|
||||
0x75, 0x01, // Report Size (1)
|
||||
0x05, 0x08, // Usage Page (LEDs)
|
||||
0x19, 0x01, // Usage Minimum (1) - Num Lock
|
||||
0x29, 0x05, // Usage Maximum (5) - Kana
|
||||
0x91, 0x02, // Output (Data, Variable, Absolute) - LED bits
|
||||
// LED padding (3 bits)
|
||||
0x95, 0x01, // Report Count (1)
|
||||
0x75, 0x03, // Report Size (3)
|
||||
0x91, 0x01, // Output (Constant) - Padding
|
||||
// Key array (6 bytes)
|
||||
0x95, 0x06, // Report Count (6)
|
||||
0x75, 0x08, // Report Size (8)
|
||||
@@ -147,14 +130,33 @@ pub const MOUSE_ABSOLUTE: &[u8] = &[
|
||||
0xC0, // End Collection
|
||||
];
|
||||
|
||||
/// Consumer Control HID Report Descriptor (2 bytes report)
|
||||
/// Report format:
|
||||
/// [0-1] Consumer Control Usage (16-bit little-endian)
|
||||
/// Supports: Play/Pause, Stop, Next/Prev Track, Mute, Volume Up/Down, etc.
|
||||
pub const CONSUMER_CONTROL: &[u8] = &[
|
||||
0x05, 0x0C, // Usage Page (Consumer)
|
||||
0x09, 0x01, // Usage (Consumer Control)
|
||||
0xA1, 0x01, // Collection (Application)
|
||||
0x15, 0x00, // Logical Minimum (0)
|
||||
0x26, 0xFF, 0x03, // Logical Maximum (1023)
|
||||
0x19, 0x00, // Usage Minimum (0)
|
||||
0x2A, 0xFF, 0x03, // Usage Maximum (1023)
|
||||
0x75, 0x10, // Report Size (16)
|
||||
0x95, 0x01, // Report Count (1)
|
||||
0x81, 0x00, // Input (Data, Array)
|
||||
0xC0, // End Collection
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_report_descriptor_sizes() {
|
||||
assert!(!KEYBOARD_WITH_LED.is_empty());
|
||||
assert!(!KEYBOARD.is_empty());
|
||||
assert!(!MOUSE_RELATIVE.is_empty());
|
||||
assert!(!MOUSE_ABSOLUTE.is_empty());
|
||||
assert!(!CONSUMER_CONTROL.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ pub struct HidDevicePaths {
|
||||
pub keyboard: PathBuf,
|
||||
pub mouse_relative: PathBuf,
|
||||
pub mouse_absolute: PathBuf,
|
||||
pub consumer: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for HidDevicePaths {
|
||||
@@ -47,6 +48,7 @@ impl Default for HidDevicePaths {
|
||||
keyboard: PathBuf::from("/dev/hidg0"),
|
||||
mouse_relative: PathBuf::from("/dev/hidg1"),
|
||||
mouse_absolute: PathBuf::from("/dev/hidg2"),
|
||||
consumer: Some(PathBuf::from("/dev/hidg3")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,16 +355,18 @@ impl OtgService {
|
||||
manager.add_keyboard(),
|
||||
manager.add_mouse_relative(),
|
||||
manager.add_mouse_absolute(),
|
||||
manager.add_consumer_control(),
|
||||
) {
|
||||
(Ok(kb), Ok(rel), Ok(abs)) => {
|
||||
(Ok(kb), Ok(rel), Ok(abs), Ok(consumer)) => {
|
||||
hid_paths = Some(HidDevicePaths {
|
||||
keyboard: kb,
|
||||
mouse_relative: rel,
|
||||
mouse_absolute: abs,
|
||||
consumer: Some(consumer),
|
||||
});
|
||||
debug!("HID functions added to gadget");
|
||||
}
|
||||
(Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => {
|
||||
(Err(e), _, _, _) | (_, Err(e), _, _) | (_, _, Err(e), _) | (_, _, _, Err(e)) => {
|
||||
let error = format!("Failed to add HID functions: {}", e);
|
||||
let mut state = self.state.write().await;
|
||||
state.error = Some(error.clone());
|
||||
|
||||
@@ -256,6 +256,11 @@ impl WsHidHandler {
|
||||
HidChannelEvent::Mouse(ms_event) => {
|
||||
hid.send_mouse(ms_event).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
HidChannelEvent::Consumer(consumer_event) => {
|
||||
hid.send_consumer(consumer_event)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
client.events_processed.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
@@ -226,6 +226,11 @@ impl PeerConnection {
|
||||
debug!("Failed to send mouse event: {}", e);
|
||||
}
|
||||
}
|
||||
HidChannelEvent::Consumer(consumer_event) => {
|
||||
if let Err(e) = hid.send_consumer(consumer_event).await {
|
||||
debug!("Failed to send consumer event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -437,6 +437,11 @@ impl UniversalSession {
|
||||
debug!("Failed to send mouse event: {}", e);
|
||||
}
|
||||
}
|
||||
HidChannelEvent::Consumer(consumer_event) => {
|
||||
if let Err(e) = hid.send_consumer(consumer_event).await {
|
||||
debug!("Failed to send consumer event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user