1#[cfg(all(unix, feature = "libusb"))]
4use crate::llm::actions::{
5 protocol_trait::{ActionResult, Protocol, Server},
6 ActionDefinition, Parameter,
7};
8#[cfg(all(unix, feature = "libusb"))]
9use crate::protocol::log_template::LogTemplate;
10#[cfg(all(unix, feature = "libusb"))]
11use crate::protocol::EventType;
12#[cfg(all(unix, feature = "libusb"))]
13use crate::server::connection::ConnectionId;
14#[cfg(all(unix, feature = "libusb"))]
15use crate::server::usb::descriptors::{mouse_buttons, MouseReport};
16#[cfg(all(unix, feature = "libusb"))]
17use crate::state::app_state::AppState;
18#[cfg(all(unix, feature = "libusb"))]
19use anyhow::{Context, Result};
20#[cfg(all(unix, feature = "libusb"))]
21use serde_json::json;
22#[cfg(all(unix, feature = "libusb"))]
23use std::collections::HashMap;
24#[cfg(all(unix, feature = "libusb"))]
25use std::sync::{Arc, LazyLock};
26#[cfg(all(unix, feature = "libusb"))]
27use tokio::sync::Mutex;
28
29#[cfg(all(unix, feature = "libusb"))]
31pub static USB_MOUSE_ATTACHED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
32 EventType::new(
33 "usb_mouse_attached",
34 "A USB/IP host attached to this virtual mouse and will now accept input reports. Move, \
35 click, scroll or drag to drive the host's pointer, or wait_for_more to stay idle.",
36 json!({"type": "move_relative", "x": 50, "y": -20}),
37 )
38 .with_parameters(vec![Parameter {
39 name: "connection_id".to_string(),
40 type_hint: "string".to_string(),
41 description: "Connection ID of the USB/IP session. Actions may quote it back, but it is \
42 optional: with one host attached it is inferred."
43 .to_string(),
44 required: true,
45 }])
46 .with_actions(vec![
47 move_relative_action(),
48 move_absolute_action(),
49 click_action(),
50 scroll_action(),
51 drag_action(),
52 wait_for_more_action(),
53 ])
54 .with_alternative_example(json!({"type": "click", "button": "left"}))
55});
56
57#[cfg(all(unix, feature = "libusb"))]
58pub static USB_MOUSE_DETACHED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
59 EventType::new(
60 "usb_mouse_detached",
61 "The host detached from this virtual mouse. Purely informational - the USB/IP session \
62 is gone, so an input report has nowhere to go.",
63 json!({"type": "show_message", "message": "USB mouse host detached"}),
64 )
65 .with_parameters(vec![Parameter {
66 name: "connection_id".to_string(),
67 type_hint: "string".to_string(),
68 description: "Connection ID of the USB/IP session. Actions may quote it back, but it is \
69 optional: with one host attached it is inferred."
70 .to_string(),
71 required: true,
72 }])
73 .with_no_actions()
74});
75
76#[cfg(all(unix, feature = "libusb"))]
78pub struct UsbMouseProtocol {
79 #[allow(dead_code)]
81 connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
82 handlers: Arc<std::sync::Mutex<HashMap<ConnectionId, SharedHandler>>>,
88}
89
90#[cfg(all(unix, feature = "libusb"))]
92type SharedHandler = Arc<std::sync::Mutex<Box<dyn usbip::UsbInterfaceHandler + Send>>>;
93
94#[cfg(all(unix, feature = "libusb"))]
95pub struct ConnectionData {
96 }
98
99#[cfg(all(unix, feature = "libusb"))]
100impl Default for UsbMouseProtocol {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106#[cfg(all(unix, feature = "libusb"))]
107impl UsbMouseProtocol {
108 pub fn new() -> Self {
109 Self {
110 connections: Arc::new(Mutex::new(HashMap::new())),
111 handlers: Arc::new(std::sync::Mutex::new(HashMap::new())),
112 }
113 }
114
115 pub async fn set_handler(&self, connection_id: ConnectionId, handler: SharedHandler) {
117 self.lock_handlers().insert(connection_id, handler);
118 }
119
120 pub async fn remove_handler(&self, connection_id: ConnectionId) {
122 self.lock_handlers().remove(&connection_id);
123 }
124
125 fn lock_handlers(&self) -> std::sync::MutexGuard<'_, HashMap<ConnectionId, SharedHandler>> {
126 self.handlers
127 .lock()
128 .unwrap_or_else(|poisoned| poisoned.into_inner())
129 }
130
131 fn resolve_handler(&self, action: &serde_json::Value) -> Result<SharedHandler> {
138 let handlers = self.lock_handlers();
139
140 let requested = action["connection_id"].as_u64().or_else(|| {
144 action["connection_id"].as_str().and_then(|s| {
145 let s = s.trim();
146 s.strip_prefix("conn-").unwrap_or(s).parse::<u64>().ok()
147 })
148 });
149
150 if let Some(id) = requested {
151 let connection_id = u32::try_from(id).map(ConnectionId::new).map_err(|_| {
154 anyhow::anyhow!("connection_id {} is not a valid connection number", id)
155 })?;
156 return handlers.get(&connection_id).cloned().ok_or_else(|| {
157 anyhow::anyhow!("No USB mouse attached on connection {}", connection_id)
158 });
159 }
160
161 match handlers.len() {
162 0 => Err(anyhow::anyhow!(
163 "No USB/IP host is attached to this mouse, so an input report has nowhere to go"
164 )),
165 1 => Ok(handlers.values().next().cloned().expect("len checked")),
166 _ => {
167 let mut ids: Vec<u32> = handlers.keys().map(|c| c.as_u32()).collect();
168 ids.sort_unstable();
169 Err(anyhow::anyhow!(
170 "{} USB/IP hosts are attached ({:?}); the action must name one with \
171 'connection_id'",
172 ids.len(),
173 ids
174 ))
175 }
176 }
177 }
178
179 fn queue(&self, action: &serde_json::Value, reports: Vec<MouseReport>) -> Result<usize> {
184 if reports.is_empty() {
185 return Err(anyhow::anyhow!("action produced no mouse reports"));
186 }
187 let handler = self.resolve_handler(action)?;
188 let mut guard = handler
189 .lock()
190 .map_err(|poisoned| poisoned.into_inner())
191 .unwrap_or_else(|guard| guard);
192 let mouse = guard
193 .as_any()
194 .downcast_mut::<super::handler::UsbHidMouseHandler>()
195 .context("Handler is not a USB HID mouse handler")?;
196 let count = reports.len();
197 for report in reports {
198 mouse.queue(report);
199 }
200 Ok(count)
201 }
202}
203
204#[cfg(all(unix, feature = "libusb"))]
213const MAX_MOVEMENT: i64 = 65_535;
214
215#[cfg(all(unix, feature = "libusb"))]
222const MAX_SCREEN_DIMENSION: i64 = 65_535;
223
224#[cfg(all(unix, feature = "libusb"))]
226fn movement_field(action: &serde_json::Value, field: &str, action_type: &str) -> Result<i64> {
227 let value = action[field]
228 .as_i64()
229 .with_context(|| format!("{action_type} requires '{field}' field"))?;
230 if !(-MAX_MOVEMENT..=MAX_MOVEMENT).contains(&value) {
231 return Err(anyhow::anyhow!(
232 "{} '{}' is {}, outside the +/-{} this device can move in one action",
233 action_type,
234 field,
235 value,
236 MAX_MOVEMENT
237 ));
238 }
239 Ok(value)
240}
241
242#[cfg(all(unix, feature = "libusb"))]
244fn screen_dimension(action: &serde_json::Value, field: &str, default: i64) -> Result<i64> {
245 let value = match action.get(field) {
246 Some(v) if !v.is_null() => v
247 .as_i64()
248 .with_context(|| format!("move_absolute '{field}' must be an integer"))?,
249 _ => return Ok(default),
250 };
251 if !(1..=MAX_SCREEN_DIMENSION).contains(&value) {
252 return Err(anyhow::anyhow!(
253 "move_absolute '{}' is {}, outside 1..={}",
254 field,
255 value,
256 MAX_SCREEN_DIMENSION
257 ));
258 }
259 Ok(value)
260}
261
262#[cfg(all(unix, feature = "libusb"))]
268fn split_movement(mut x: i64, mut y: i64) -> Vec<MouseReport> {
269 let mut reports = Vec::new();
270 while x != 0 || y != 0 {
271 let dx = x.clamp(-127, 127);
272 let dy = y.clamp(-127, 127);
273 let mut report = MouseReport::new();
274 report.x = dx as i8;
275 report.y = dy as i8;
276 reports.push(report);
277 x -= dx;
278 y -= dy;
279 }
280 reports
281}
282
283#[cfg(all(unix, feature = "libusb"))]
285fn button_bit(name: &str) -> Result<u8> {
286 match name.to_ascii_lowercase().as_str() {
287 "left" => Ok(mouse_buttons::LEFT),
288 "right" => Ok(mouse_buttons::RIGHT),
289 "middle" => Ok(mouse_buttons::MIDDLE),
290 other => Err(anyhow::anyhow!(
291 "unknown mouse button '{}'; expected left, right or middle",
292 other
293 )),
294 }
295}
296
297#[cfg(all(unix, feature = "libusb"))]
299impl Protocol for UsbMouseProtocol {
300 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
301 vec![
302 crate::llm::actions::ParameterDefinition {
303 name: "first_byte_timeout_secs".to_string(),
304 type_hint: "number".to_string(),
305 description: "Seconds a peer that has only connected may send no USB/IP message \
306 at all before the server closes it. Default 30. USB/IP is \
307 client-speaks-first and nothing here answers a peer that has not \
308 asked - `usbip attach` and `usbip list -r` both write their \
309 request as soon as the socket is up - so a short bound is the \
310 honest one. Raise it only if your host is reached over a link \
311 where a handshake can take that long."
312 .to_string(),
313 required: false,
314 example: serde_json::json!(30),
315 default: Some(serde_json::json!(
316 crate::server::usb::guard::DEFAULT_FIRST_MESSAGE_TIMEOUT.as_secs()
317 )),
318 },
319 crate::llm::actions::ParameterDefinition {
320 name: "idle_timeout_secs".to_string(),
321 type_hint: "number".to_string(),
322 description: "Seconds an attached host may go without sending a further USB/IP \
323 message before the server closes the session. Default 1800. \
324 USB/IP has no keepalive and nothing obliges an attached host to \
325 speak - a mouse a host has imported and is asking nothing of \
326 is idle by design - so this is deliberately long and exists to \
327 reap a peer that is gone rather than to police one that is here. \
328 Lower it if you are exporting to strangers."
329 .to_string(),
330 required: false,
331 example: serde_json::json!(1800),
332 default: Some(serde_json::json!(
333 crate::server::usb::guard::DEFAULT_IDLE_TIMEOUT.as_secs()
334 )),
335 },
336 ]
337 }
338
339 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
340 vec![]
341 }
342
343 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
344 vec![
345 move_relative_action(),
346 move_absolute_action(),
347 click_action(),
348 scroll_action(),
349 drag_action(),
350 wait_for_more_action(),
351 ]
352 }
353
354 fn protocol_name(&self) -> &'static str {
355 "USB-Mouse"
356 }
357
358 fn get_event_types(&self) -> Vec<EventType> {
359 vec![
360 USB_MOUSE_ATTACHED_EVENT.clone(),
361 USB_MOUSE_DETACHED_EVENT.clone(),
362 ]
363 }
364
365 fn stack_name(&self) -> &'static str {
366 "USB>HID>Mouse"
367 }
368
369 fn keywords(&self) -> Vec<&'static str> {
370 vec!["usb", "mouse", "hid", "pointer", "cursor"]
371 }
372
373 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
374 crate::protocol::metadata::ProtocolMetadataV2::builder()
375 .state(crate::protocol::metadata::DevelopmentState::Experimental)
376 .deliberately_silent()
380 .implementation("Virtual USB HID mouse device using USB/IP protocol")
381 .llm_control("LLM controls mouse movement, clicks, and scrolling")
382 .e2e_testing("E2E tests using Linux usbip client")
383 .privilege_requirement(crate::protocol::metadata::PrivilegeRequirement::None)
384 .notes("Virtual USB HID mouse over USB/IP. A hand-written UsbInterfaceHandler (handler.rs) supplies the report descriptor and 4-byte reports, because usbip 0.9 still ships no UsbHidMouseHandler. It was written but never wired: handle_connection took the accepted socket as _stream, dropped it, ran no USB/IP session, and parked on sleep(u64::MAX), while every action logged 'not yet implemented' and returned NoAction. All of that is live now, and usb_mouse_detached has an emit site for the first time. move_relative splits movement larger than a report can carry; click and drag emit their own release, so neither sticks; scroll sends one detent per report. move_absolute has no way to know where the pointer is -- a boot-protocol mouse is relative-only -- so it slams into the top-left corner first and moves out from there, which is visible on screen. Exercised against an in-test USB/IP client that decodes the HID reports; never against a real Linux usbip host, so nothing here has been seen by a kernel HID driver.")
385 .max_inbound_bytes(crate::server::usb::guard::MAX_TRANSFER_BUFFER_BYTES)
388 .build()
389 }
390
391 fn description(&self) -> &'static str {
392 "Virtual USB HID mouse device"
393 }
394
395 fn example_prompt(&self) -> &'static str {
396 "Create a USB mouse and move it in a circle when attached"
397 }
398
399 fn group_name(&self) -> &'static str {
400 "USB Devices"
401 }
402
403 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
404 use crate::llm::actions::StartupExamples;
405 use serde_json::json;
406
407 let script = r#"import json, sys
410data = json.load(sys.stdin)
411event = data["event"]
412if data["event_type_id"] == "usb_mouse_attached":
413 actions = [{"type": "move_relative", "x": 10, "y": -5}]
414else:
415 actions = []
416print(json.dumps({"actions": actions}))"#;
417
418 StartupExamples::new(
419 json!({
421 "type": "open_server",
422 "port": 3240,
423 "base_stack": "usb-mouse",
424 "instruction": "Create a USB mouse and move it in a circle pattern when attached"
425 }),
426 json!({
428 "type": "open_server",
429 "port": 3240,
430 "base_stack": "usb-mouse",
431 "event_handlers": [{
432 "event_pattern": "usb_mouse_attached",
433 "handler": {
434 "type": "script",
435 "language": "python",
436 "code": script
437 }
438 }]
439 }),
440 json!({
442 "type": "open_server",
443 "port": 3240,
444 "base_stack": "usb-mouse",
445 "event_handlers": [{
446 "event_pattern": "usb_mouse_attached",
447 "handler": {
448 "type": "static",
449 "actions": [{
450 "type": "move_relative",
451 "x": 100,
452 "y": 50
453 }]
454 }
455 }]
456 }),
457 )
458 }
459}
460
461#[cfg(all(unix, feature = "libusb"))]
463impl Server for UsbMouseProtocol {
464 fn spawn(
465 &self,
466 ctx: crate::protocol::SpawnContext,
467 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
468 {
469 Box::pin(async move {
470 let secs = |name: &str| -> anyhow::Result<Option<u64>> {
476 Ok(ctx
477 .startup_params
478 .as_ref()
479 .map(|p| p.get_optional_u64(name))
480 .transpose()?
481 .flatten())
482 };
483 let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
484 let idle_timeout_secs = secs("idle_timeout_secs")?;
485 crate::server::usb::mouse::UsbMouseServer::spawn_with_llm_actions(
486 ctx.legacy_listen_addr(),
487 ctx.llm_client,
488 ctx.state,
489 ctx.status_tx,
490 ctx.server_id,
491 first_byte_timeout_secs,
492 idle_timeout_secs,
493 )
494 .await
495 })
496 }
497
498 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
499 let action_type = action["type"]
500 .as_str()
501 .context("Action must have 'type' field")?;
502
503 match action_type {
508 "move_relative" => {
509 let x = movement_field(&action, "x", "move_relative")?;
510 let y = movement_field(&action, "y", "move_relative")?;
511 if x == 0 && y == 0 {
512 return Err(anyhow::anyhow!(
513 "move_relative with x=0 and y=0 would move nothing"
514 ));
515 }
516
517 let reports = split_movement(x, y);
518 let count = self.queue(&action, reports)?;
519 tracing::info!("USB mouse move ({}, {}) as {} report(s)", x, y, count);
520 Ok(ActionResult::NoAction)
521 }
522
523 "move_absolute" => {
524 let x = movement_field(&action, "x", "move_absolute")?;
525 let y = movement_field(&action, "y", "move_absolute")?;
526 let screen_width = screen_dimension(&action, "screen_width", 1920)?;
527 let screen_height = screen_dimension(&action, "screen_height", 1080)?;
528
529 if !(0..=screen_width).contains(&x) || !(0..=screen_height).contains(&y) {
530 return Err(anyhow::anyhow!(
531 "move_absolute target ({}, {}) is outside the {}x{} screen",
532 x,
533 y,
534 screen_width,
535 screen_height
536 ));
537 }
538
539 let mut reports = split_movement(-screen_width - 127, -screen_height - 127);
546 reports.extend(split_movement(x, y));
547 let count = self.queue(&action, reports)?;
548 tracing::info!(
549 "USB mouse move_absolute to ({}, {}) on {}x{} as {} report(s)",
550 x,
551 y,
552 screen_width,
553 screen_height,
554 count
555 );
556 Ok(ActionResult::NoAction)
557 }
558
559 "click" => {
560 let button = action["button"]
561 .as_str()
562 .context("click requires 'button' field")?;
563 let bit = button_bit(button)?;
564
565 let mut press = MouseReport::new();
568 press.buttons = bit;
569 self.queue(&action, vec![press, MouseReport::new()])?;
570 tracing::info!("USB mouse {} click", button);
571 Ok(ActionResult::NoAction)
572 }
573
574 "scroll" => {
575 let direction = action["direction"]
576 .as_str()
577 .context("scroll requires 'direction' field")?;
578 let amount = action["amount"].as_i64().unwrap_or(1);
579 if amount <= 0 {
580 return Err(anyhow::anyhow!("scroll 'amount' must be positive"));
581 }
582
583 let step: i8 = match direction.to_ascii_lowercase().as_str() {
584 "up" => 1,
585 "down" => -1,
586 other => {
587 return Err(anyhow::anyhow!(
588 "unknown scroll direction '{}'; expected up or down",
589 other
590 ))
591 }
592 };
593
594 let reports: Vec<MouseReport> = (0..amount.min(64))
597 .map(|_| {
598 let mut r = MouseReport::new();
599 r.wheel = step;
600 r
601 })
602 .collect();
603 let count = self.queue(&action, reports)?;
604 tracing::info!("USB mouse scroll {} x{}", direction, count);
605 Ok(ActionResult::NoAction)
606 }
607
608 "drag" => {
609 let start_x = movement_field(&action, "start_x", "drag")?;
610 let start_y = movement_field(&action, "start_y", "drag")?;
611 let end_x = movement_field(&action, "end_x", "drag")?;
612 let end_y = movement_field(&action, "end_y", "drag")?;
613 let duration_ms = action["duration_ms"]
617 .as_i64()
618 .unwrap_or(500)
619 .clamp(0, 60_000);
620 let button = action["button"].as_str().unwrap_or("left");
621 let bit = button_bit(button)?;
622
623 let steps = (duration_ms / 10).clamp(1, 64);
627 let (dx, dy) = (end_x - start_x, end_y - start_y);
628
629 let mut reports = Vec::new();
630 let mut press = MouseReport::new();
631 press.buttons = bit;
632 reports.push(press);
633
634 let mut moved_x = 0i64;
635 let mut moved_y = 0i64;
636 for step in 1..=steps {
637 let target_x = dx * step / steps;
638 let target_y = dy * step / steps;
639 for mut report in split_movement(target_x - moved_x, target_y - moved_y) {
640 report.buttons = bit;
643 reports.push(report);
644 }
645 moved_x = target_x;
646 moved_y = target_y;
647 }
648 reports.push(MouseReport::new()); let count = self.queue(&action, reports)?;
651 tracing::info!(
652 "USB mouse drag ({}, {}) -> ({}, {}) with {} held, {} report(s)",
653 start_x,
654 start_y,
655 end_x,
656 end_y,
657 button,
658 count
659 );
660 Ok(ActionResult::NoAction)
661 }
662
663 "wait_for_more" => Ok(ActionResult::WaitForMore),
664 _ => Err(anyhow::anyhow!("Unknown action type: {}", action_type)),
665 }
666 }
667}
668
669#[cfg(all(unix, feature = "libusb"))]
681fn connection_id_parameter() -> Parameter {
682 Parameter {
683 name: "connection_id".to_string(),
684 type_hint: "string".to_string(),
685 description: "Which attached host to act on, as the event reports it (e.g. \"conn-2\"). \
686 Omit it when only one host is attached; required when there are several."
687 .to_string(),
688 required: false,
689 }
690}
691
692#[cfg(all(unix, feature = "libusb"))]
693fn move_relative_action() -> ActionDefinition {
694 ActionDefinition {
695 name: "move_relative".to_string(),
696 description: "Move mouse cursor by relative offset".to_string(),
697 parameters: vec![
698 Parameter {
699 name: "x".to_string(),
700 type_hint: "number".to_string(),
701 description: "Horizontal movement in pixels (-127 to 127)".to_string(),
702 required: true,
703 },
704 Parameter {
705 name: "y".to_string(),
706 type_hint: "number".to_string(),
707 description: "Vertical movement in pixels (-127 to 127)".to_string(),
708 required: true,
709 },
710 connection_id_parameter(),
711 ],
712 example: json!({
713 "type": "move_relative",
714 "x": 10,
715 "y": -5
716 }),
717 log_template: Some(
718 LogTemplate::new()
719 .with_info("-> USB mouse move ({x}, {y})")
720 .with_debug("USB-Mouse move_relative: x={x} y={y}"),
721 ),
722 }
723}
724
725#[cfg(all(unix, feature = "libusb"))]
726fn move_absolute_action() -> ActionDefinition {
727 ActionDefinition {
728 name: "move_absolute".to_string(),
729 description: "Move mouse cursor to absolute screen position".to_string(),
730 parameters: vec![
731 Parameter {
732 name: "x".to_string(),
733 type_hint: "number".to_string(),
734 description: "Target X coordinate".to_string(),
735 required: true,
736 },
737 Parameter {
738 name: "y".to_string(),
739 type_hint: "number".to_string(),
740 description: "Target Y coordinate".to_string(),
741 required: true,
742 },
743 Parameter {
744 name: "screen_width".to_string(),
745 type_hint: "number".to_string(),
746 description: "Screen width in pixels (default: 1920)".to_string(),
747 required: false,
748 },
749 Parameter {
750 name: "screen_height".to_string(),
751 type_hint: "number".to_string(),
752 description: "Screen height in pixels (default: 1080)".to_string(),
753 required: false,
754 },
755 connection_id_parameter(),
756 ],
757 example: json!({
758 "type": "move_absolute",
759 "x": 960,
760 "y": 540,
761 "screen_width": 1920,
762 "screen_height": 1080
763 }),
764 log_template: Some(
765 LogTemplate::new()
766 .with_info("-> USB mouse move to ({x}, {y})")
767 .with_debug(
768 "USB-Mouse move_absolute: x={x} y={y} screen={screen_width}x{screen_height}",
769 ),
770 ),
771 }
772}
773
774#[cfg(all(unix, feature = "libusb"))]
775fn click_action() -> ActionDefinition {
776 ActionDefinition {
777 name: "click".to_string(),
778 description: "Click a mouse button".to_string(),
779 parameters: vec![
780 Parameter {
781 name: "button".to_string(),
782 type_hint: "string".to_string(),
783 description: "Button to click: 'left', 'right', or 'middle'".to_string(),
784 required: true,
785 },
786 connection_id_parameter(),
787 ],
788 example: json!({
789 "type": "click",
790 "button": "left"
791 }),
792 log_template: Some(
793 LogTemplate::new()
794 .with_info("-> USB mouse click {button}")
795 .with_debug("USB-Mouse click: button={button}"),
796 ),
797 }
798}
799
800#[cfg(all(unix, feature = "libusb"))]
801fn scroll_action() -> ActionDefinition {
802 ActionDefinition {
803 name: "scroll".to_string(),
804 description: "Scroll the mouse wheel".to_string(),
805 parameters: vec![
806 Parameter {
807 name: "direction".to_string(),
808 type_hint: "string".to_string(),
809 description: "Scroll direction: 'up' or 'down'".to_string(),
810 required: true,
811 },
812 Parameter {
813 name: "amount".to_string(),
814 type_hint: "number".to_string(),
815 description: "Number of scroll steps (default: 1)".to_string(),
816 required: false,
817 },
818 connection_id_parameter(),
819 ],
820 example: json!({
821 "type": "scroll",
822 "direction": "up",
823 "amount": 3
824 }),
825 log_template: Some(
826 LogTemplate::new()
827 .with_info("-> USB mouse scroll {direction} ({amount})")
828 .with_debug("USB-Mouse scroll: direction={direction} amount={amount}"),
829 ),
830 }
831}
832
833#[cfg(all(unix, feature = "libusb"))]
834fn drag_action() -> ActionDefinition {
835 ActionDefinition {
836 name: "drag".to_string(),
837 description: "Drag from one position to another with a button held (left by default)"
838 .to_string(),
839 parameters: vec![
840 Parameter {
844 name: "button".to_string(),
845 type_hint: "string".to_string(),
846 description: "Button to hold during the drag: 'left' (default), 'right' or \
847 'middle'"
848 .to_string(),
849 required: false,
850 },
851 Parameter {
852 name: "start_x".to_string(),
853 type_hint: "number".to_string(),
854 description: "Starting X coordinate".to_string(),
855 required: true,
856 },
857 Parameter {
858 name: "start_y".to_string(),
859 type_hint: "number".to_string(),
860 description: "Starting Y coordinate".to_string(),
861 required: true,
862 },
863 Parameter {
864 name: "end_x".to_string(),
865 type_hint: "number".to_string(),
866 description: "Ending X coordinate".to_string(),
867 required: true,
868 },
869 Parameter {
870 name: "end_y".to_string(),
871 type_hint: "number".to_string(),
872 description: "Ending Y coordinate".to_string(),
873 required: true,
874 },
875 Parameter {
876 name: "duration_ms".to_string(),
877 type_hint: "number".to_string(),
878 description: "Duration of drag in milliseconds (default: 500)".to_string(),
879 required: false,
880 },
881 connection_id_parameter(),
882 ],
883 example: json!({
884 "type": "drag",
885 "start_x": 100,
886 "start_y": 100,
887 "end_x": 200,
888 "end_y": 200,
889 "duration_ms": 500
890 }),
891 log_template: Some(
892 LogTemplate::new()
893 .with_info("-> USB mouse drag ({start_x},{start_y}) to ({end_x},{end_y})")
894 .with_debug("USB-Mouse drag: from=({start_x},{start_y}) to=({end_x},{end_y}) duration={duration_ms}ms"),
895 ),
896 }
897}
898
899#[cfg(all(unix, feature = "libusb"))]
900fn wait_for_more_action() -> ActionDefinition {
901 ActionDefinition {
902 name: "wait_for_more".to_string(),
903 description: "Wait for more input from the host (do nothing for now)".to_string(),
904 parameters: vec![],
905 example: json!({
906 "type": "wait_for_more"
907 }),
908 log_template: Some(
909 LogTemplate::new()
910 .with_info("-> USB mouse wait for more")
911 .with_debug("USB-Mouse wait_for_more"),
912 ),
913 }
914}