mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-13 02:54:26 +08:00
feat(bluetooth-hid): 支持经典蓝牙 HID 后端
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 1–64 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
80
src/db/hid_bonds.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod hid_bonds;
|
||||
mod pool;
|
||||
mod wol_history;
|
||||
|
||||
|
||||
@@ -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))"],
|
||||
];
|
||||
|
||||
@@ -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
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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user