feat(hid): 支持控制台鼠标抖动

在控制台鼠键配置菜单增加鼠标抖动开关和间隔设置,默认关闭,间隔支持 10–3600 秒。
设备端按键鼠空闲时间调度,支持配置持久化、热更新、真实输入打断和断连后重新计时。
兼容绝对及相对鼠标模式,按键或按钮按住期间暂停抖动。

验证:608 项 Rust 单测通过;暂存快照前端构建和浏览器交互测试通过;已完成 ARMv7 OpenWrt 部署验证。

Refs #316
This commit is contained in:
mofeng-git
2026-10-28 07:11:44 +08:00
parent f303a8cb2f
commit fc99c6f274
12 changed files with 886 additions and 14 deletions

View File

@@ -246,10 +246,39 @@ impl OtgHidProfile {
} }
} }
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct MouseJigglerConfig {
pub enabled: bool,
pub interval_secs: u32,
}
impl Default for MouseJigglerConfig {
fn default() -> Self {
Self {
enabled: false,
interval_secs: 60,
}
}
}
impl MouseJigglerConfig {
pub fn validate(&self) -> crate::error::Result<()> {
if !(10..=3600).contains(&self.interval_secs) {
return Err(crate::error::AppError::BadRequest(
"Mouse jiggler interval must be an integer between 10 and 3600 seconds".into(),
));
}
Ok(())
}
}
#[typeshare] #[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)] #[serde(default)]
pub struct HidConfig { pub struct HidConfig {
pub jiggler: MouseJigglerConfig,
pub bluetooth: BluetoothHidConfig, pub bluetooth: BluetoothHidConfig,
pub backend: HidBackend, pub backend: HidBackend,
pub otg_udc: Option<String>, pub otg_udc: Option<String>,
@@ -276,6 +305,7 @@ pub struct HidConfig {
impl Default for HidConfig { impl Default for HidConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
jiggler: MouseJigglerConfig::default(),
backend: HidBackend::None, backend: HidBackend::None,
bluetooth: BluetoothHidConfig::default(), bluetooth: BluetoothHidConfig::default(),
otg_udc: None, otg_udc: None,
@@ -294,6 +324,17 @@ impl Default for HidConfig {
} }
impl HidConfig { impl HidConfig {
pub fn has_mouse(&self) -> bool {
match self.backend {
HidBackend::None => false,
HidBackend::Otg => {
let functions = self.effective_otg_functions();
functions.mouse_relative || functions.mouse_absolute
}
_ => true,
}
}
pub fn effective_otg_functions(&self) -> OtgHidFunctions { pub fn effective_otg_functions(&self) -> OtgHidFunctions {
self.otg_profile.resolve_functions(&self.otg_functions) self.otg_profile.resolve_functions(&self.otg_functions)
} }
@@ -311,6 +352,7 @@ impl HidConfig {
} }
pub fn validate_platform(&self) -> crate::error::Result<()> { pub fn validate_platform(&self) -> crate::error::Result<()> {
self.jiggler.validate()?;
if self.backend == HidBackend::Ch9329Otg && !cfg!(target_os = "linux") { if self.backend == HidBackend::Ch9329Otg && !cfg!(target_os = "linux") {
return Err(crate::error::AppError::BadRequest( return Err(crate::error::AppError::BadRequest(
"CH9329 + OTG requires Linux with a USB device controller".into(), "CH9329 + OTG requires Linux with a USB device controller".into(),

View File

@@ -144,6 +144,32 @@ mod tests {
use crate::db::DatabasePool; use crate::db::DatabasePool;
use tempfile::tempdir; use tempfile::tempdir;
#[tokio::test]
async fn jiggler_defaults_and_preferences_survive_restart() {
let defaults: crate::config::HidConfig =
serde_json::from_str(r#"{"backend":"otg"}"#).unwrap();
assert!(!defaults.jiggler.enabled);
assert_eq!(defaults.jiggler.interval_secs, 60);
let dir = tempdir().unwrap();
let db = DatabasePool::new(&dir.path().join("jiggler.db"))
.await
.unwrap();
db.init_schema().await.unwrap();
let store = ConfigStore::new(db.clone_pool());
store.load().await.unwrap();
store
.update(|config| {
config.hid.jiggler.enabled = true;
config.hid.jiggler.interval_secs = 120;
})
.await
.unwrap();
let reloaded = ConfigStore::new(db.clone_pool());
reloaded.load().await.unwrap();
assert!(reloaded.get().hid.jiggler.enabled);
assert_eq!(reloaded.get().hid.jiggler.interval_secs, 120);
}
#[tokio::test] #[tokio::test]
async fn test_config_store() { async fn test_config_store() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();

500
src/hid/jiggler.rs Normal file
View File

@@ -0,0 +1,500 @@
//! Device-side mouse jiggler. Real input invalidates every remaining movement.
use std::collections::HashSet;
use std::time::{Duration, Instant};
use crate::config::{HidBackend, HidConfig, MouseJigglerConfig};
use super::{CanonicalKey, KeyEventType, KeyboardEvent, MouseEvent, MouseEventType};
const STEP_DELAY: Duration = Duration::from_millis(100);
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode {
Relative,
Absolute,
}
pub(super) struct JigglerState {
config: MouseJigglerConfig,
relative: bool,
absolute: bool,
mode: Mode,
ready: bool,
activity: Instant,
keys: HashSet<CanonicalKey>,
modifiers: u8,
buttons: u8,
consumer: u16,
last_absolute: Option<(i32, i32)>,
return_move: Option<(MouseEvent, Instant)>,
generation: u64,
}
impl Default for JigglerState {
fn default() -> Self {
Self {
config: MouseJigglerConfig::default(),
relative: false,
absolute: false,
mode: Mode::Relative,
ready: false,
activity: Instant::now(),
keys: HashSet::new(),
modifiers: 0,
buttons: 0,
consumer: 0,
last_absolute: None,
return_move: None,
generation: 0,
}
}
}
impl JigglerState {
pub fn configure(&mut self, hid: &HidConfig, now: Instant) {
let (relative, absolute) = match hid.backend {
HidBackend::None => (false, false),
HidBackend::Bluetooth => (true, false),
HidBackend::Otg => {
let functions = hid.effective_otg_functions();
(functions.mouse_relative, functions.mouse_absolute)
}
_ => (true, true),
};
let mode = if absolute && (hid.mouse_absolute || !relative) {
Mode::Absolute
} else {
Mode::Relative
};
if self.config != hid.jiggler
|| (self.relative, self.absolute, self.mode) != (relative, absolute, mode)
{
self.config = hid.jiggler.clone();
self.relative = relative;
self.absolute = absolute;
self.mode = mode;
self.activity(now);
}
}
pub fn activity(&mut self, now: Instant) {
self.activity = now;
self.return_move = None;
self.generation = self.generation.wrapping_add(1);
}
pub fn keyboard(&mut self, event: &KeyboardEvent, now: Instant) {
self.activity(now);
match event.event_type {
KeyEventType::Down => {
self.keys.insert(event.key);
}
KeyEventType::Up => {
self.keys.remove(&event.key);
}
}
self.modifiers = event.modifiers.to_hid_byte();
}
pub fn mouse(&mut self, event: &MouseEvent, now: Instant) {
self.activity(now);
match event.event_type {
MouseEventType::Move if self.relative => self.mode = Mode::Relative,
MouseEventType::MoveAbs if self.absolute => {
self.mode = Mode::Absolute;
self.last_absolute = Some((event.x.clamp(0, 32767), event.y.clamp(0, 32767)));
}
MouseEventType::Down => {
if let Some(button) = event.button {
self.buttons |= button.to_hid_bit();
}
}
MouseEventType::Up => {
if let Some(button) = event.button {
self.buttons &= !button.to_hid_bit();
}
}
_ => {}
}
}
pub fn consumer(&mut self, usage: u16, now: Instant) {
self.activity(now);
self.consumer = usage;
}
pub fn reset(&mut self, now: Instant) {
self.activity(now);
self.keys.clear();
self.modifiers = 0;
self.buttons = 0;
self.consumer = 0;
}
pub fn suspend(&mut self) {
self.ready = false;
self.last_absolute = None;
self.reset(Instant::now());
}
pub fn delay(&self) -> Option<Duration> {
(self.config.enabled && (self.relative || self.absolute)).then_some(
if self.return_move.is_some() {
STEP_DELAY
} else {
Duration::from_secs(1)
},
)
}
pub fn valid(&self, generation: u64) -> bool {
self.config.enabled && self.ready && self.generation == generation
}
pub fn tick(
&mut self,
ready: bool,
resolution: (u32, u32),
now: Instant,
) -> Option<(MouseEvent, u64)> {
if ready != self.ready {
self.ready = ready;
if ready {
// Input may already have arrived since reconnecting. Preserve
// held keys/buttons when starting the new idle period.
self.activity(now);
} else {
self.reset(now);
self.last_absolute = None;
}
}
if !self.ready
|| !self.config.enabled
|| (!self.relative && !self.absolute)
|| !self.keys.is_empty()
|| self.modifiers != 0
|| self.buttons != 0
|| self.consumer != 0
{
return None;
}
if let Some((_, deadline)) = &self.return_move {
if now < *deadline {
return None;
}
let (event, _) = self.return_move.take().unwrap();
self.activity = now;
return Some((event, self.generation));
}
if now.duration_since(self.activity) < Duration::from_secs(self.config.interval_secs.into())
{
return None;
}
let (event, back) = if self.mode == Mode::Absolute
&& self.absolute
&& (self.last_absolute.is_some() || !self.relative)
{
// An absolute-only device has no way to query the host cursor position.
let (x, y) = self.last_absolute.unwrap_or((16384, 16384));
let offset = |position: i32, extent: u32| {
let delta = (65534 / extent.max(1)).clamp(1, 512) as i32;
if position + delta <= 32767 {
position + delta
} else {
position - delta
}
};
(
MouseEvent::move_abs(offset(x, resolution.0), offset(y, resolution.1)),
MouseEvent::move_abs(x, y),
)
} else {
(MouseEvent::move_rel(2, 0), MouseEvent::move_rel(-2, 0))
};
self.return_move = Some((back, now + STEP_DELAY));
Some((event, self.generation))
}
pub fn failed(&mut self, generation: u64, now: Instant) {
if self.generation == generation {
self.activity(now);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::OtgHidProfile;
use crate::hid::{KeyboardModifiers, MouseButton};
fn configured(now: Instant) -> (JigglerState, HidConfig) {
let mut config = HidConfig::default();
config.backend = HidBackend::Otg;
config.jiggler.enabled = true;
config.jiggler.interval_secs = 10;
let mut state = JigglerState::default();
state.configure(&config, now);
assert!(state.tick(true, (1920, 1080), now).is_none());
(state, config)
}
#[test]
fn jiggler_waits_then_moves_out_and_back_without_counting_synthetic_input() {
let now = Instant::now();
let (mut state, _) = configured(now);
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(9))
.is_none());
let due = now + Duration::from_secs(10);
let (out, generation) = state.tick(true, (1920, 1080), due).unwrap();
assert_eq!((out.event_type, out.x, out.y), (MouseEventType::Move, 2, 0));
assert!(state.valid(generation));
assert!(state
.tick(true, (1920, 1080), due + STEP_DELAY / 2)
.is_none());
let (back, _) = state.tick(true, (1920, 1080), due + STEP_DELAY).unwrap();
assert_eq!((back.x, back.y), (-2, 0));
assert!(state
.tick(true, (1920, 1080), due + Duration::from_secs(10))
.is_none());
assert!(state
.tick(
true,
(1920, 1080),
due + STEP_DELAY + Duration::from_secs(10)
)
.is_some());
}
#[test]
fn jiggler_real_input_cancels_return_and_invalidates_dispatched_movement() {
let now = Instant::now();
let (mut state, _) = configured(now);
state.mouse(&MouseEvent::move_abs(32767, 0), now);
let due = now + Duration::from_secs(10);
let (out, generation) = state.tick(true, (1920, 1080), due).unwrap();
assert!(out.x < 32767 && out.y > 0);
let input = due + Duration::from_millis(50);
state.mouse(&MouseEvent::move_abs(12000, 24000), input);
assert!(!state.valid(generation));
assert!(state.tick(true, (1920, 1080), due + STEP_DELAY).is_none());
let (next, _) = state
.tick(true, (1920, 1080), input + Duration::from_secs(10))
.unwrap();
assert!(next.x > 12000 && next.x < 12100 && next.y > 24000 && next.y < 24100);
}
#[test]
fn jiggler_pauses_for_held_keys_modifiers_buttons_and_consumer_controls() {
let now = Instant::now();
let (mut state, _) = configured(now);
let down = KeyboardEvent::key_down(CanonicalKey::KeyA, KeyboardModifiers::default());
state.keyboard(&down, now);
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(100))
.is_none());
state.keyboard(
&KeyboardEvent::key_up(
CanonicalKey::KeyA,
KeyboardModifiers {
left_ctrl: true,
..Default::default()
},
),
now,
);
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(100))
.is_none());
state.reset(now);
state.mouse(&MouseEvent::button_down(MouseButton::Left), now);
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(100))
.is_none());
state.mouse(&MouseEvent::button_up(MouseButton::Left), now);
state.consumer(0xe9, now);
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(100))
.is_none());
state.consumer(0, now);
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(10))
.is_some());
}
#[test]
fn jiggler_config_changes_disconnects_and_shutdown_cancel_and_restart_the_wait() {
let now = Instant::now();
let (mut state, mut config) = configured(now);
let due = now + Duration::from_secs(10);
let (_, generation) = state.tick(true, (1920, 1080), due).unwrap();
config.jiggler.enabled = false;
state.configure(&config, due);
assert!(!state.valid(generation));
assert!(state.delay().is_none());
config.jiggler.enabled = true;
config.jiggler.interval_secs = 60;
state.configure(&config, due);
assert!(state
.tick(true, (1920, 1080), due + Duration::from_secs(59))
.is_none());
assert!(state
.tick(false, (1920, 1080), due + Duration::from_secs(60))
.is_none());
let reconnect = due + Duration::from_secs(100);
assert!(state.tick(true, (1920, 1080), reconnect).is_none());
assert!(state
.tick(true, (1920, 1080), reconnect + Duration::from_secs(60))
.is_some());
state.suspend();
assert!(!state.valid(state.generation));
}
#[test]
fn jiggler_handles_bluetooth_relative_only_absolute_only_and_keyboard_only() {
let now = Instant::now();
let (mut state, mut config) = configured(now);
config.backend = HidBackend::Bluetooth;
state.configure(&config, now);
assert_eq!(
state
.tick(true, (1920, 1080), now + Duration::from_secs(10))
.unwrap()
.0
.event_type,
MouseEventType::Move
);
config.backend = HidBackend::Otg;
config.otg_profile = OtgHidProfile::Custom;
config.otg_functions.mouse_relative = false;
state.configure(&config, now);
assert_eq!(
state
.tick(true, (1920, 1080), now + Duration::from_secs(10))
.unwrap()
.0
.event_type,
MouseEventType::MoveAbs
);
config.otg_profile = OtgHidProfile::LegacyKeyboard;
state.configure(&config, now);
assert!(state.delay().is_none());
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(100))
.is_none());
}
#[test]
fn jiggler_failed_moves_wait_a_full_interval_before_retrying() {
let now = Instant::now();
let (mut state, _) = configured(now);
let due = now + Duration::from_secs(10);
let (_, generation) = state.tick(true, (1920, 1080), due).unwrap();
state.failed(generation, due);
assert!(state.tick(true, (1920, 1080), due + STEP_DELAY).is_none());
assert!(state
.tick(true, (1920, 1080), due + Duration::from_secs(10))
.is_some());
}
#[test]
fn jiggler_reconnect_does_not_forget_input_received_before_the_first_tick() {
let now = Instant::now();
let (mut state, _) = configured(now);
state.suspend();
state.keyboard(
&KeyboardEvent::key_down(CanonicalKey::KeyA, KeyboardModifiers::default()),
now,
);
assert!(state.tick(true, (1920, 1080), now).is_none());
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(100))
.is_none());
state.keyboard(
&KeyboardEvent::key_up(CanonicalKey::KeyA, KeyboardModifiers::default()),
now,
);
assert!(state
.tick(true, (1920, 1080), now + Duration::from_secs(10))
.is_some());
}
struct RecordingBackend {
mouse: tokio::sync::mpsc::UnboundedSender<MouseEvent>,
runtime: tokio::sync::watch::Sender<()>,
}
#[async_trait::async_trait]
impl crate::hid::HidBackend for RecordingBackend {
async fn init(&self) -> crate::error::Result<()> {
Ok(())
}
async fn send_keyboard(&self, _: KeyboardEvent) -> crate::error::Result<()> {
Ok(())
}
async fn send_mouse(&self, event: MouseEvent) -> crate::error::Result<()> {
self.mouse.send(event).unwrap();
Ok(())
}
async fn reset(&self) -> crate::error::Result<()> {
Ok(())
}
async fn shutdown(&self) -> crate::error::Result<()> {
Ok(())
}
fn runtime_snapshot(&self) -> crate::hid::HidBackendRuntimeSnapshot {
crate::hid::HidBackendRuntimeSnapshot {
initialized: true,
online: true,
..Default::default()
}
}
fn subscribe_runtime(&self) -> tokio::sync::watch::Receiver<()> {
self.runtime.subscribe()
}
}
#[tokio::test]
async fn jiggler_worker_runs_without_clients_and_confirmed_input_cancels_return() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let backend = std::sync::Arc::new(RecordingBackend {
mouse: tx,
runtime: tokio::sync::watch::channel(()).0,
});
let controller = crate::hid::test_controller(backend).await;
let (_, config) = configured(Instant::now());
controller.configure_jiggler(&config);
{
let mut state = controller.jiggler.lock();
state.ready = true;
state.activity(Instant::now() - Duration::from_secs(9));
}
// Bluetooth publishes status periodically, even with no user input.
let changed = controller.jiggler_changed.clone();
let notifications = tokio::spawn(async move {
loop {
changed.notify_one();
tokio::time::sleep(Duration::from_millis(20)).await;
}
});
let out = tokio::time::timeout(Duration::from_secs(3), rx.recv())
.await
.unwrap()
.unwrap();
notifications.abort();
assert_eq!(out.x, 2);
controller
.send_keyboard_confirmed(KeyboardEvent::key_down(
CanonicalKey::KeyA,
KeyboardModifiers::default(),
))
.await
.unwrap();
assert!(tokio::time::timeout(Duration::from_millis(300), rx.recv())
.await
.is_err());
controller.shutdown().await.unwrap();
}
}

View File

@@ -8,6 +8,7 @@ mod ch9329_proto;
pub mod consumer; pub mod consumer;
pub mod datachannel; pub mod datachannel;
mod factory; mod factory;
mod jiggler;
pub mod keyboard; pub mod keyboard;
mod macos_drag; mod macos_drag;
#[cfg(unix)] #[cfg(unix)]
@@ -141,6 +142,8 @@ pub struct HidController {
backend_available: Arc<AtomicBool>, backend_available: Arc<AtomicBool>,
reset_requested: Arc<AtomicBool>, reset_requested: Arc<AtomicBool>,
screen_resolution: parking_lot::RwLock<(u32, u32)>, screen_resolution: parking_lot::RwLock<(u32, u32)>,
jiggler: Arc<parking_lot::Mutex<jiggler::JigglerState>>,
jiggler_changed: Arc<tokio::sync::Notify>,
} }
impl HidController { impl HidController {
@@ -164,6 +167,8 @@ impl HidController {
backend_available: Arc::new(AtomicBool::new(false)), backend_available: Arc::new(AtomicBool::new(false)),
reset_requested: Arc::new(AtomicBool::new(false)), reset_requested: Arc::new(AtomicBool::new(false)),
screen_resolution: parking_lot::RwLock::new((1920, 1080)), screen_resolution: parking_lot::RwLock::new((1920, 1080)),
jiggler: Arc::new(parking_lot::Mutex::new(jiggler::JigglerState::default())),
jiggler_changed: Arc::new(tokio::sync::Notify::new()),
} }
} }
@@ -187,6 +192,8 @@ impl HidController {
backend_available: Arc::new(AtomicBool::new(false)), backend_available: Arc::new(AtomicBool::new(false)),
reset_requested: Arc::new(AtomicBool::new(false)), reset_requested: Arc::new(AtomicBool::new(false)),
screen_resolution: parking_lot::RwLock::new((1920, 1080)), screen_resolution: parking_lot::RwLock::new((1920, 1080)),
jiggler: Arc::new(parking_lot::Mutex::new(jiggler::JigglerState::default())),
jiggler_changed: Arc::new(tokio::sync::Notify::new()),
} }
} }
@@ -235,6 +242,9 @@ impl HidController {
pub async fn shutdown(&self) -> Result<()> { pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down HID controller"); info!("Shutting down HID controller");
self.backend_available.store(false, Ordering::Release);
self.jiggler.lock().suspend();
self.jiggler_changed.notify_one();
self.stop_runtime_worker().await; self.stop_runtime_worker().await;
if let Some(backend) = self.backend.write().await.take() { if let Some(backend) = self.backend.write().await.take() {
@@ -265,6 +275,7 @@ impl HidController {
info!("Preparing OTG HID backend for gadget rebuild"); info!("Preparing OTG HID backend for gadget rebuild");
self.backend_available.store(false, Ordering::Release); self.backend_available.store(false, Ordering::Release);
self.jiggler.lock().suspend();
self.stop_runtime_worker().await; self.stop_runtime_worker().await;
if let Some(backend) = self.backend.write().await.take() { if let Some(backend) = self.backend.write().await.take() {
@@ -403,6 +414,14 @@ impl HidController {
self.runtime_state.read().await.clone() self.runtime_state.read().await.clone()
} }
/// Jiggler preferences are independent of USB descriptors and device reloads.
pub fn configure_jiggler(&self, config: &crate::config::HidConfig) {
self.jiggler
.lock()
.configure(config, std::time::Instant::now());
self.jiggler_changed.notify_one();
}
async fn ch9329_backend(&self) -> Result<Arc<dyn HidBackend>> { async fn ch9329_backend(&self) -> Result<Arc<dyn HidBackend>> {
if !matches!( if !matches!(
*self.backend_type.read().await, *self.backend_type.read().await,
@@ -436,6 +455,7 @@ impl HidController {
pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> { pub async fn reload(&self, new_backend_type: HidBackendType) -> Result<()> {
info!("Reloading HID backend: {:?}", new_backend_type); info!("Reloading HID backend: {:?}", new_backend_type);
self.backend_available.store(false, Ordering::Release); self.backend_available.store(false, Ordering::Release);
self.jiggler.lock().suspend();
self.stop_runtime_worker().await; self.stop_runtime_worker().await;
if let Some(backend) = self.backend.write().await.take() { if let Some(backend) = self.backend.write().await.take() {
@@ -544,13 +564,53 @@ impl HidController {
let pending_move = self.pending_move.clone(); let pending_move = self.pending_move.clone();
let pending_move_flag = self.pending_move_flag.clone(); let pending_move_flag = self.pending_move_flag.clone();
let reset_requested = self.reset_requested.clone(); let reset_requested = self.reset_requested.clone();
let jiggler = self.jiggler.clone();
let jiggler_changed = self.jiggler_changed.clone();
let backend_available = self.backend_available.clone();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
let mut rx = rx; let mut rx = rx;
let mut next_poll = tokio::time::Instant::now();
loop { loop {
let event = match rx.recv().await { let delay = jiggler.lock().delay();
Some(ev) => ev, let event = tokio::select! {
None => break, biased;
event = rx.recv() => match event {
Some(event) => event,
None => break,
},
_ = jiggler_changed.notified() => continue,
_ = tokio::time::sleep_until(next_poll), if delay.is_some() => {
// Synthetic motion is sent by this same worker, outside the
// coalescing queue, so opposite movements cannot cancel out.
let active_backend = backend.read().await.clone();
let snapshot = active_backend.as_ref().map(|backend| backend.runtime_snapshot());
let ready = backend_available.load(Ordering::Acquire)
&& snapshot.as_ref().is_some_and(|state| state.initialized && state.online);
let resolution = snapshot.and_then(|state| state.screen_resolution).unwrap_or((1920, 1080));
let movement = jiggler.lock().tick(ready, resolution, std::time::Instant::now());
if let (Some((event, generation)), Some(active_backend)) = (movement, active_backend) {
let state = jiggler.clone();
let available = backend_available.clone();
let result = tokio::task::spawn_blocking(move || {
// Check again after dispatch to the blocking pool:
// real input, shutdown or a config change may win.
if !available.load(Ordering::Acquire) || !state.lock().valid(generation) {
return Ok(());
}
futures::executor::block_on(active_backend.send_mouse(event))
}).await;
if !matches!(result, Ok(Ok(()))) {
jiggler.lock().failed(generation, std::time::Instant::now());
warn!("Mouse jiggler movement failed: {:?}", result);
}
}
// Keep an absolute deadline: periodic backend status
// notifications must not restart the timer indefinitely.
next_poll = tokio::time::Instant::now()
+ jiggler.lock().delay().unwrap_or(Duration::from_secs(1));
continue;
}
}; };
if reset_requested.swap(false, Ordering::AcqRel) { if reset_requested.swap(false, Ordering::AcqRel) {
@@ -559,15 +619,16 @@ impl HidController {
while rx.try_recv().is_ok() {} while rx.try_recv().is_ok() {}
*pending_move.lock() = None; *pending_move.lock() = None;
pending_move_flag.store(false, Ordering::Release); pending_move_flag.store(false, Ordering::Release);
process_hid_event(QueuedHidEvent::Reset, &backend).await; process_hid_event(QueuedHidEvent::Reset, &backend, &jiggler).await;
continue; continue;
} }
process_hid_event(event, &backend).await; process_hid_event(event, &backend, &jiggler).await;
if pending_move_flag.swap(false, Ordering::AcqRel) { if pending_move_flag.swap(false, Ordering::AcqRel) {
let move_event = { pending_move.lock().take() }; let move_event = { pending_move.lock().take() };
if let Some(move_event) = move_event { if let Some(move_event) = move_event {
process_hid_event(QueuedHidEvent::Mouse(move_event), &backend).await; process_hid_event(QueuedHidEvent::Mouse(move_event), &backend, &jiggler)
.await;
} }
} }
} }
@@ -589,6 +650,8 @@ impl HidController {
let events = self.events.clone(); let events = self.events.clone();
let backend_available = self.backend_available.clone(); let backend_available = self.backend_available.clone();
let backend_type = self.backend_type.clone(); let backend_type = self.backend_type.clone();
let jiggler = self.jiggler.clone();
let jiggler_changed = self.jiggler_changed.clone();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
loop { loop {
@@ -604,6 +667,11 @@ impl HidController {
Some(backend.as_ref()), Some(backend.as_ref()),
) )
.await; .await;
let state = runtime_state.read().await;
if !state.initialized || !state.online {
jiggler.lock().suspend();
}
jiggler_changed.notify_one();
} }
}); });
@@ -617,6 +685,7 @@ impl HidController {
} }
fn enqueue_mouse_move(&self, event: MouseEvent) -> Result<()> { fn enqueue_mouse_move(&self, event: MouseEvent) -> Result<()> {
self.jiggler.lock().activity(std::time::Instant::now());
match self.hid_tx.try_send(QueuedHidEvent::Mouse(event.clone())) { match self.hid_tx.try_send(QueuedHidEvent::Mouse(event.clone())) {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(mpsc::error::TrySendError::Full(_)) => { Err(mpsc::error::TrySendError::Full(_)) => {
@@ -631,6 +700,8 @@ impl HidController {
} }
async fn enqueue_event(&self, event: QueuedHidEvent) -> Result<()> { async fn enqueue_event(&self, event: QueuedHidEvent) -> Result<()> {
self.jiggler.lock().activity(std::time::Instant::now());
self.jiggler_changed.notify_one();
match self.hid_tx.try_send(event) { match self.hid_tx.try_send(event) {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(mpsc::error::TrySendError::Full(ev)) => { Err(mpsc::error::TrySendError::Full(ev)) => {
@@ -690,11 +761,23 @@ async fn apply_backend_runtime_state(
async fn process_hid_event( async fn process_hid_event(
event: QueuedHidEvent, event: QueuedHidEvent,
backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>, backend: &Arc<RwLock<Option<Arc<dyn HidBackend>>>>,
jiggler: &Arc<parking_lot::Mutex<jiggler::JigglerState>>,
) { ) {
let (event, reply) = match event { let (event, reply) = match event {
QueuedHidEvent::Confirmed(event, reply) => (*event, Some(reply)), QueuedHidEvent::Confirmed(event, reply) => (*event, Some(reply)),
event => (event, None), event => (event, None),
}; };
{
let mut state = jiggler.lock();
let now = std::time::Instant::now();
match &event {
QueuedHidEvent::Keyboard(event) => state.keyboard(event, now),
QueuedHidEvent::Mouse(event) => state.mouse(event, now),
QueuedHidEvent::Consumer(event) => state.consumer(event.usage, now),
QueuedHidEvent::Reset => state.reset(now),
QueuedHidEvent::Confirmed(..) => unreachable!("nested confirmed HID event"),
}
}
let backend = backend.read().await.clone(); let backend = backend.read().await.clone();
let result = match backend { let result = match backend {
Some(backend) => tokio::task::spawn_blocking(move || { Some(backend) => tokio::task::spawn_blocking(move || {

View File

@@ -107,6 +107,7 @@ impl RuntimeBuilder {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
hid.set_bond_store(config_store.hid_bonds()); hid.set_bond_store(config_store.hid_bonds());
hid.set_event_bus(events.clone()).await; hid.set_event_bus(events.clone()).await;
hid.configure_jiggler(&config.hid);
hid.set_screen_resolution(video_resolution.width, video_resolution.height) hid.set_screen_resolution(video_resolution.width, video_resolution.height)
.await; .await;
if let Err(error) = hid.init().await { if let Err(error) = hid.init().await {

View File

@@ -52,6 +52,8 @@ impl UsbCoordinator {
pub async fn apply_config(&self, old_config: &AppConfig, new_config: &AppConfig) -> Result<()> { pub async fn apply_config(&self, old_config: &AppConfig, new_config: &AppConfig) -> Result<()> {
new_config.hid.validate_platform()?; new_config.hid.validate_platform()?;
// Also runs for config-only updates and rollback; never forces a USB reload.
self.hid.configure_jiggler(&new_config.hid);
#[cfg(unix)] #[cfg(unix)]
{ {
let transitioning_away_from_otg = old_config.hid.backend == HidBackend::Otg let transitioning_away_from_otg = old_config.hid.backend == HidBackend::Otg
@@ -318,6 +320,32 @@ fn hid_otg_config_changed(old: &HidConfig, new: &HidConfig) -> bool {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn jiggler_preferences_do_not_reload_hid_or_change_usb_descriptors() {
let old = AppConfig::default();
let mut new = old.clone();
new.hid.jiggler.enabled = true;
new.hid.jiggler.interval_secs = 120;
assert!(!hid_requires_reload(&old.hid, &new.hid));
#[cfg(unix)]
assert_eq!(
crate::otg::service::OtgDesiredState::from_config(
&old.hid,
&old.msd,
&old.otg_network,
&old.uac
)
.unwrap(),
crate::otg::service::OtgDesiredState::from_config(
&new.hid,
&new.msd,
&new.otg_network,
&new.uac
)
.unwrap()
);
}
#[test] #[test]
fn composite_otg_preferences_do_not_reload_ch9329_but_input_changes_do() { fn composite_otg_preferences_do_not_reload_ch9329_but_input_changes_do() {
let old = HidConfig { let old = HidConfig {

View File

@@ -399,9 +399,28 @@ impl Ch9329DescriptorConfigUpdate {
} }
} }
#[typeshare]
#[derive(Debug, Deserialize)]
pub struct MouseJigglerConfigUpdate {
pub enabled: Option<bool>,
pub interval_secs: Option<u32>,
}
impl MouseJigglerConfigUpdate {
fn apply_to(&self, config: &mut MouseJigglerConfig) {
if let Some(enabled) = self.enabled {
config.enabled = enabled;
}
if let Some(interval) = self.interval_secs {
config.interval_secs = interval;
}
}
}
#[typeshare] #[typeshare]
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct HidConfigUpdate { pub struct HidConfigUpdate {
pub jiggler: Option<MouseJigglerConfigUpdate>,
/// Request-only; never persisted or replayed during startup. /// Request-only; never persisted or replayed during startup.
pub bluetooth_reset_pairing: Option<bool>, pub bluetooth_reset_pairing: Option<bool>,
pub bluetooth: Option<crate::config::BluetoothHidConfig>, pub bluetooth: Option<crate::config::BluetoothHidConfig>,
@@ -432,6 +451,11 @@ pub struct OtgConfigUpdate {
impl HidConfigUpdate { impl HidConfigUpdate {
pub fn validate(&self) -> crate::error::Result<()> { pub fn validate(&self) -> crate::error::Result<()> {
if let Some(update) = &self.jiggler {
let mut config = MouseJigglerConfig::default();
update.apply_to(&mut config);
config.validate()?;
}
if let Some(config) = &self.bluetooth { if let Some(config) = &self.bluetooth {
config.validate()?; config.validate()?;
} }
@@ -453,6 +477,9 @@ impl HidConfigUpdate {
} }
pub fn apply_to(&self, config: &mut HidConfig) { pub fn apply_to(&self, config: &mut HidConfig) {
if let Some(update) = &self.jiggler {
update.apply_to(&mut config.jiggler);
}
if let Some(bluetooth) = &self.bluetooth { if let Some(bluetooth) = &self.bluetooth {
config.bluetooth = bluetooth.clone(); config.bluetooth = bluetooth.clone();
} }

View File

@@ -12,15 +12,24 @@ pub(super) fn stage_hid_config_update(
update.validate()?; update.validate()?;
let old_descriptor = staged_hid.ch9329_descriptor.clone(); let old_descriptor = staged_hid.ch9329_descriptor.clone();
update.apply_to(staged_hid); let mut updated_hid = staged_hid.clone();
update.apply_to(&mut updated_hid);
if update.jiggler.as_ref().and_then(|update| update.enabled) == Some(true)
&& !updated_hid.has_mouse()
{
return Err(crate::error::AppError::BadRequest(
"Mouse jiggler requires a configured mouse interface".into(),
));
}
let requested_descriptor = update let requested_descriptor = update
.ch9329_descriptor .ch9329_descriptor
.as_ref() .as_ref()
.map(|_| staged_hid.ch9329_descriptor.clone()); .map(|_| updated_hid.ch9329_descriptor.clone());
if requested_descriptor.is_some() { if requested_descriptor.is_some() {
staged_hid.ch9329_descriptor = old_descriptor; updated_hid.ch9329_descriptor = old_descriptor;
} }
*staged_hid = updated_hid;
Ok(requested_descriptor) Ok(requested_descriptor)
} }
@@ -318,6 +327,7 @@ mod tests {
fn hid_update() -> HidConfigUpdate { fn hid_update() -> HidConfigUpdate {
HidConfigUpdate { HidConfigUpdate {
jiggler: None,
bluetooth_reset_pairing: None, bluetooth_reset_pairing: None,
bluetooth: None, bluetooth: None,
backend: None, backend: None,
@@ -335,6 +345,52 @@ mod tests {
} }
} }
#[test]
fn jiggler_partial_updates_validate_and_preserve_other_preferences() {
let mut hid = HidConfig::default();
hid.backend = HidBackend::Ch9329;
for (request, enabled, interval) in [
(serde_json::json!({"jiggler":{"enabled":true}}), true, 60),
(
serde_json::json!({"jiggler":{"interval_secs":120}}),
true,
120,
),
(serde_json::json!({"jiggler":{"enabled":false}}), false, 120),
(serde_json::json!({"mouse_absolute":false}), false, 120),
] {
let update: HidConfigUpdate = serde_json::from_value(request).unwrap();
stage_hid_config_update(&mut hid, &update).unwrap();
assert_eq!(hid.jiggler.enabled, enabled);
assert_eq!(hid.jiggler.interval_secs, interval);
assert_eq!(hid.backend, HidBackend::Ch9329);
}
for interval in [0, 9, 3601] {
let update: HidConfigUpdate =
serde_json::from_value(serde_json::json!({"jiggler":{"interval_secs":interval}}))
.unwrap();
assert!(stage_hid_config_update(&mut hid, &update).is_err());
assert_eq!(hid.jiggler.interval_secs, 120);
}
for interval in [
serde_json::json!(-1),
serde_json::json!(10.5),
serde_json::json!("60"),
] {
assert!(serde_json::from_value::<HidConfigUpdate>(
serde_json::json!({"jiggler":{"interval_secs":interval}})
)
.is_err());
}
hid.backend = HidBackend::None;
let enable: HidConfigUpdate =
serde_json::from_value(serde_json::json!({"jiggler":{"enabled":true}})).unwrap();
assert!(stage_hid_config_update(&mut hid, &enable).is_err());
let disable: HidConfigUpdate =
serde_json::from_value(serde_json::json!({"jiggler":{"enabled":false}})).unwrap();
assert!(stage_hid_config_update(&mut hid, &disable).is_ok());
}
#[test] #[test]
fn reset_is_request_only_and_feature_updates_preserve_device_selection() { fn reset_is_request_only_and_feature_updates_preserve_device_selection() {
let mut hid = HidConfig::default(); let mut hid = HidConfig::default();

View File

@@ -8,6 +8,7 @@ import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider' import { Slider } from '@/components/ui/slider'
import { Input } from '@/components/ui/input'
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -67,6 +68,53 @@ watch(showCursor, (newValue, oldValue) => {
const buttonText = computed(() => t('actionbar.hidConfig')) const buttonText = computed(() => t('actionbar.hidConfig'))
const hidSaving = ref(false) const hidSaving = ref(false)
const hidControlsDisabled = computed(() => hidSaving.value || configStore.hidLoading || !configStore.hid) const hidControlsDisabled = computed(() => hidSaving.value || configStore.hidLoading || !configStore.hid)
const jigglerEnabled = computed(() => configStore.hid?.jiggler?.enabled ?? false)
const jigglerInterval = ref<string | number>(60)
const jigglerIntervalInvalid = ref(false)
const hasMouseInterface = computed(() => {
const hid = configStore.hid
if (!hid || hid.backend === HidBackend.None) return false
if (hid.backend !== HidBackend.Otg) return true
if (hid.otg_profile === OtgHidProfile.LegacyKeyboard) return false
if (hid.otg_profile === OtgHidProfile.Custom) {
return !!(hid.otg_functions?.mouse_relative || hid.otg_functions?.mouse_absolute)
}
return true
})
watch(() => [configStore.hid?.jiggler?.interval_secs, configStore.hid?.jiggler?.enabled] as const, ([interval]) => {
jigglerInterval.value = interval ?? 60
jigglerIntervalInvalid.value = false
}, { immediate: true })
async function saveJiggler(update: { enabled?: boolean; interval_secs?: number }) {
if (hidControlsDisabled.value) return
hidSaving.value = true
try {
await configStore.updateHid({ jiggler: update })
} catch (error) {
toast.error(t('config.updateFailed'), {
description: error instanceof Error ? error.message : undefined,
})
await configStore.refreshHid().catch(() => undefined)
jigglerInterval.value = configStore.hid?.jiggler?.interval_secs ?? 60
} finally {
hidSaving.value = false
}
}
function toggleJiggler(enabled: boolean) {
if (enabled && !hasMouseInterface.value) return
void saveJiggler({ enabled })
}
function saveJigglerInterval() {
if (hidControlsDisabled.value || !jigglerEnabled.value) return
const interval = Number(jigglerInterval.value)
jigglerIntervalInvalid.value = !Number.isInteger(interval) || interval < 10 || interval > 3600
if (jigglerIntervalInvalid.value || interval === configStore.hid?.jiggler?.interval_secs) return
void saveJiggler({ interval_secs: interval })
}
const supportsLinuxCompatibility = computed(() => usesCh9329(configStore.hid?.backend)) const supportsLinuxCompatibility = computed(() => usesCh9329(configStore.hid?.backend))
const supportsMacosCompatibility = computed(() => const supportsMacosCompatibility = computed(() =>
supportsLinuxCompatibility.value || configStore.hid?.backend === HidBackend.Otg supportsLinuxCompatibility.value || configStore.hid?.backend === HidBackend.Otg
@@ -140,6 +188,8 @@ watch(() => props.open, (open) => {
mouseThrottle.value = loadMouseMoveSendIntervalFromStorage() mouseThrottle.value = loadMouseMoveSendIntervalFromStorage()
showCursor.value = localStorage.getItem('hidShowCursor') !== 'false' showCursor.value = localStorage.getItem('hidShowCursor') !== 'false'
if (!hidSaving.value) void configStore.refreshHid().catch(() => undefined) if (!hidSaving.value) void configStore.refreshHid().catch(() => undefined)
jigglerInterval.value = configStore.hid?.jiggler?.interval_secs ?? 60
jigglerIntervalInvalid.value = false
}) })
</script> </script>
@@ -160,7 +210,7 @@ watch(() => props.open, (open) => {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
@open-auto-focus="focusConsolePanel" @open-auto-focus="focusConsolePanel"
class="console-config-panel w-[min(320px,92vw)] p-3" class="console-config-panel w-[min(320px,92vw)] max-h-(--reka-popover-content-available-height) overflow-y-auto p-3"
align="start" align="start"
:side="props.side ?? 'bottom'" :side="props.side ?? 'bottom'"
> >
@@ -231,6 +281,45 @@ watch(() => props.open, (open) => {
<Label class="text-xs text-muted-foreground">{{ t('actionbar.showCursor') }}</Label> <Label class="text-xs text-muted-foreground">{{ t('actionbar.showCursor') }}</Label>
<Switch v-model="showCursor" /> <Switch v-model="showCursor" />
</div> </div>
<div class="space-y-2" :aria-busy="hidSaving">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-1">
<Label for="hid-mouse-jiggler" class="text-xs text-muted-foreground">{{ t('actionbar.mouseJiggler') }}</Label>
<HelpTooltip :content="t('actionbar.mouseJigglerHelp')" icon-size="sm" />
</div>
<Switch
id="hid-mouse-jiggler"
:model-value="jigglerEnabled"
:disabled="hidControlsDisabled || (!hasMouseInterface && !jigglerEnabled)"
@update:model-value="toggleJiggler"
/>
</div>
<div v-if="jigglerEnabled" class="flex items-center justify-between gap-3">
<Label for="hid-jiggler-interval" class="text-xs text-muted-foreground">{{ t('actionbar.jigglerInterval') }}</Label>
<div class="flex items-center gap-2">
<Input
id="hid-jiggler-interval"
v-model="jigglerInterval"
type="number" min="10" max="3600" step="1" size="sm"
class="w-20 text-xs"
:disabled="hidControlsDisabled"
:aria-invalid="jigglerIntervalInvalid"
:aria-describedby="jigglerIntervalInvalid ? 'hid-jiggler-interval-error' : undefined"
@input="jigglerIntervalInvalid = false"
@blur="saveJigglerInterval"
@keydown.enter.prevent="saveJigglerInterval"
/>
<span class="text-xs text-muted-foreground">{{ t('actionbar.jigglerSeconds') }}</span>
</div>
</div>
<p v-if="jigglerEnabled && jigglerIntervalInvalid" id="hid-jiggler-interval-error" role="alert" class="text-xs text-destructive">{{ t('actionbar.jigglerIntervalInvalid') }}</p>
<p v-if="!hasMouseInterface && configStore.hid" class="text-xs text-muted-foreground">{{ t('actionbar.jigglerNoMouse') }}</p>
</div>
<p v-if="hidSaving" role="status" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="size-3 shrink-0 animate-spin" />
{{ t('actionbar.applying') }}
</p>
</div> </div>
<template v-if="supportsMacosCompatibility"> <template v-if="supportsMacosCompatibility">
@@ -265,10 +354,6 @@ watch(() => props.open, (open) => {
</div> </div>
<p v-if="macosDragRequiresInterfaces" class="text-xs text-warning">{{ t('actionbar.macosDragRequiresBoth') }}</p> <p v-if="macosDragRequiresInterfaces" class="text-xs text-warning">{{ t('actionbar.macosDragRequiresBoth') }}</p>
<p v-if="hidSaving" role="status" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="size-3 shrink-0 animate-spin" />
{{ t('actionbar.applying') }}
</p>
</div> </div>
</template> </template>
</div> </div>

View File

@@ -278,6 +278,12 @@ export default {
positioningMode: 'Positioning Mode', positioningMode: 'Positioning Mode',
sendInterval: 'Send Interval', sendInterval: 'Send Interval',
showCursor: 'Show Cursor', showCursor: 'Show Cursor',
mouseJiggler: 'Mouse jiggler',
mouseJigglerHelp: 'Gently moves the mouse while idle to help prevent the target from sleeping or turning off its display. Pauses during input or while keys or buttons are held. Keeps working after closing this page and remembers settings after restart.',
jigglerInterval: 'Jiggle interval',
jigglerSeconds: 'sec',
jigglerIntervalInvalid: 'Enter a whole number between 10 and 3600 seconds.',
jigglerNoMouse: 'Enable a mouse interface in HID device settings first.',
backend: 'Backend', backend: 'Backend',
devicePath: 'Device Path', devicePath: 'Device Path',
baudrate: 'Baudrate', baudrate: 'Baudrate',

View File

@@ -278,6 +278,12 @@ export default {
positioningMode: '定位模式', positioningMode: '定位模式',
sendInterval: '发送间隔', sendInterval: '发送间隔',
showCursor: '显示指针', showCursor: '显示指针',
mouseJiggler: '鼠标抖动',
mouseJigglerHelp: '空闲时自动小幅移动鼠标,帮助防止目标主机息屏或休眠。操作键鼠或按住按键时暂停。关闭网页后仍生效,重启后保留设置。',
jigglerInterval: '抖动间隔',
jigglerSeconds: '秒',
jigglerIntervalInvalid: '请输入 10–3600 之间的整数秒数。',
jigglerNoMouse: '请先在 HID 设备设置中启用鼠标接口。',
backend: '后端类型', backend: '后端类型',
devicePath: '设备路径', devicePath: '设备路径',
baudrate: '波特率', baudrate: '波特率',

View File

@@ -61,7 +61,13 @@ export interface Ch9329DescriptorConfig {
serial_number?: string; serial_number?: string;
} }
export interface MouseJigglerConfig {
enabled: boolean;
interval_secs: number;
}
export interface HidConfig { export interface HidConfig {
jiggler: MouseJigglerConfig;
bluetooth: BluetoothHidConfig; bluetooth: BluetoothHidConfig;
backend: HidBackend; backend: HidBackend;
otg_udc?: string; otg_udc?: string;
@@ -503,7 +509,13 @@ export interface OtgHidFunctionsUpdate {
consumer?: boolean; consumer?: boolean;
} }
export interface MouseJigglerConfigUpdate {
enabled?: boolean;
interval_secs?: number;
}
export interface HidConfigUpdate { export interface HidConfigUpdate {
jiggler?: MouseJigglerConfigUpdate;
bluetooth_reset_pairing?: boolean; bluetooth_reset_pairing?: boolean;
bluetooth?: BluetoothHidConfig; bluetooth?: BluetoothHidConfig;
backend?: HidBackend; backend?: HidBackend;