mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-11-01 10:20:50 +08:00
merge: resolve dev conflicts for CH9329 macOS drag compatibility
This commit is contained in:
@@ -18,6 +18,9 @@ fn default_ch9329_baud_rate() -> u32 {
|
||||
#[derive(Default)]
|
||||
pub enum HidBackendType {
|
||||
Otg,
|
||||
Bluetooth {
|
||||
config: crate::config::BluetoothHidConfig,
|
||||
},
|
||||
Ch9329 {
|
||||
port: String,
|
||||
#[serde(default = "default_ch9329_baud_rate")]
|
||||
@@ -35,6 +38,7 @@ impl HidBackendType {
|
||||
pub fn name_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Otg => "otg",
|
||||
Self::Bluetooth { .. } => "bluetooth",
|
||||
Self::Ch9329 { .. } => "ch9329",
|
||||
Self::None => "none",
|
||||
}
|
||||
@@ -83,6 +87,17 @@ pub trait HidBackend: Send + Sync {
|
||||
))
|
||||
}
|
||||
|
||||
async fn bluetooth_status(&self) -> Result<serde_json::Value> {
|
||||
Err(crate::error::AppError::BadRequest(
|
||||
"Bluetooth HID is not active".into(),
|
||||
))
|
||||
}
|
||||
async fn bluetooth_action(&self, _action: &str, _seconds: u32) -> Result<()> {
|
||||
Err(crate::error::AppError::BadRequest(
|
||||
"Bluetooth HID is not active".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn reset(&self) -> Result<()>;
|
||||
|
||||
async fn prepare_rebuild(&self) -> Result<()> {
|
||||
|
||||
283
src/hid/bluetooth.rs
Normal file
283
src/hid/bluetooth.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
//! Translate canonical One-KVM input into the native BlueZ peripheral.
|
||||
use super::{
|
||||
backend::{HidBackend, HidBackendRuntimeSnapshot},
|
||||
types::{
|
||||
ConsumerEvent, KeyEventType, KeyboardEvent, KeyboardReport, MouseEvent, MouseEventType,
|
||||
},
|
||||
};
|
||||
use crate::{
|
||||
config::BluetoothHidConfig,
|
||||
error::{AppError, Result},
|
||||
events::LedState,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use one_kvm_bluetooth_hid::{Action, Config, Peripheral, Report};
|
||||
use tokio::sync::{watch, Mutex};
|
||||
|
||||
fn error(message: String) -> AppError {
|
||||
AppError::HidError {
|
||||
backend: "bluetooth".into(),
|
||||
error_code: "bluetooth_error".into(),
|
||||
reason: message,
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct InputState {
|
||||
keyboard: KeyboardReport,
|
||||
buttons: u8,
|
||||
generation: u64,
|
||||
}
|
||||
pub struct BluetoothBackend {
|
||||
peripheral: Peripheral,
|
||||
input: Mutex<InputState>,
|
||||
runtime: watch::Sender<()>,
|
||||
worker: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
impl BluetoothBackend {
|
||||
pub fn new(
|
||||
config: BluetoothHidConfig,
|
||||
bonds: Option<std::sync::Arc<dyn one_kvm_bluetooth_hid::bonds::BondStore>>,
|
||||
) -> Result<Self> {
|
||||
let peripheral = Peripheral::start_with_store(
|
||||
Config {
|
||||
adapter: config.adapter,
|
||||
name: config.name,
|
||||
peer: config.peer,
|
||||
},
|
||||
bonds,
|
||||
)
|
||||
.map_err(error)?;
|
||||
let (runtime, _) = watch::channel(());
|
||||
Ok(Self {
|
||||
peripheral,
|
||||
input: Mutex::new(InputState::default()),
|
||||
runtime,
|
||||
worker: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
fn check(&self, input: &mut InputState) -> Result<()> {
|
||||
let status = self.peripheral.status();
|
||||
if input.generation != status.generation || !status.ready {
|
||||
*input = InputState {
|
||||
generation: status.generation,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if !status.ready {
|
||||
return Err(error(status.error.unwrap_or_else(|| {
|
||||
"Pair and connect a computer; HID reports are not ready".into()
|
||||
})));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl HidBackend for BluetoothBackend {
|
||||
async fn init(&self) -> Result<()> {
|
||||
let mut status = self.peripheral.subscribe();
|
||||
let runtime = self.runtime.clone();
|
||||
*self.worker.lock().await = Some(tokio::spawn(async move {
|
||||
let mut last_error = None;
|
||||
while status.changed().await.is_ok() {
|
||||
let error = status.borrow_and_update().error.clone();
|
||||
if error != last_error {
|
||||
if let Some(reason) = &error {
|
||||
tracing::warn!(%reason, "Bluetooth HID unavailable");
|
||||
}
|
||||
last_error = error;
|
||||
}
|
||||
runtime.send_replace(());
|
||||
}
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
|
||||
let mut input = self.input.lock().await;
|
||||
self.check(&mut input)?;
|
||||
apply_key(&mut input.keyboard, &event);
|
||||
let result = self
|
||||
.peripheral
|
||||
.send(Report::Keyboard, input.keyboard.to_bytes().to_vec())
|
||||
.await
|
||||
.map_err(error);
|
||||
if result.is_err() {
|
||||
*input = InputState::default();
|
||||
}
|
||||
result
|
||||
}
|
||||
async fn send_mouse(&self, event: MouseEvent) -> Result<()> {
|
||||
let mut input = self.input.lock().await;
|
||||
self.check(&mut input)?;
|
||||
let (mut x, mut y, wheel) = match event.event_type {
|
||||
MouseEventType::Move => (event.x, event.y, 0),
|
||||
MouseEventType::MoveAbs => {
|
||||
return Err(AppError::BadRequest(
|
||||
"Bluetooth HID supports relative mouse only".into(),
|
||||
))
|
||||
}
|
||||
MouseEventType::Down | MouseEventType::Up => {
|
||||
if let Some(button) = event.button {
|
||||
let bit = button.to_hid_bit();
|
||||
if event.event_type == MouseEventType::Down {
|
||||
input.buttons |= bit;
|
||||
} else {
|
||||
input.buttons &= !bit;
|
||||
}
|
||||
}
|
||||
(0, 0, 0)
|
||||
}
|
||||
MouseEventType::Scroll => (0, 0, event.scroll),
|
||||
};
|
||||
// Bound malformed remote input without silently clipping normal relative movements.
|
||||
if x.unsigned_abs() > 32767 || y.unsigned_abs() > 32767 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Relative mouse displacement too large".into(),
|
||||
));
|
||||
}
|
||||
loop {
|
||||
let dx = x.clamp(-127, 127);
|
||||
let dy = y.clamp(-127, 127);
|
||||
self.peripheral
|
||||
.send(
|
||||
Report::Mouse,
|
||||
vec![input.buttons, dx as i8 as u8, dy as i8 as u8, wheel as u8],
|
||||
)
|
||||
.await
|
||||
.map_err(error)?;
|
||||
x -= dx;
|
||||
y -= dy;
|
||||
if x == 0 && y == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn send_consumer(&self, event: ConsumerEvent) -> Result<()> {
|
||||
let mut input = self.input.lock().await;
|
||||
self.check(&mut input)?;
|
||||
if event.usage > 0x3ff {
|
||||
return Err(AppError::BadRequest(
|
||||
"Consumer usage exceeds Bluetooth report range".into(),
|
||||
));
|
||||
}
|
||||
self.peripheral
|
||||
.send(Report::Consumer, event.usage.to_le_bytes().to_vec())
|
||||
.await
|
||||
.map_err(error)
|
||||
}
|
||||
async fn reset(&self) -> Result<()> {
|
||||
*self.input.lock().await = InputState::default();
|
||||
self.peripheral.action(Action::Reset).await.map_err(error)
|
||||
}
|
||||
async fn shutdown(&self) -> Result<()> {
|
||||
let result = self.peripheral.shutdown().await.map_err(error);
|
||||
if let Some(worker) = self.worker.lock().await.take() {
|
||||
worker.abort();
|
||||
}
|
||||
result
|
||||
}
|
||||
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
|
||||
let state = self.peripheral.status();
|
||||
HidBackendRuntimeSnapshot {
|
||||
initialized: state.initialized,
|
||||
online: state.ready,
|
||||
supports_absolute_mouse: false,
|
||||
keyboard_leds_enabled: true,
|
||||
led_state: LedState {
|
||||
num_lock: state.leds & 1 != 0,
|
||||
caps_lock: state.leds & 2 != 0,
|
||||
scroll_lock: state.leds & 4 != 0,
|
||||
compose: state.leds & 8 != 0,
|
||||
kana: state.leds & 16 != 0,
|
||||
},
|
||||
device: Some(state.peer.unwrap_or(state.adapter)),
|
||||
screen_resolution: None,
|
||||
error_code: state.error.as_ref().map(|_| "bluetooth_error".into()),
|
||||
error: state.error,
|
||||
}
|
||||
}
|
||||
fn subscribe_runtime(&self) -> watch::Receiver<()> {
|
||||
self.runtime.subscribe()
|
||||
}
|
||||
async fn bluetooth_status(&self) -> Result<serde_json::Value> {
|
||||
serde_json::to_value(self.peripheral.status()).map_err(|e| error(e.to_string()))
|
||||
}
|
||||
async fn bluetooth_action(&self, action: &str, seconds: u32) -> Result<()> {
|
||||
if action == "pair" && !(10..=300).contains(&seconds) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Pairing window must be 10–300 seconds".into(),
|
||||
));
|
||||
}
|
||||
let action = match action {
|
||||
"pair" => Action::Pair(seconds),
|
||||
"close" => Action::ClosePairing,
|
||||
"forget" => Action::Forget,
|
||||
"disconnect" => Action::Disconnect,
|
||||
_ => return Err(AppError::BadRequest("Unknown Bluetooth action".into())),
|
||||
};
|
||||
self.peripheral.action(action).await.map_err(error)
|
||||
}
|
||||
}
|
||||
fn apply_key(report: &mut KeyboardReport, event: &KeyboardEvent) {
|
||||
if let Some(bit) = event.key.modifier_bit() {
|
||||
match event.event_type {
|
||||
KeyEventType::Down => report.modifiers |= bit,
|
||||
KeyEventType::Up => report.modifiers &= !bit,
|
||||
}
|
||||
} else {
|
||||
report.modifiers = event.modifiers.to_hid_byte();
|
||||
let usage = event.key.to_hid_usage();
|
||||
match event.event_type {
|
||||
KeyEventType::Down if !report.keys.contains(&usage) => {
|
||||
report.add_key(usage);
|
||||
}
|
||||
KeyEventType::Up => report.remove_key(usage),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::hid::{CanonicalKey, KeyboardModifiers};
|
||||
#[test]
|
||||
fn repeated_keydown_does_not_leave_stuck_keys() {
|
||||
let mut report = KeyboardReport::default();
|
||||
let event = KeyboardEvent {
|
||||
key: CanonicalKey::KeyA,
|
||||
event_type: KeyEventType::Down,
|
||||
modifiers: KeyboardModifiers::default(),
|
||||
};
|
||||
apply_key(&mut report, &event);
|
||||
apply_key(&mut report, &event);
|
||||
assert_eq!(report.keys.iter().filter(|&&key| key == 4).count(), 1);
|
||||
apply_key(
|
||||
&mut report,
|
||||
&KeyboardEvent {
|
||||
event_type: KeyEventType::Up,
|
||||
..event
|
||||
},
|
||||
);
|
||||
assert_eq!(report.to_bytes(), [0; 8]);
|
||||
}
|
||||
#[test]
|
||||
fn modifier_press_and_release() {
|
||||
let mut report = KeyboardReport::default();
|
||||
let event = KeyboardEvent {
|
||||
key: CanonicalKey::ShiftRight,
|
||||
event_type: KeyEventType::Down,
|
||||
modifiers: KeyboardModifiers::default(),
|
||||
};
|
||||
apply_key(&mut report, &event);
|
||||
assert_eq!(report.modifiers, 0x20);
|
||||
apply_key(
|
||||
&mut report,
|
||||
&KeyboardEvent {
|
||||
event_type: KeyEventType::Up,
|
||||
..event
|
||||
},
|
||||
);
|
||||
assert_eq!(report.modifiers, 0);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,10 @@ const PARAM_CFG_VID_PID_OFFSET: usize = 11;
|
||||
const PARAM_CFG_STRING_FLAGS_OFFSET: usize = 36;
|
||||
const DESCRIPTOR_READ_RETRIES: usize = 3;
|
||||
const DESCRIPTOR_RETRY_DELAY_MS: u64 = 80;
|
||||
const DESCRIPTOR_APPLY_RESET_WAIT_MS: u64 = 3000;
|
||||
|
||||
// CH9329/CH9329F can take several seconds to restart after a descriptor update.
|
||||
const DESCRIPTOR_APPLY_RESET_WAIT_MS: u64 = 5000;
|
||||
|
||||
const USB_STRING_MAX_LEN: usize = 23;
|
||||
const USB_STRING_FLAG_ENABLE: u8 = 0x80;
|
||||
const USB_STRING_FLAG_MANUFACTURER: u8 = 0x04;
|
||||
@@ -379,33 +382,41 @@ impl Ch9329Backend {
|
||||
|
||||
Self::write_packet(port, address, cmd, data)?;
|
||||
|
||||
let mut pending = Vec::with_capacity(128);
|
||||
// Keep enough room for a full parameter response and adjacent packets.
|
||||
let mut pending = Vec::with_capacity(256);
|
||||
let deadline = Instant::now() + Duration::from_millis(RESPONSE_TIMEOUT_MS);
|
||||
let expected_ok = expected_response_cmd(cmd, false);
|
||||
let expected_err = expected_response_cmd(cmd, true);
|
||||
|
||||
loop {
|
||||
let mut chunk = [0u8; 128];
|
||||
let mut chunk = [0u8; 256];
|
||||
match port.read(&mut chunk) {
|
||||
Ok(n) if n > 0 => {
|
||||
pending.extend_from_slice(&chunk[..n]);
|
||||
|
||||
// Drain every complete frame so adjacent/out-of-order responses
|
||||
// cannot block the response for the current command.
|
||||
while let Some((response, consumed)) = try_extract_response(&pending) {
|
||||
let current_response_cmd = response.cmd;
|
||||
pending.drain(..consumed);
|
||||
if response.cmd == expected_ok || response.cmd == expected_err {
|
||||
|
||||
if current_response_cmd == expected_ok
|
||||
|| current_response_cmd == expected_err
|
||||
{
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
trace!(
|
||||
"CH9329 ignored out-of-order response: expected 0x{:02X}/0x{:02X}, got 0x{:02X}",
|
||||
"CH9329 filtered an overlapping packet: expected 0x{:02X}/0x{:02X}, bypass 0x{:02X}",
|
||||
expected_ok,
|
||||
expected_err,
|
||||
response.cmd
|
||||
current_response_cmd
|
||||
);
|
||||
}
|
||||
|
||||
// Bound memory use if a noisy or disconnected port keeps delivering bytes.
|
||||
if pending.len() > MAX_PACKET_SIZE * 4 {
|
||||
let keep = MAX_PACKET_SIZE;
|
||||
let keep = MAX_PACKET_SIZE * 2;
|
||||
pending.drain(..pending.len().saturating_sub(keep));
|
||||
}
|
||||
}
|
||||
@@ -421,15 +432,19 @@ impl Ch9329Backend {
|
||||
|
||||
if Instant::now() >= deadline {
|
||||
return Err(Self::backend_error(
|
||||
format!("No matching response from CH9329 for cmd 0x{:02X}", cmd),
|
||||
format!(
|
||||
"No matching response from CH9329 for cmd 0x{:02X}. Remaining buffer: {}",
|
||||
cmd,
|
||||
Self::hex_bytes(&pending)
|
||||
),
|
||||
"no_response",
|
||||
));
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
// Give the serial driver a short opportunity to deliver the next chunk.
|
||||
thread::sleep(Duration::from_micros(200));
|
||||
}
|
||||
}
|
||||
|
||||
fn try_best_effort_reset(port: &mut dyn serialport::SerialPort, address: u8) {
|
||||
if let Err(err) = Self::write_packet(port, address, cmd::RESET, &[]) {
|
||||
trace!("CH9329 best-effort reset failed: {}", err);
|
||||
@@ -709,7 +724,6 @@ impl Ch9329Backend {
|
||||
let mut port = Self::open_port(port_path, baud_rate)?;
|
||||
Self::read_device_descriptor_on_port(port.as_mut(), DEFAULT_ADDR)
|
||||
}
|
||||
|
||||
fn open_ready_port(
|
||||
port_path: &str,
|
||||
baud_rate: u32,
|
||||
@@ -880,7 +894,7 @@ impl Ch9329Backend {
|
||||
match Self::open_ready_port(port_path, baud_rate, address) {
|
||||
Ok((port, info)) => {
|
||||
info!(
|
||||
"CH9329 reconnected: {}, USB: {}",
|
||||
"CH9329-compatible chip reconnected: {}, USB: {}",
|
||||
info.version,
|
||||
if info.usb_connected {
|
||||
"connected"
|
||||
@@ -903,7 +917,6 @@ impl Ch9329Backend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn recover_worker_port(
|
||||
mut port: Box<dyn serialport::SerialPort>,
|
||||
rx: &mpsc::Receiver<WorkerCommand>,
|
||||
@@ -1196,7 +1209,7 @@ impl HidBackend for Ch9329Backend {
|
||||
match init_rx.recv_timeout(Duration::from_millis(INIT_WAIT_MS)) {
|
||||
Ok(Ok(info)) => {
|
||||
info!(
|
||||
"CH9329 chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}",
|
||||
"CH9329-compatible chip detected: {}, USB: {}, LEDs: NumLock={}, CapsLock={}, ScrollLock={}",
|
||||
info.version,
|
||||
if info.usb_connected {
|
||||
"connected"
|
||||
@@ -1215,13 +1228,13 @@ impl HidBackend for Ch9329Backend {
|
||||
Ok(Err(err)) => {
|
||||
self.record_error(
|
||||
format!(
|
||||
"CH9329 not responding on {} @ {} baud: {}",
|
||||
"CH9329-compatible chip not responding on {} @ {} baud: {}",
|
||||
self.port_path, self.baud_rate, err
|
||||
),
|
||||
"init_failed",
|
||||
);
|
||||
warn!(
|
||||
"CH9329 not responding on {} @ {} baud, retrying in background: {}",
|
||||
"CH9329-compatible chip not responding on {} @ {} baud, retrying in background: {}",
|
||||
self.port_path, self.baud_rate, err
|
||||
);
|
||||
*self.worker_tx.lock() = Some(tx);
|
||||
@@ -1231,9 +1244,12 @@ impl HidBackend for Ch9329Backend {
|
||||
Err(_) => {
|
||||
let _ = tx.send(WorkerCommand::Shutdown);
|
||||
let _ = handle.join();
|
||||
self.record_error("Timed out waiting for CH9329 worker init", "init_timeout");
|
||||
self.record_error(
|
||||
"Timed out waiting for CH9329-compatible worker init",
|
||||
"init_timeout",
|
||||
);
|
||||
Err(AppError::Internal(
|
||||
"Timed out waiting for CH9329 initialization".to_string(),
|
||||
"Timed out waiting for CH9329-compatible initialization".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub const DEFAULT_ADDR: u8 = 0x00;
|
||||
pub const DEFAULT_BAUD_RATE: u32 = 9600;
|
||||
pub const MAX_DATA_LEN: usize = 64;
|
||||
pub const MAX_PACKET_SIZE: usize = 70;
|
||||
const EXTENDED_PARAMETER_RESPONSE_SIZES: [usize; 2] = [72, 88];
|
||||
|
||||
pub mod cmd {
|
||||
pub const GET_INFO: u8 = 0x01;
|
||||
@@ -130,7 +131,8 @@ impl Response {
|
||||
|
||||
let cmd = bytes[3];
|
||||
let len = bytes[4] as usize;
|
||||
if bytes.len() < 5 + len + 1 {
|
||||
let expected_frame_len = 6 + len;
|
||||
if bytes.len() < expected_frame_len {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -139,7 +141,7 @@ impl Response {
|
||||
.iter()
|
||||
.fold(0u8, |acc, &x| acc.wrapping_add(x));
|
||||
if expected_checksum != calculated_checksum {
|
||||
tracing::warn!(
|
||||
tracing::debug!(
|
||||
"CH9329 checksum mismatch: expected {:02X}, got {:02X}",
|
||||
expected_checksum,
|
||||
calculated_checksum
|
||||
@@ -215,6 +217,11 @@ pub fn try_extract_response(buffer: &[u8]) -> Option<(Response, usize)> {
|
||||
}
|
||||
|
||||
let len = buffer[offset + 4] as usize;
|
||||
if len > MAX_DATA_LEN {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let frame_len = 6 + len;
|
||||
if offset + frame_len > buffer.len() {
|
||||
return None;
|
||||
@@ -225,8 +232,103 @@ pub fn try_extract_response(buffer: &[u8]) -> Option<(Response, usize)> {
|
||||
return Some((response, offset + frame_len));
|
||||
}
|
||||
|
||||
// Some CH9329F firmware appends reserved bytes to GET_PARA_CFG while
|
||||
// retaining the protocol LEN value of 50. Locate and validate the real
|
||||
// checksum, return the documented 50-byte payload, and consume the
|
||||
// complete extended frame. Other commands keep strict framing.
|
||||
let cmd = buffer[offset + 3];
|
||||
let data_start = offset + 5;
|
||||
let parameter_payload_is_plausible = cmd == expected_response_cmd(cmd::GET_PARA_CFG, false)
|
||||
&& len == 50
|
||||
&& matches!(buffer[data_start], 0x00..=0x03 | 0x80..=0x83)
|
||||
&& matches!(buffer[data_start + 1], 0x00..=0x02 | 0x80..=0x82);
|
||||
if parameter_payload_is_plausible {
|
||||
for extended_size in EXTENDED_PARAMETER_RESPONSE_SIZES {
|
||||
let extended_end = offset + extended_size;
|
||||
if buffer.len() >= extended_end {
|
||||
let checksum_index = extended_end - 1;
|
||||
if calculate_checksum(&buffer[offset..checksum_index]) != buffer[checksum_index]
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Some((
|
||||
Response {
|
||||
cmd,
|
||||
data: buffer[data_start..data_start + len].to_vec(),
|
||||
is_error: false,
|
||||
error_code: None,
|
||||
},
|
||||
extended_end,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if buffer.len() < offset + EXTENDED_PARAMETER_RESPONSE_SIZES[1] {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_standard_response_and_checksum() {
|
||||
let frame = build_packet(DEFAULT_ADDR, 0x81, &[0x30, 0x01, 0x00, 0, 0, 0, 0, 0]);
|
||||
let response = Response::parse(&frame).expect("valid response");
|
||||
assert_eq!(response.cmd, 0x81);
|
||||
assert_eq!(response.data, vec![0x30, 0x01, 0x00, 0, 0, 0, 0, 0]);
|
||||
assert!(!response.is_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_extended_parameter_response_with_valid_trailing_checksum() {
|
||||
let payload = [
|
||||
0x80, 0x80, 0x00, 0x00, 0x00, 0x25, 0x80, 0x08, 0x00, 0x00, 0x03, 0x86, 0x1A, 0x2A,
|
||||
0xE1, 0x00, 0x00, 0x00, 0x01, 0x00, 0x0D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
for reserved_len in [16, 32] {
|
||||
let mut frame = vec![0x57, 0xAB, DEFAULT_ADDR, 0x88, 50];
|
||||
frame.extend_from_slice(&payload);
|
||||
frame.extend_from_slice(&vec![0; reserved_len]);
|
||||
frame.push(calculate_checksum(&frame));
|
||||
|
||||
assert!(Response::parse(&frame[..56]).is_none());
|
||||
let (response, consumed) = try_extract_response(&frame).expect("extended response");
|
||||
assert_eq!(response.cmd, 0x88);
|
||||
assert_eq!(response.data, payload);
|
||||
assert!(!response.is_error);
|
||||
assert_eq!(consumed, frame.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_checksum_for_other_commands() {
|
||||
let mut frame = build_packet(DEFAULT_ADDR, 0x89, &[0x00; 50]);
|
||||
*frame.last_mut().unwrap() ^= 0xFF;
|
||||
assert!(Response::parse(&frame).is_none());
|
||||
assert!(try_extract_response(&frame).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_noise_and_adjacent_packets() {
|
||||
let first = build_packet(DEFAULT_ADDR, 0x81, &[0x30, 0x01, 0, 0, 0, 0, 0, 0]);
|
||||
let second = build_packet(DEFAULT_ADDR, 0x82, &[0x00]);
|
||||
let mut buffer = vec![0x00, 0xFF];
|
||||
buffer.extend_from_slice(&first);
|
||||
buffer.extend_from_slice(&second);
|
||||
|
||||
let (_, consumed) = try_extract_response(&buffer).expect("first response");
|
||||
assert_eq!(consumed, 2 + first.len());
|
||||
let (response, _) = try_extract_response(&buffer[consumed..]).expect("second response");
|
||||
assert_eq!(response.cmd, 0x82);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use crate::error::{AppError, Result};
|
||||
use crate::otg::OtgService;
|
||||
|
||||
pub struct HidBackendFactory {
|
||||
#[cfg(target_os = "linux")]
|
||||
pub bonds: std::sync::OnceLock<Arc<dyn one_kvm_bluetooth_hid::bonds::BondStore>>,
|
||||
#[cfg(unix)]
|
||||
otg_service: Option<Arc<OtgService>>,
|
||||
}
|
||||
@@ -15,7 +17,11 @@ pub struct HidBackendFactory {
|
||||
impl HidBackendFactory {
|
||||
#[cfg(unix)]
|
||||
pub fn new(otg_service: Option<Arc<OtgService>>) -> Self {
|
||||
Self { otg_service }
|
||||
Self {
|
||||
otg_service,
|
||||
#[cfg(target_os = "linux")]
|
||||
bonds: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
@@ -58,6 +64,22 @@ impl HidBackendFactory {
|
||||
)?,
|
||||
)))
|
||||
}
|
||||
HidBackendType::Bluetooth { config } => {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Ok(Some(Arc::new(super::bluetooth::BluetoothBackend::new(
|
||||
config.clone(),
|
||||
self.bonds.get().cloned(),
|
||||
)?)))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = config;
|
||||
Err(AppError::Config(
|
||||
"Bluetooth HID requires Linux and BlueZ".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
HidBackendType::None => {
|
||||
warn!("HID backend disabled");
|
||||
Ok(None)
|
||||
|
||||
161
src/hid/mod.rs
161
src/hid/mod.rs
@@ -1,6 +1,8 @@
|
||||
//! HID path: browser (WebSocket or WebRTC DataChannel) → queue → OTG gadget or CH9329.
|
||||
|
||||
pub mod backend;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod bluetooth;
|
||||
pub mod ch9329;
|
||||
mod ch9329_proto;
|
||||
pub mod consumer;
|
||||
@@ -132,6 +134,7 @@ pub struct HidController {
|
||||
hid_worker: Mutex<Option<JoinHandle<()>>>,
|
||||
runtime_worker: Mutex<Option<JoinHandle<()>>>,
|
||||
backend_available: Arc<AtomicBool>,
|
||||
reset_requested: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl HidController {
|
||||
@@ -153,6 +156,7 @@ impl HidController {
|
||||
hid_worker: Mutex::new(None),
|
||||
runtime_worker: Mutex::new(None),
|
||||
backend_available: Arc::new(AtomicBool::new(false)),
|
||||
reset_requested: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,9 +178,15 @@ impl HidController {
|
||||
hid_worker: Mutex::new(None),
|
||||
runtime_worker: Mutex::new(None),
|
||||
backend_available: Arc::new(AtomicBool::new(false)),
|
||||
reset_requested: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn set_bond_store(&self, store: crate::db::hid_bonds::HidBondStore) {
|
||||
let _ = self.backend_factory.bonds.set(Arc::new(store));
|
||||
}
|
||||
|
||||
pub async fn set_event_bus(&self, events: Arc<EventBus>) {
|
||||
*self.events.write().await = Some(events);
|
||||
}
|
||||
@@ -293,6 +303,25 @@ impl HidController {
|
||||
self.enqueue_event(QueuedHidEvent::Consumer(event)).await
|
||||
}
|
||||
|
||||
pub async fn bluetooth_status(&self) -> Result<serde_json::Value> {
|
||||
let backend = self
|
||||
.backend
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::BadRequest("HID unavailable".into()))?;
|
||||
backend.bluetooth_status().await
|
||||
}
|
||||
pub async fn bluetooth_action(&self, action: &str, seconds: u32) -> Result<()> {
|
||||
let backend = self
|
||||
.backend
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::BadRequest("HID unavailable".into()))?;
|
||||
backend.bluetooth_action(action, seconds).await
|
||||
}
|
||||
|
||||
pub async fn reset(&self) -> Result<()> {
|
||||
if !self.backend_available.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
@@ -349,6 +378,14 @@ impl HidController {
|
||||
|
||||
if let Some(backend) = self.backend.write().await.take() {
|
||||
if let Err(e) = backend.shutdown().await {
|
||||
// A Bluetooth shutdown may fail to restore adapter settings. Surface
|
||||
// that failure so the config transaction can roll back.
|
||||
if matches!(
|
||||
*self.backend_type.read().await,
|
||||
HidBackendType::Bluetooth { .. }
|
||||
) {
|
||||
return Err(e);
|
||||
}
|
||||
warn!("Error shutting down old HID backend: {}", e);
|
||||
}
|
||||
}
|
||||
@@ -437,6 +474,7 @@ impl HidController {
|
||||
let backend = self.backend.clone();
|
||||
let pending_move = self.pending_move.clone();
|
||||
let pending_move_flag = self.pending_move_flag.clone();
|
||||
let reset_requested = self.reset_requested.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut rx = rx;
|
||||
@@ -446,6 +484,15 @@ impl HidController {
|
||||
None => break,
|
||||
};
|
||||
|
||||
if reset_requested.swap(false, Ordering::AcqRel) {
|
||||
// A full input queue must not lose a key/button release and leave
|
||||
// the host stuck. Discard the obsolete batch and send all-up.
|
||||
while rx.try_recv().is_ok() {}
|
||||
*pending_move.lock() = None;
|
||||
pending_move_flag.store(false, Ordering::Release);
|
||||
process_hid_event(QueuedHidEvent::Reset, &backend).await;
|
||||
continue;
|
||||
}
|
||||
process_hid_event(event, &backend).await;
|
||||
|
||||
if pending_move_flag.swap(false, Ordering::AcqRel) {
|
||||
@@ -504,7 +551,7 @@ impl HidController {
|
||||
match self.hid_tx.try_send(QueuedHidEvent::Mouse(event.clone())) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
*self.pending_move.lock() = Some(event);
|
||||
merge_pending_move(&mut self.pending_move.lock(), event);
|
||||
self.pending_move_flag.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
@@ -524,11 +571,16 @@ impl HidController {
|
||||
tx.send(ev),
|
||||
)
|
||||
.await;
|
||||
if send_result.is_ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
warn!("HID event queue full, dropping event");
|
||||
Ok(())
|
||||
match send_result {
|
||||
Ok(Ok(())) => Ok(()),
|
||||
Ok(Err(_)) => Err(AppError::BadRequest("HID event queue closed".into())),
|
||||
Err(_) => {
|
||||
self.reset_requested.store(true, Ordering::Release);
|
||||
warn!("HID event queue full; scheduling all-input release");
|
||||
Err(AppError::ServiceUnavailable(
|
||||
"HID input queue full; input state will be reset".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
@@ -620,3 +672,100 @@ async fn apply_runtime_state(
|
||||
events.mark_device_info_dirty();
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_pending_move(pending: &mut Option<MouseEvent>, event: MouseEvent) {
|
||||
if let Some(previous) = pending {
|
||||
if previous.event_type == MouseEventType::Move && event.event_type == MouseEventType::Move {
|
||||
previous.x = previous.x.saturating_add(event.x).clamp(-32767, 32767);
|
||||
previous.y = previous.y.saturating_add(event.y).clamp(-32767, 32767);
|
||||
return;
|
||||
}
|
||||
}
|
||||
*pending = Some(event);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod queue_tests {
|
||||
use super::*;
|
||||
struct TestBackend {
|
||||
pressed: Arc<AtomicBool>,
|
||||
reset_done: Arc<tokio::sync::Notify>,
|
||||
runtime: tokio::sync::watch::Sender<()>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl HidBackend for TestBackend {
|
||||
async fn init(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn send_keyboard(&self, _: KeyboardEvent) -> Result<()> {
|
||||
self.pressed.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
async fn send_mouse(&self, _: MouseEvent) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn reset(&self) -> Result<()> {
|
||||
self.pressed.store(false, Ordering::Release);
|
||||
self.reset_done.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
async fn shutdown(&self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot {
|
||||
HidBackendRuntimeSnapshot::default()
|
||||
}
|
||||
fn subscribe_runtime(&self) -> tokio::sync::watch::Receiver<()> {
|
||||
self.runtime.subscribe()
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn congested_queue_releases_host_instead_of_replaying_keydowns() {
|
||||
#[cfg(unix)]
|
||||
let controller = HidController::new(HidBackendType::None, None);
|
||||
#[cfg(not(unix))]
|
||||
let controller = HidController::new(HidBackendType::None);
|
||||
let pressed = Arc::new(AtomicBool::new(true));
|
||||
let done = Arc::new(tokio::sync::Notify::new());
|
||||
let (runtime, _) = tokio::sync::watch::channel(());
|
||||
*controller.backend.write().await = Some(Arc::new(TestBackend {
|
||||
pressed: pressed.clone(),
|
||||
reset_done: done.clone(),
|
||||
runtime,
|
||||
}));
|
||||
for _ in 0..HID_EVENT_QUEUE_CAPACITY {
|
||||
controller
|
||||
.enqueue_event(QueuedHidEvent::Keyboard(KeyboardEvent::key_down(
|
||||
CanonicalKey::KeyA,
|
||||
KeyboardModifiers::default(),
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert!(controller
|
||||
.enqueue_event(QueuedHidEvent::Reset)
|
||||
.await
|
||||
.is_err());
|
||||
controller.start_event_worker().await;
|
||||
tokio::time::timeout(Duration::from_secs(1), done.notified())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!pressed.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_motion_is_accumulated_but_absolute_is_replaced() {
|
||||
let mut pending = Some(MouseEvent::move_rel(100, -20));
|
||||
merge_pending_move(&mut pending, MouseEvent::move_rel(80, 30));
|
||||
assert_eq!(
|
||||
(pending.as_ref().unwrap().x, pending.as_ref().unwrap().y),
|
||||
(180, 10)
|
||||
);
|
||||
merge_pending_move(&mut pending, MouseEvent::move_abs(10, 20));
|
||||
merge_pending_move(&mut pending, MouseEvent::move_abs(30, 40));
|
||||
assert_eq!(
|
||||
(pending.as_ref().unwrap().x, pending.as_ref().unwrap().y),
|
||||
(30, 40)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user