feat(bluetooth-hid): 支持经典蓝牙 HID 后端

This commit is contained in:
mofeng-git
2026-09-06 11:15:45 +08:00
parent 3014edffbb
commit 2c19208094
33 changed files with 2792 additions and 60 deletions

View File

@@ -96,6 +96,7 @@ fn is_setup_public_endpoint(path: &str) -> bool {
"/setup"
| "/setup/init"
| "/devices"
| "/hid/bluetooth/adapters"
| "/video/input-status"
| "/stream/codecs"
| "/video/codecs"

View File

@@ -1,6 +1,54 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct BluetoothHidConfig {
pub adapter: String,
pub name: String,
pub peer: Option<String>,
}
impl Default for BluetoothHidConfig {
fn default() -> Self {
Self {
adapter: "hci0".into(),
name: "One-KVM HID".into(),
peer: None,
}
}
}
impl BluetoothHidConfig {
pub fn validate(&self) -> crate::error::Result<()> {
let invalid = |reason: &str| crate::error::AppError::BadRequest(reason.into());
if !self
.adapter
.strip_prefix("hci")
.is_some_and(|s| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()))
{
return Err(invalid(
"Bluetooth adapter must be hci followed by an index",
));
}
if self.name.is_empty() || self.name.len() > 64 || self.name.chars().any(char::is_control) {
return Err(invalid(
"Bluetooth name must contain 164 UTF-8 bytes without control characters",
));
}
if let Some(peer) = &self.peer {
let parts: Vec<_> = peer.split(':').collect();
if parts.len() != 6
|| parts
.iter()
.any(|p| p.len() != 2 || !p.bytes().all(|c| c.is_ascii_hexdigit()))
{
return Err(invalid("Invalid Bluetooth peer address"));
}
}
Ok(())
}
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
@@ -8,6 +56,7 @@ use typeshare::typeshare;
pub enum HidBackend {
Otg,
Ch9329,
Bluetooth,
#[default]
None,
}
@@ -166,6 +215,7 @@ impl OtgHidProfile {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct HidConfig {
pub bluetooth: BluetoothHidConfig,
pub backend: HidBackend,
pub otg_udc: Option<String>,
#[serde(default)]
@@ -189,6 +239,7 @@ impl Default for HidConfig {
fn default() -> Self {
Self {
backend: HidBackend::None,
bluetooth: BluetoothHidConfig::default(),
otg_udc: None,
otg_descriptor: OtgDescriptorConfig::default(),
otg_profile: OtgHidProfile::default(),
@@ -252,3 +303,49 @@ impl HidConfig {
})
}
}
#[cfg(test)]
mod bluetooth_tests {
use super::*;
#[test]
fn old_configs_keep_bluetooth_disabled_and_get_defaults() {
let config: HidConfig = serde_json::from_str(r#"{"backend":"otg"}"#).unwrap();
assert_eq!(config.backend, HidBackend::Otg);
assert_eq!(config.bluetooth, BluetoothHidConfig::default());
}
#[test]
fn obsolete_ble_flag_is_ignored_and_not_saved() {
let config: BluetoothHidConfig =
serde_json::from_str(r#"{"adapter":"hci0","name":"My keyboard","le_only":true}"#)
.unwrap();
config.validate().unwrap();
assert!(serde_json::to_value(config)
.unwrap()
.get("le_only")
.is_none());
}
#[test]
fn bluetooth_uses_relative_mouse_and_existing_usb_constraints() {
let mut config = crate::config::AppConfig::default();
config.hid.backend = HidBackend::Bluetooth;
config.hid.mouse_absolute = true;
config.msd.enabled = true;
config.uac.enabled = true;
config.otg_network.enabled = true;
config.enforce_invariants();
assert!(!config.hid.mouse_absolute);
assert!(!config.msd.enabled && !config.uac.enabled && !config.otg_network.enabled);
}
#[test]
fn reject_invalid_adapter_address_and_oversize_advertisement_name() {
let mut config = BluetoothHidConfig::default();
config.adapter = "/dev/hci0".into();
assert!(config.validate().is_err());
config.adapter = "hci0".into();
config.peer = Some("not-a-mac".into());
assert!(config.validate().is_err());
config.peer = None;
config.name = "".repeat(24);
assert!(config.validate().is_err());
}
}

View File

@@ -56,6 +56,9 @@ impl AppConfig {
self.otg_network.enabled = false;
self.uac.enabled = false;
}
if self.hid.backend == HidBackend::Bluetooth {
self.hid.mouse_absolute = false;
}
self.atx.normalize();
}

View File

@@ -83,6 +83,11 @@ impl ConfigStore {
Ok(())
}
#[cfg(target_os = "linux")]
pub fn hid_bonds(&self) -> crate::db::hid_bonds::HidBondStore {
crate::db::hid_bonds::HidBondStore(self.pool.clone())
}
pub fn get(&self) -> Arc<AppConfig> {
self.cache.load_full()
}

80
src/db/hid_bonds.rs Normal file
View File

@@ -0,0 +1,80 @@
use one_kvm_bluetooth_hid::bonds::{Bond, BondStore, Operation};
use sqlx::{Pool, Sqlite};
#[derive(Clone)]
pub struct HidBondStore(pub Pool<Sqlite>);
impl BondStore for HidBondStore {
fn list(&self) -> Operation<'_, Vec<Bond>> {
Box::pin(async move {
let rows: Vec<(String, String, bool)> = sqlx::query_as(
"SELECT adapter, peer, pending FROM hid_bonds ORDER BY adapter, peer",
)
.fetch_all(&self.0)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.into_iter()
.map(|(adapter, peer, pending)| Bond {
adapter,
peer,
pending,
})
.collect())
})
}
fn save(&self, bond: Bond) -> Operation<'_, ()> {
Box::pin(async move {
sqlx::query("INSERT INTO hid_bonds(adapter, peer, pending) VALUES (?, ?, ?) ON CONFLICT(adapter, peer) DO UPDATE SET pending = MAX(pending, excluded.pending)")
.bind(bond.adapter.to_ascii_uppercase()).bind(bond.peer.to_ascii_uppercase()).bind(bond.pending)
.execute(&self.0).await.map_err(|e| e.to_string())?;
Ok(())
})
}
fn remove(&self, bond: Bond) -> Operation<'_, ()> {
Box::pin(async move {
sqlx::query("DELETE FROM hid_bonds WHERE adapter = ? AND peer = ?")
.bind(bond.adapter)
.bind(bond.peer)
.execute(&self.0)
.await
.map_err(|e| e.to_string())?;
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn ownership_and_pending_cleanup_survive_reopen() {
let dir = tempfile::tempdir().unwrap();
let db = super::super::open_database_pool(dir.path()).await.unwrap();
let store = HidBondStore(db.clone_pool());
let bond = Bond {
adapter: "AA:BB:CC:DD:EE:FF".into(),
peer: "11:22:33:44:55:66".into(),
pending: false,
};
store.save(bond.clone()).await.unwrap();
store
.save(Bond {
pending: true,
..bond.clone()
})
.await
.unwrap();
store.save(bond.clone()).await.unwrap(); // Late status cannot undo a pending reset.
let reopened = super::super::open_database_pool(dir.path()).await.unwrap();
let records = HidBondStore(reopened.clone_pool()).list().await.unwrap();
assert_eq!(
records,
vec![Bond {
pending: true,
..bond.clone()
}]
);
store.remove(bond).await.unwrap();
assert!(store.list().await.unwrap().is_empty());
}
}

View File

@@ -1,3 +1,5 @@
#[cfg(target_os = "linux")]
pub mod hid_bonds;
mod pool;
mod wol_history;

View File

@@ -144,4 +144,5 @@ const SCHEMA_MIGRATIONS: &[&[&str]] = &[
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_singleton
ON users ((1))
"#],
&["CREATE TABLE hid_bonds (adapter TEXT NOT NULL, peer TEXT NOT NULL, pending INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(adapter, peer))"],
];

View File

@@ -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")]
@@ -33,6 +36,7 @@ impl HidBackendType {
pub fn name_str(&self) -> &str {
match self {
Self::Otg => "otg",
Self::Bluetooth { .. } => "bluetooth",
Self::Ch9329 { .. } => "ch9329",
Self::None => "none",
}
@@ -81,6 +85,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
View 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 10300 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);
}
}

View File

@@ -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))]
@@ -54,6 +60,22 @@ impl HidBackendFactory {
*hybrid_mouse,
)?)))
}
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)

View File

@@ -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)
);
}
}

View File

@@ -104,6 +104,8 @@ impl RuntimeBuilder {
let hid = Arc::new(HidController::new(hid_backend, Some(otg_service.clone())));
#[cfg(not(unix))]
let hid = Arc::new(HidController::new(hid_backend));
#[cfg(target_os = "linux")]
hid.set_bond_store(config_store.hid_bonds());
hid.set_event_bus(events.clone()).await;
if let Err(error) = hid.init().await {
tracing::warn!("Failed to initialize HID backend: {}", error);
@@ -412,6 +414,9 @@ fn hid_backend_type(config: &AppConfig) -> HidBackendType {
hybrid_mouse: config.hid.ch9329_hybrid_mouse,
},
config::HidBackend::None => HidBackendType::None,
config::HidBackend::Bluetooth => HidBackendType::Bluetooth {
config: config.hid.bluetooth.clone(),
},
}
}

View File

@@ -168,6 +168,7 @@ impl UsbCoordinator {
options: ConfigApplyOptions,
) -> Result<()> {
new_config.validate_otg_functions()?;
new_config.bluetooth.validate()?;
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
let hid_functions_changed =
@@ -180,6 +181,7 @@ impl UsbCoordinator {
if old_config.backend == new_config.backend
&& old_config.ch9329_port == new_config.ch9329_port
&& old_config.ch9329_baudrate == new_config.ch9329_baudrate
&& old_config.bluetooth == new_config.bluetooth
&& !ch9329_runtime_changed
&& old_config.otg_udc == new_config.otg_udc
&& !descriptor_changed
@@ -314,6 +316,9 @@ fn hid_backend_type(config: &HidConfig) -> HidBackendType {
hybrid_mouse: config.ch9329_hybrid_mouse,
},
HidBackend::None => HidBackendType::None,
HidBackend::Bluetooth => HidBackendType::Bluetooth {
config: config.bluetooth.clone(),
},
}
}

View File

@@ -5,7 +5,7 @@ use crate::error::Result;
use crate::web::state::UsbApiState;
use super::types::HidConfigUpdate;
use super::usb_update::{stage_hid_config_update, update_usb_config};
use super::usb_update::{stage_hid_config_update, update_usb_config_with_reset};
pub async fn get_hid_config(State(state): State<UsbApiState>) -> Json<HidConfig> {
Json(state.config.get().hid.clone())
@@ -15,7 +15,8 @@ pub async fn update_hid_config(
State(state): State<UsbApiState>,
Json(req): Json<HidConfigUpdate>,
) -> Result<Json<HidConfig>> {
let config = update_usb_config(&state, move |staged| {
let reset = req.bluetooth_reset_pairing.unwrap_or(false);
let config = update_usb_config_with_reset(&state, reset, move |staged| {
stage_hid_config_update(&mut staged.hid, &req)
})
.await?;

View File

@@ -8,7 +8,7 @@ use crate::otg::OtgNetworkStatus;
use crate::web::state::UsbApiState;
use super::types::OtgConfigUpdate;
use super::usb_update::{stage_hid_config_update, update_usb_config};
use super::usb_update::{stage_hid_config_update, update_usb_config_with_reset};
#[typeshare]
#[derive(Debug, Serialize)]
@@ -30,7 +30,12 @@ pub(super) async fn update_otg_config_inner(
state: &UsbApiState,
request: OtgConfigUpdate,
) -> Result<OtgConfigResponse> {
let staged_config = update_usb_config(state, move |staged| {
let reset = request
.hid
.as_ref()
.and_then(|h| h.bluetooth_reset_pairing)
.unwrap_or(false);
let staged_config = update_usb_config_with_reset(state, reset, move |staged| {
let requested_ch9329_descriptor = match request.hid.as_ref() {
Some(update) => stage_hid_config_update(&mut staged.hid, update)?,
None => None,

View File

@@ -402,6 +402,9 @@ impl Ch9329DescriptorConfigUpdate {
#[typeshare]
#[derive(Debug, Deserialize)]
pub struct HidConfigUpdate {
/// Request-only; never persisted or replayed during startup.
pub bluetooth_reset_pairing: Option<bool>,
pub bluetooth: Option<crate::config::BluetoothHidConfig>,
pub backend: Option<HidBackend>,
pub ch9329_port: Option<String>,
pub ch9329_baudrate: Option<u32>,
@@ -426,6 +429,9 @@ pub struct OtgConfigUpdate {
impl HidConfigUpdate {
pub fn validate(&self) -> crate::error::Result<()> {
if let Some(config) = &self.bluetooth {
config.validate()?;
}
if let Some(baudrate) = self.ch9329_baudrate {
let valid_rates = [9600, 19200, 38400, 57600, 115200];
if !valid_rates.contains(&baudrate) {
@@ -444,6 +450,9 @@ impl HidConfigUpdate {
}
pub fn apply_to(&self, config: &mut HidConfig) {
if let Some(bluetooth) = &self.bluetooth {
config.bluetooth = bluetooth.clone();
}
if let Some(backend) = self.backend.clone() {
config.backend = backend;
}

View File

@@ -26,6 +26,17 @@ pub(super) fn stage_hid_config_update(
}
pub(super) async fn update_usb_config<F>(state: &UsbApiState, stage_update: F) -> Result<AppConfig>
where
F: FnOnce(&mut AppConfig) -> Result<Option<Ch9329DescriptorConfig>>,
{
update_usb_config_with_reset(state, false, stage_update).await
}
pub(super) async fn update_usb_config_with_reset<F>(
state: &UsbApiState,
reset: bool,
stage_update: F,
) -> Result<AppConfig>
where
F: FnOnce(&mut AppConfig) -> Result<Option<Ch9329DescriptorConfig>>,
{
@@ -53,63 +64,174 @@ where
staged_config.uac.validate()?;
}
if let Err(error) = state
.coordinator
.apply_config(&old_config, &staged_config)
.await
{
return Err(rollback_after_failure(state, &staged_config, &old_config, error, false).await);
staged_config.hid.bluetooth.validate()?;
if reset && staged_config.hid.backend != HidBackend::Bluetooth {
return Err(AppError::BadRequest(
"Pairing reset requires Bluetooth HID".into(),
));
}
#[cfg(not(target_os = "linux"))]
if reset {
return Err(AppError::BadRequest("Bluetooth HID requires Linux".into()));
}
#[cfg(target_os = "linux")]
if reset {
use one_kvm_bluetooth_hid::{
adapters,
bonds::{self, Bond, BondStore},
};
let adapters = adapters().await.map_err(AppError::Config)?;
if !adapters
.iter()
.any(|a| a.name == staged_config.hid.bluetooth.adapter)
{
return Err(AppError::BadRequest(
"Selected Bluetooth adapter is missing".into(),
));
}
let mut explicit = Vec::new();
let mut runtime = state.hid.bluetooth_status().await.ok();
if runtime
.as_ref()
.is_some_and(|s| s["initialized"].as_bool() == Some(true))
{
// Publish the latest bond before shutdown, including pairing completed
// since the last 500 ms status update. No new peers can enter afterward.
state.hid.bluetooth_action("close", 120).await?;
runtime = state.hid.bluetooth_status().await.ok();
}
if let Some(status) = runtime {
if let (Some(adapter), Some(peer)) =
(status["adapter_address"].as_str(), status["peer"].as_str())
{
if !adapter.is_empty() {
explicit.push(Bond {
adapter: adapter.into(),
peer: peer.into(),
pending: true,
});
}
}
}
let owned = state
.config
.hid_bonds()
.list()
.await
.map_err(AppError::Config)?;
for config in [&old_config.hid.bluetooth, &staged_config.hid.bluetooth] {
// A recorded hardware address takes precedence over a potentially
// renumbered hci index. Runtime targets above also carry real addresses.
if config.peer.as_ref().is_some_and(|peer| {
owned
.iter()
.chain(explicit.iter())
.any(|bond| bond.peer.eq_ignore_ascii_case(peer))
}) {
continue;
}
if let (Some(peer), Some(adapter)) = (
&config.peer,
adapters.iter().find(|a| a.name == config.adapter),
) {
explicit.push(Bond {
adapter: adapter.address.clone(),
peer: peer.clone(),
pending: true,
});
}
}
// Stop observation before writing tombstones or removing bonds.
if old_config.hid.backend == HidBackend::Bluetooth {
state.hid.reload(crate::hid::HidBackendType::None).await?;
}
if let Err(error) = bonds::reset(&state.config.hid_bonds(), explicit).await {
return Err(AppError::Config(format!("Bluetooth binding cleanup failed; some old bindings may already be cleared and require pairing again: {error}")));
}
staged_config.hid.bluetooth.peer = None;
}
let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor {
if staged_config.hid.backend == HidBackend::Ch9329 {
match state.hid.apply_ch9329_descriptor(descriptor).await {
Ok(actual) => {
staged_config.hid.ch9329_descriptor = actual.descriptor;
true
}
Err(error) => {
return Err(rollback_after_failure(
state,
&staged_config,
&old_config,
error,
true,
)
.await);
let result = async {
if let Err(error) = state
.coordinator
.apply_config(&old_config, &staged_config)
.await
{
return Err(
rollback_after_failure(state, &staged_config, &old_config, error, false).await,
);
}
#[cfg(target_os = "linux")]
if reset
&& !matches!(
state.hid.backend_type().await,
crate::hid::HidBackendType::Bluetooth { .. }
)
{
// Even identical device selections must rebuild with no pinned peer.
state
.hid
.reload(crate::hid::HidBackendType::Bluetooth {
config: staged_config.hid.bluetooth.clone(),
})
.await?;
}
let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor {
if staged_config.hid.backend == HidBackend::Ch9329 {
match state.hid.apply_ch9329_descriptor(descriptor).await {
Ok(actual) => {
staged_config.hid.ch9329_descriptor = actual.descriptor;
true
}
Err(error) => {
return Err(rollback_after_failure(
state,
&staged_config,
&old_config,
error,
true,
)
.await);
}
}
} else {
false
}
} else {
false
};
if let Err(error) = state
.config
.update(|config| {
config.hid = staged_config.hid.clone();
config.msd = staged_config.msd.clone();
config.otg_network = staged_config.otg_network.clone();
config.uac = staged_config.uac.clone();
config.enforce_invariants();
})
.await
{
return Err(rollback_after_failure(
state,
&staged_config,
&old_config,
AppError::Config(format!(
"Failed to persist USB configuration after apply: {error}"
)),
descriptor_was_applied,
)
.await);
}
} else {
false
};
if let Err(error) = state
.config
.update(|config| {
config.hid = staged_config.hid.clone();
config.msd = staged_config.msd.clone();
config.otg_network = staged_config.otg_network.clone();
config.uac = staged_config.uac.clone();
config.enforce_invariants();
})
.await
{
return Err(rollback_after_failure(
state,
&staged_config,
&old_config,
AppError::Config(format!(
"Failed to persist USB configuration after apply: {error}"
)),
descriptor_was_applied,
)
.await);
Ok(staged_config)
}
Ok(staged_config)
.await;
result.map_err(|error| if reset {
AppError::Config(format!("Old One-KVM HID bindings were cleared; pair again. Configuration apply failed: {error}"))
} else { error })
}
async fn rollback_after_failure(
@@ -156,6 +278,8 @@ mod tests {
fn hid_update() -> HidConfigUpdate {
HidConfigUpdate {
bluetooth_reset_pairing: None,
bluetooth: None,
backend: None,
ch9329_port: None,
ch9329_baudrate: None,
@@ -170,6 +294,37 @@ mod tests {
}
}
#[test]
fn reset_is_request_only_and_feature_updates_preserve_device_selection() {
let mut hid = HidConfig::default();
hid.backend = HidBackend::Ch9329;
hid.ch9329_port = "COM7".into();
let update: HidConfigUpdate = serde_json::from_value(serde_json::json!({
"ch9329_hybrid_mouse": true, "bluetooth_reset_pairing": true
}))
.unwrap();
stage_hid_config_update(&mut hid, &update).unwrap();
assert_eq!(hid.backend, HidBackend::Ch9329);
assert_eq!(hid.ch9329_port, "COM7");
assert!(serde_json::to_value(&hid)
.unwrap()
.get("bluetooth_reset_pairing")
.is_none());
}
#[test]
fn invalid_target_does_not_mutate_staged_config() {
let mut hid = HidConfig::default();
let before = serde_json::to_value(&hid).unwrap();
let update: HidConfigUpdate = serde_json::from_value(serde_json::json!({
"backend": "bluetooth", "bluetooth_reset_pairing": true,
"bluetooth": { "adapter": "hci0", "name": "" }
}))
.unwrap();
assert!(stage_hid_config_update(&mut hid, &update).is_err());
assert_eq!(serde_json::to_value(hid).unwrap(), before);
}
#[test]
fn stages_regular_hid_fields_immediately() {
let mut hid = HidConfig::default();

View File

@@ -112,3 +112,43 @@ fn cached_ch9329_descriptor(
descriptor,
}
}
#[derive(Deserialize)]
pub struct BluetoothAction {
pub action: String,
pub seconds: Option<u32>,
}
pub async fn hid_bluetooth_status(
State(state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>> {
Ok(Json(state.hid.bluetooth_status().await?))
}
pub async fn hid_bluetooth_action(
State(state): State<Arc<AppState>>,
Json(req): Json<BluetoothAction>,
) -> Result<Json<serde_json::Value>> {
let _guard = crate::runtime::try_apply_lock(&state.config_apply_locks.otg, "bluetooth")?;
state
.hid
.bluetooth_action(&req.action, req.seconds.unwrap_or(120))
.await?;
Ok(Json(serde_json::json!({"success": true})))
}
pub async fn hid_bluetooth_adapters() -> Result<Json<serde_json::Value>> {
#[cfg(target_os = "linux")]
{
Ok(Json(
serde_json::to_value(
one_kvm_bluetooth_hid::adapters()
.await
.map_err(AppError::ServiceUnavailable)?,
)
.map_err(|e| AppError::Internal(e.to_string()))?,
))
}
#[cfg(not(target_os = "linux"))]
{
Err(AppError::BadRequest("Bluetooth HID requires Linux".into()))
}
}

View File

@@ -91,6 +91,14 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/webrtc/close", post(handlers::webrtc_close_session))
// HID endpoints
.route("/hid/status", get(handlers::hid_status))
.route(
"/hid/bluetooth/adapters",
get(handlers::hid_bluetooth_adapters),
)
.route(
"/hid/bluetooth",
get(handlers::hid_bluetooth_status).post(handlers::hid_bluetooth_action),
)
.route(
"/hid/ch9329/descriptor",
get(handlers::hid_ch9329_descriptor),