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::state::app_state::AppState;
16#[cfg(all(unix, feature = "libusb"))]
17use anyhow::{Context, Result};
18#[cfg(all(unix, feature = "libusb"))]
19use serde_json::json;
20#[cfg(all(unix, feature = "libusb"))]
21use std::collections::HashMap;
22#[cfg(all(unix, feature = "libusb"))]
23use std::sync::{Arc, LazyLock};
24#[cfg(all(unix, feature = "libusb"))]
25use tokio::sync::Mutex;
26
27#[cfg(all(unix, feature = "libusb"))]
29pub static USB_KEYBOARD_ATTACHED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
30 EventType::new(
31 "usb_keyboard_attached",
32 "A USB/IP host attached to this virtual keyboard and will now accept HID reports. Type \
33 text, press a key or a combination, or wait_for_more to stay idle.",
34 json!({
35 "type": "type_text",
36 "text": "Hello, World!",
37 "typing_speed_ms": 50
38 }),
39 )
40 .with_parameters(vec![Parameter {
41 name: "connection_id".to_string(),
42 type_hint: "string".to_string(),
43 description: "Connection ID of the USB/IP session. Actions may quote it back, but it \
44 is optional: with one host attached it is inferred."
45 .to_string(),
46 required: true,
47 }])
48 .with_actions(vec![
49 type_text_action(),
50 press_key_action(),
51 press_key_combo_action(),
52 release_all_keys_action(),
53 wait_for_more_action(),
54 ])
55 .with_alternative_example(json!({"type": "wait_for_more"}))
56});
57
58#[cfg(all(unix, feature = "libusb"))]
59pub static USB_KEYBOARD_DETACHED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
60 EventType::new(
61 "usb_keyboard_detached",
62 "The host detached from this virtual keyboard. Purely informational - the USB/IP \
63 session is gone, so a HID report has nowhere to go.",
64 json!({"type": "show_message", "message": "USB keyboard host detached"}),
65 )
66 .with_parameters(vec![Parameter {
67 name: "connection_id".to_string(),
68 type_hint: "string".to_string(),
69 description: "Connection ID of the USB/IP session. Actions may quote it back, but it \
70 is optional: with one host attached it is inferred."
71 .to_string(),
72 required: true,
73 }])
74 .with_no_actions()
75});
76
77#[cfg(all(unix, feature = "libusb"))]
78pub static USB_KEYBOARD_LED_STATUS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
79 EventType::new(
80 "usb_keyboard_led_status",
81 "The host changed the keyboard LEDs (Num Lock, Caps Lock, Scroll Lock). This is how the \
82 host reports lock state back to a keyboard - use it to tell whether Caps Lock is on \
83 before typing, and correct it with press_key if it is not what you want.",
84 json!({
85 "type": "wait_for_more"
86 }),
87 )
88 .with_parameters(vec![
89 Parameter {
90 name: "connection_id".to_string(),
91 type_hint: "string".to_string(),
92 description: "Connection ID of the USB/IP session. Actions may quote it back, but it \
93 is optional: with one host attached it is inferred."
94 .to_string(),
95 required: true,
96 },
97 Parameter {
98 name: "num_lock".to_string(),
99 type_hint: "boolean".to_string(),
100 description: "Num Lock LED state".to_string(),
101 required: true,
102 },
103 Parameter {
104 name: "caps_lock".to_string(),
105 type_hint: "boolean".to_string(),
106 description: "Caps Lock LED state".to_string(),
107 required: true,
108 },
109 Parameter {
110 name: "scroll_lock".to_string(),
111 type_hint: "boolean".to_string(),
112 description: "Scroll Lock LED state".to_string(),
113 required: true,
114 },
115 ])
116 .with_actions(vec![
117 type_text_action(),
118 press_key_action(),
119 press_key_combo_action(),
120 release_all_keys_action(),
121 wait_for_more_action(),
122 ])
123 .with_alternative_example(json!({"type": "press_key", "key": "capslock"}))
124});
125
126#[cfg(all(unix, feature = "libusb"))]
128pub struct UsbKeyboardProtocol {
129 #[allow(dead_code)]
131 connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
132 handlers: Arc<std::sync::Mutex<HashMap<ConnectionId, SharedHandler>>>,
139}
140
141#[cfg(all(unix, feature = "libusb"))]
143type SharedHandler = Arc<std::sync::Mutex<Box<dyn usbip::UsbInterfaceHandler + Send>>>;
144
145#[cfg(all(unix, feature = "libusb"))]
146#[derive(Clone)]
147pub struct ConnectionData {
148 }
151
152#[cfg(all(unix, feature = "libusb"))]
153impl Default for UsbKeyboardProtocol {
154 fn default() -> Self {
155 Self::new()
156 }
157}
158
159#[cfg(all(unix, feature = "libusb"))]
160impl UsbKeyboardProtocol {
161 pub fn new() -> Self {
162 Self {
163 connections: Arc::new(Mutex::new(HashMap::new())),
164 handlers: Arc::new(std::sync::Mutex::new(HashMap::new())),
165 }
166 }
167
168 pub fn set_handler(&self, connection_id: ConnectionId, handler: SharedHandler) {
170 if let Ok(mut handlers) = self.handlers.lock() {
171 handlers.insert(connection_id, handler);
172 }
173 }
174
175 pub fn remove_handler(&self, connection_id: ConnectionId) {
177 if let Ok(mut handlers) = self.handlers.lock() {
178 handlers.remove(&connection_id);
179 }
180 }
181
182 fn resolve_handler(&self, action: &serde_json::Value) -> Result<SharedHandler> {
194 let handlers = self
195 .handlers
196 .lock()
197 .unwrap_or_else(|poisoned| poisoned.into_inner());
198
199 let requested = action["connection_id"].as_u64().or_else(|| {
200 action["connection_id"].as_str().and_then(|s| {
201 let s = s.trim();
202 s.strip_prefix("conn-").unwrap_or(s).parse::<u64>().ok()
203 })
204 });
205
206 if let Some(id) = requested {
207 let connection_id = u32::try_from(id).map(ConnectionId::new).map_err(|_| {
210 anyhow::anyhow!("connection_id {} is not a valid connection number", id)
211 })?;
212 return handlers.get(&connection_id).cloned().ok_or_else(|| {
213 anyhow::anyhow!("No USB keyboard attached on connection {}", connection_id)
214 });
215 }
216
217 match handlers.len() {
218 0 => Err(anyhow::anyhow!(
219 "No USB/IP host is attached to this keyboard, so a keystroke has nowhere to go"
220 )),
221 1 => Ok(handlers.values().next().cloned().expect("len checked")),
222 _ => {
223 let mut ids: Vec<u32> = handlers.keys().map(|c| c.as_u32()).collect();
224 ids.sort_unstable();
225 Err(anyhow::anyhow!(
226 "{} USB/IP hosts are attached ({:?}); the action must name one with \
227 'connection_id'",
228 ids.len(),
229 ids
230 ))
231 }
232 }
233 }
234
235 fn queue_reports(
239 &self,
240 handler: SharedHandler,
241 reports: Vec<usbip::hid::UsbHidKeyboardReport>,
242 ) -> Result<()> {
243 let mut handler_guard = handler
244 .lock()
245 .map_err(|_| anyhow::anyhow!("USB keyboard handler mutex poisoned"))?;
246 let hid = handler_guard
247 .as_any()
248 .downcast_mut::<crate::server::usb::keyboard::handler::NetGetKeyboardHandler>()
249 .context("Handler is not a USB HID keyboard handler")?;
250 for report in reports {
251 hid.queue(report);
252 }
253 Ok(())
254 }
255}
256
257#[cfg(all(unix, feature = "libusb"))]
259impl Protocol for UsbKeyboardProtocol {
260 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
261 vec![
262 crate::llm::actions::ParameterDefinition {
263 name: "first_byte_timeout_secs".to_string(),
264 type_hint: "number".to_string(),
265 description: "Seconds a peer that has only connected may send no USB/IP message \
266 at all before the server closes it. Default 30. USB/IP is \
267 client-speaks-first and nothing here answers a peer that has not \
268 asked - `usbip attach` and `usbip list -r` both write their \
269 request as soon as the socket is up - so a short bound is the \
270 honest one. Raise it only if your host is reached over a link \
271 where a handshake can take that long."
272 .to_string(),
273 required: false,
274 example: serde_json::json!(30),
275 default: Some(serde_json::json!(
276 crate::server::usb::guard::DEFAULT_FIRST_MESSAGE_TIMEOUT.as_secs()
277 )),
278 },
279 crate::llm::actions::ParameterDefinition {
280 name: "idle_timeout_secs".to_string(),
281 type_hint: "number".to_string(),
282 description: "Seconds an attached host may go without sending a further USB/IP \
283 message before the server closes the session. Default 1800. \
284 USB/IP has no keepalive and nothing obliges an attached host to \
285 speak - a keyboard a host has imported and is asking nothing of \
286 is idle by design - so this is deliberately long and exists to \
287 reap a peer that is gone rather than to police one that is here. \
288 Lower it if you are exporting to strangers."
289 .to_string(),
290 required: false,
291 example: serde_json::json!(1800),
292 default: Some(serde_json::json!(
293 crate::server::usb::guard::DEFAULT_IDLE_TIMEOUT.as_secs()
294 )),
295 },
296 ]
297 }
298
299 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
300 vec![]
301 }
302
303 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
304 vec![
305 type_text_action(),
306 press_key_action(),
307 press_key_combo_action(),
308 release_all_keys_action(),
309 wait_for_more_action(),
310 ]
311 }
312
313 fn protocol_name(&self) -> &'static str {
314 "USB-Keyboard"
315 }
316
317 fn get_event_types(&self) -> Vec<EventType> {
318 vec![
319 USB_KEYBOARD_ATTACHED_EVENT.clone(),
320 USB_KEYBOARD_DETACHED_EVENT.clone(),
321 USB_KEYBOARD_LED_STATUS_EVENT.clone(),
322 ]
323 }
324
325 fn stack_name(&self) -> &'static str {
326 "USB>HID>Keyboard"
327 }
328
329 fn keywords(&self) -> Vec<&'static str> {
330 vec!["usb", "keyboard", "hid", "input", "typing"]
331 }
332
333 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
334 crate::protocol::metadata::ProtocolMetadataV2::builder()
335 .state(crate::protocol::metadata::DevelopmentState::Experimental)
336 .deliberately_silent()
340 .implementation("Virtual USB HID keyboard device using USB/IP protocol")
341 .llm_control("LLM controls keyboard input (typing, key presses, combinations)")
342 .e2e_testing("Mocked E2E over the USB/IP socket; real HID typing needs a Linux usbip client")
343 .privilege_requirement(crate::protocol::metadata::PrivilegeRequirement::None)
344 .notes("USB/IP runs on the accepted socket via usbip::handler (usbip 0.9, tokio 1.x), so the listen port is whatever the caller asks for and multiple instances can coexist. All three events are emitted: attached, detached, and led_status. led_status became reachable when the crate's UsbHidKeyboardHandler was wrapped by handler.rs -- the crate discards HID output reports, so a declared event could never fire; the wrapper also stops a control request the crate does not know (GET_PROTOCOL, GET_REPORT, and SET_REPORT itself) from hitting its unimplemented!() and panicking the session task. press_key_combo folds modifiers and up to six keys into one report. type_text/press_key cover the characters key_name_to_hid maps and refuse the rest rather than typing a different string. Exercised against an in-test USB/IP client that reads HID reports off the interrupt endpoint and writes LED reports; never against a real Linux usbip host, so nothing here has been seen by a kernel HID driver.")
345 .max_inbound_bytes(crate::server::usb::guard::MAX_TRANSFER_BUFFER_BYTES)
348 .build()
349 }
350
351 fn description(&self) -> &'static str {
352 "Virtual USB HID keyboard device"
353 }
354
355 fn example_prompt(&self) -> &'static str {
356 "Create a USB keyboard device and type 'hello world' when attached"
357 }
358
359 fn group_name(&self) -> &'static str {
360 "USB Devices"
361 }
362
363 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
364 use crate::llm::actions::StartupExamples;
365 use serde_json::json;
366
367 let script = r#"import json, sys
370data = json.load(sys.stdin)
371event = data["event"]
372if data["event_type_id"] == "usb_keyboard_attached":
373 actions = [{"type": "type_text", "text": "hello from netget"}]
374else:
375 actions = []
376print(json.dumps({"actions": actions}))"#;
377
378 StartupExamples::new(
379 json!({
381 "type": "open_server",
382 "port": 3240,
383 "base_stack": "usb-keyboard",
384 "instruction": "Create a USB keyboard device and type 'hello world' when attached"
385 }),
386 json!({
388 "type": "open_server",
389 "port": 3240,
390 "base_stack": "usb-keyboard",
391 "event_handlers": [{
392 "event_pattern": "usb_keyboard_attached",
393 "handler": {
394 "type": "script",
395 "language": "python",
396 "code": script
397 }
398 }]
399 }),
400 json!({
402 "type": "open_server",
403 "port": 3240,
404 "base_stack": "usb-keyboard",
405 "event_handlers": [{
406 "event_pattern": "usb_keyboard_attached",
407 "handler": {
408 "type": "static",
409 "actions": [{
410 "type": "type_text",
411 "text": "Hello from NetGet!",
412 "typing_speed_ms": 50
413 }]
414 }
415 }]
416 }),
417 )
418 }
419}
420
421#[cfg(all(unix, feature = "libusb"))]
423impl Server for UsbKeyboardProtocol {
424 fn spawn(
425 &self,
426 ctx: crate::protocol::SpawnContext,
427 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
428 {
429 Box::pin(async move {
430 let secs = |name: &str| -> anyhow::Result<Option<u64>> {
436 Ok(ctx
437 .startup_params
438 .as_ref()
439 .map(|p| p.get_optional_u64(name))
440 .transpose()?
441 .flatten())
442 };
443 let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
444 let idle_timeout_secs = secs("idle_timeout_secs")?;
445 crate::server::usb::keyboard::UsbKeyboardServer::spawn_with_llm_actions(
446 ctx.legacy_listen_addr(),
447 ctx.llm_client,
448 ctx.state,
449 ctx.status_tx,
450 ctx.server_id,
451 first_byte_timeout_secs,
452 idle_timeout_secs,
453 )
454 .await
455 })
456 }
457
458 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
459 let action_type = action["type"]
460 .as_str()
461 .context("Action must have 'type' field")?;
462
463 if action_type == "wait_for_more" {
464 return Ok(ActionResult::WaitForMore);
465 }
466
467 let handler = self.resolve_handler(&action)?;
470
471 match action_type {
472 "type_text" => {
473 let text = action["text"]
474 .as_str()
475 .context("type_text requires 'text' field")?;
476
477 let mut reports = Vec::with_capacity(text.len());
478 let mut unsupported: Vec<char> = Vec::new();
479 for ch in text.chars() {
480 match ascii_to_hid(ch) {
481 Some((modifier, key)) => reports.push(usbip::hid::UsbHidKeyboardReport {
482 modifier,
483 keys: [key, 0, 0, 0, 0, 0],
484 }),
485 None => unsupported.push(ch),
486 }
487 }
488
489 if !unsupported.is_empty() {
490 return Err(anyhow::anyhow!(
494 "type_text cannot produce these characters on a US HID keyboard: {:?}",
495 unsupported
496 ));
497 }
498
499 let count = reports.len();
500 let typing_speed_ms = action["typing_speed_ms"].as_u64().unwrap_or(0).min(5_000);
505
506 if count == 0 {
507 return Err(anyhow::anyhow!("type_text requires non-empty 'text'"));
508 }
509
510 if typing_speed_ms == 0 {
511 self.queue_reports(handler, reports)?;
512 } else {
513 let mut rest = reports;
519 let first = rest.remove(0);
520 self.queue_reports(handler.clone(), vec![first])?;
521
522 tokio::runtime::Handle::current().spawn(async move {
523 for report in rest {
524 tokio::time::sleep(std::time::Duration::from_millis(typing_speed_ms))
525 .await;
526 let mut guard = handler
527 .lock()
528 .unwrap_or_else(|poisoned| poisoned.into_inner());
529 if let Some(hid) = guard.as_any().downcast_mut::<
530 crate::server::usb::keyboard::handler::NetGetKeyboardHandler,
531 >() {
532 hid.queue(report);
533 }
534 }
535 });
536 }
537
538 tracing::info!(
539 "Queued {} keyboard reports (speed {}ms)",
540 count,
541 typing_speed_ms
542 );
543 Ok(ActionResult::NoAction)
544 }
545 "press_key" => {
546 let key = action["key"]
547 .as_str()
548 .context("press_key requires 'key' field")?;
549
550 let (mut modifier, keycode) =
551 key_name_to_hid(key).with_context(|| format!("Unsupported key: {}", key))?;
552
553 if let Some(arr) = action["modifiers"].as_array() {
554 for m in arr.iter().filter_map(|v| v.as_str()) {
555 modifier |= modifier_name_to_bit(m)
556 .with_context(|| format!("Unsupported modifier: {}", m))?;
557 }
558 }
559
560 self.queue_reports(
561 handler,
562 vec![usbip::hid::UsbHidKeyboardReport {
563 modifier,
564 keys: [keycode, 0, 0, 0, 0, 0],
565 }],
566 )?;
567 tracing::info!("Queued key press '{}' (modifier={:#04x})", key, modifier);
568 Ok(ActionResult::NoAction)
569 }
570 "press_key_combo" => {
571 let names = action["keys"]
572 .as_array()
573 .context("press_key_combo requires 'keys' array")?
574 .iter()
575 .filter_map(|v| v.as_str())
576 .collect::<Vec<_>>();
577
578 let mut modifier = 0u8;
581 let mut keys = [0u8; 6];
582 let mut n = 0usize;
583 for name in &names {
584 if let Some(bit) = modifier_name_to_bit(name) {
585 modifier |= bit;
586 continue;
587 }
588 let (extra_mod, keycode) = key_name_to_hid(name)
589 .with_context(|| format!("Unsupported key in combo: {}", name))?;
590 if n >= keys.len() {
591 return Err(anyhow::anyhow!(
592 "press_key_combo supports at most {} non-modifier keys, got {}",
593 keys.len(),
594 names.len()
595 ));
596 }
597 modifier |= extra_mod;
598 keys[n] = keycode;
599 n += 1;
600 }
601
602 if modifier == 0 && n == 0 {
603 return Err(anyhow::anyhow!("press_key_combo requires at least one key"));
604 }
605
606 self.queue_reports(
607 handler,
608 vec![usbip::hid::UsbHidKeyboardReport { modifier, keys }],
609 )?;
610 tracing::info!("Queued key combo {:?} (modifier={:#04x})", names, modifier);
611 Ok(ActionResult::NoAction)
612 }
613 "release_all_keys" => {
614 self.queue_reports(
616 handler,
617 vec![usbip::hid::UsbHidKeyboardReport {
618 modifier: 0,
619 keys: [0; 6],
620 }],
621 )?;
622 tracing::info!("Released all keys");
623 Ok(ActionResult::NoAction)
624 }
625 _ => Err(anyhow::anyhow!("Unknown action type: {}", action_type)),
626 }
627 }
628}
629
630#[cfg(all(unix, feature = "libusb"))]
639mod modifier_bit {
640 pub const LEFT_CTRL: u8 = 0x01;
641 pub const LEFT_SHIFT: u8 = 0x02;
642 pub const LEFT_ALT: u8 = 0x04;
643 pub const LEFT_GUI: u8 = 0x08;
644}
645
646#[cfg(all(unix, feature = "libusb"))]
648fn modifier_name_to_bit(name: &str) -> Option<u8> {
649 match name.to_ascii_lowercase().as_str() {
650 "ctrl" | "control" => Some(modifier_bit::LEFT_CTRL),
651 "shift" => Some(modifier_bit::LEFT_SHIFT),
652 "alt" | "option" => Some(modifier_bit::LEFT_ALT),
653 "gui" | "meta" | "super" | "win" | "windows" | "cmd" | "command" => {
654 Some(modifier_bit::LEFT_GUI)
655 }
656 _ => None,
657 }
658}
659
660#[cfg(all(unix, feature = "libusb"))]
662fn ascii_to_hid(c: char) -> Option<(u8, u8)> {
663 const SHIFT: u8 = modifier_bit::LEFT_SHIFT;
664 Some(match c {
665 'a'..='z' => (0, c as u8 - b'a' + 0x04),
666 'A'..='Z' => (SHIFT, c as u8 - b'A' + 0x04),
667 '1'..='9' => (0, c as u8 - b'1' + 0x1E),
668 '0' => (0, 0x27),
669 '\n' | '\r' => (0, 0x28),
670 '\t' => (0, 0x2B),
671 ' ' => (0, 0x2C),
672 '-' => (0, 0x2D),
673 '=' => (0, 0x2E),
674 '[' => (0, 0x2F),
675 ']' => (0, 0x30),
676 '\\' => (0, 0x31),
677 ';' => (0, 0x33),
678 '\'' => (0, 0x34),
679 '`' => (0, 0x35),
680 ',' => (0, 0x36),
681 '.' => (0, 0x37),
682 '/' => (0, 0x38),
683 '!' => (SHIFT, 0x1E),
684 '@' => (SHIFT, 0x1F),
685 '#' => (SHIFT, 0x20),
686 '$' => (SHIFT, 0x21),
687 '%' => (SHIFT, 0x22),
688 '^' => (SHIFT, 0x23),
689 '&' => (SHIFT, 0x24),
690 '*' => (SHIFT, 0x25),
691 '(' => (SHIFT, 0x26),
692 ')' => (SHIFT, 0x27),
693 '_' => (SHIFT, 0x2D),
694 '+' => (SHIFT, 0x2E),
695 '{' => (SHIFT, 0x2F),
696 '}' => (SHIFT, 0x30),
697 '|' => (SHIFT, 0x31),
698 ':' => (SHIFT, 0x33),
699 '"' => (SHIFT, 0x34),
700 '~' => (SHIFT, 0x35),
701 '<' => (SHIFT, 0x36),
702 '>' => (SHIFT, 0x37),
703 '?' => (SHIFT, 0x38),
704 _ => return None,
705 })
706}
707
708#[cfg(all(unix, feature = "libusb"))]
711fn key_name_to_hid(name: &str) -> Option<(u8, u8)> {
712 let mut chars = name.chars();
713 if let (Some(c), None) = (chars.next(), chars.next()) {
714 if let Some(mapped) = ascii_to_hid(c) {
715 return Some(mapped);
716 }
717 }
718
719 let keycode = match name.to_ascii_lowercase().as_str() {
720 "enter" | "return" => 0x28,
721 "esc" | "escape" => 0x29,
722 "backspace" => 0x2A,
723 "tab" => 0x2B,
724 "space" | "spacebar" => 0x2C,
725 "capslock" | "caps_lock" => 0x39,
726 "f1" => 0x3A,
727 "f2" => 0x3B,
728 "f3" => 0x3C,
729 "f4" => 0x3D,
730 "f5" => 0x3E,
731 "f6" => 0x3F,
732 "f7" => 0x40,
733 "f8" => 0x41,
734 "f9" => 0x42,
735 "f10" => 0x43,
736 "f11" => 0x44,
737 "f12" => 0x45,
738 "printscreen" | "print_screen" => 0x46,
739 "scrolllock" | "scroll_lock" => 0x47,
740 "pause" => 0x48,
741 "insert" => 0x49,
742 "home" => 0x4A,
743 "pageup" | "page_up" => 0x4B,
744 "delete" | "del" => 0x4C,
745 "end" => 0x4D,
746 "pagedown" | "page_down" => 0x4E,
747 "right" | "rightarrow" | "arrow_right" => 0x4F,
748 "left" | "leftarrow" | "arrow_left" => 0x50,
749 "down" | "downarrow" | "arrow_down" => 0x51,
750 "up" | "uparrow" | "arrow_up" => 0x52,
751 "numlock" | "num_lock" => 0x53,
752 _ => return None,
753 };
754 Some((0, keycode))
755}
756
757#[cfg(all(unix, feature = "libusb"))]
769fn connection_id_parameter() -> Parameter {
770 Parameter {
771 name: "connection_id".to_string(),
772 type_hint: "string".to_string(),
773 description: "Which attached host to act on, as the event reports it (e.g. \"conn-2\"). \
774 Omit it when only one host is attached; required when there are several."
775 .to_string(),
776 required: false,
777 }
778}
779
780#[cfg(all(unix, feature = "libusb"))]
781fn type_text_action() -> ActionDefinition {
782 ActionDefinition {
783 name: "type_text".to_string(),
784 description: "Type text on the USB keyboard as if a user typed it".to_string(),
785 parameters: vec![
786 Parameter {
787 name: "text".to_string(),
788 type_hint: "string".to_string(),
789 description: "Text to type".to_string(),
790 required: true,
791 },
792 Parameter {
793 name: "typing_speed_ms".to_string(),
794 type_hint: "number".to_string(),
795 description: "Delay between keypresses in milliseconds (default: 0, meaning all \
796 keystrokes are queued at once)"
797 .to_string(),
798 required: false,
799 },
800 connection_id_parameter(),
801 ],
802 example: json!({
803 "type": "type_text",
804 "text": "Hello, World!",
805 "typing_speed_ms": 50
806 }),
807 log_template: Some(
808 LogTemplate::new()
809 .with_info("-> USB keyboard type '{text}'")
810 .with_debug("USB-Keyboard type_text: text='{text}' speed={typing_speed_ms}ms"),
811 ),
812 }
813}
814
815#[cfg(all(unix, feature = "libusb"))]
816fn press_key_action() -> ActionDefinition {
817 ActionDefinition {
818 name: "press_key".to_string(),
819 description: "Press a single key with optional modifier keys (Ctrl, Shift, Alt, GUI)"
820 .to_string(),
821 parameters: vec![
822 Parameter {
823 name: "key".to_string(),
824 type_hint: "string".to_string(),
825 description: "Key to press (e.g., 'a', 'enter', 'f1')".to_string(),
826 required: true,
827 },
828 Parameter {
829 name: "modifiers".to_string(),
830 type_hint: "array".to_string(),
831 description: "Modifier keys: 'ctrl', 'shift', 'alt', 'gui' (Windows/Command key)"
832 .to_string(),
833 required: false,
834 },
835 connection_id_parameter(),
836 ],
837 example: json!({
838 "type": "press_key",
839 "key": "c",
840 "modifiers": ["ctrl"]
841 }),
842 log_template: Some(
843 LogTemplate::new()
844 .with_info("-> USB keyboard press '{key}'")
845 .with_debug("USB-Keyboard press_key: key='{key}' modifiers={modifiers}"),
846 ),
847 }
848}
849
850#[cfg(all(unix, feature = "libusb"))]
851fn press_key_combo_action() -> ActionDefinition {
852 ActionDefinition {
853 name: "press_key_combo".to_string(),
854 description: "Press multiple keys simultaneously (e.g., Ctrl+Alt+Delete)".to_string(),
855 parameters: vec![
856 Parameter {
857 name: "keys".to_string(),
858 type_hint: "array".to_string(),
859 description: "Keys to press together: 'ctrl', 'alt', 'delete', etc.".to_string(),
860 required: true,
861 },
862 connection_id_parameter(),
863 ],
864 example: json!({
865 "type": "press_key_combo",
866 "keys": ["ctrl", "alt", "delete"]
867 }),
868 log_template: Some(
869 LogTemplate::new()
870 .with_info("-> USB keyboard combo {keys}")
871 .with_debug("USB-Keyboard press_key_combo: keys={keys}"),
872 ),
873 }
874}
875
876#[cfg(all(unix, feature = "libusb"))]
877fn release_all_keys_action() -> ActionDefinition {
878 ActionDefinition {
879 name: "release_all_keys".to_string(),
880 description: "Release all currently pressed keys (useful if stuck)".to_string(),
881 parameters: vec![connection_id_parameter()],
882 example: json!({
883 "type": "release_all_keys"
884 }),
885 log_template: Some(
886 LogTemplate::new()
887 .with_info("-> USB keyboard release all keys")
888 .with_debug("USB-Keyboard release_all_keys"),
889 ),
890 }
891}
892
893#[cfg(all(unix, feature = "libusb"))]
894fn wait_for_more_action() -> ActionDefinition {
895 ActionDefinition {
896 name: "wait_for_more".to_string(),
897 description: "Wait for more input from the host (do nothing for now)".to_string(),
898 parameters: vec![],
899 example: json!({
900 "type": "wait_for_more"
901 }),
902 log_template: Some(
903 LogTemplate::new()
904 .with_info("-> USB keyboard wait for more")
905 .with_debug("USB-Keyboard wait_for_more"),
906 ),
907 }
908}