netget/server/usb/mouse/mod.rs
1//! USB HID Mouse server implementation
2//!
3//! This module implements a virtual USB HID mouse using the USB/IP protocol.
4//! The mouse can be controlled by the LLM to move the cursor, click buttons,
5//! and scroll the wheel.
6//!
7//! ## What was wrong here
8//!
9//! `handle_connection` took the accepted socket as `_stream` and **dropped it**. It never ran
10//! a USB/IP session at all: it logged "NOT YET FUNCTIONAL - waiting for usbip crate mouse
11//! support", called the LLM once for `usb_mouse_attached`, and then parked on
12//! `sleep(Duration::from_secs(u64::MAX))` forever. So the device could not be enumerated, let
13//! alone imported; `usb_mouse_detached` had no emit site and could never fire; and the
14//! connection task leaked for the lifetime of the process.
15//!
16//! The premise was also stale. `usbip` 0.9 still has no mouse handler, but netget has had its
17//! own complete one in `handler.rs` — report descriptor, 4-byte reports, automatic release —
18//! for some time. Nothing was wired to it.
19//!
20//! ## When the LLM call fails, the mouse stays still — deliberately
21//!
22//! A HID mouse owes the host no reply. The host polls the interrupt IN endpoint and is NAKed
23//! whenever the pointer is not moving, which is what every mouse does most of the time; there is
24//! no HID vocabulary for "the device's brain is unreachable", and STALLing the endpoint is a
25//! fault that makes a host reset or unbind the device rather than a refusal it can act on.
26//! (`usbip` cannot express it either — returning `Err` from `handle_urb` aborts the session for
27//! the whole device.)
28//!
29//! What matters is the other direction, and it comes for free: a failed LLM call must never move
30//! the pointer or press a button. Reports exist only where an action produced them.
31//!
32//! So the failure is dual-logged at ERROR and nothing is sent.
33//! `tests/server/usb_mouse/llm_failure_test.rs` pins both halves.
34//!
35//! Because the wire looks identical in every failure mode, the log is the only place the cases
36//! are distinguishable, and each carries a `decision=` tag: `decision=fail_closed_llm_error`
37//! when the backend erred, `decision=no_action` when the model answered with nothing, and
38//! `decision=model_action` when it queued reports. There is no `decision=model_reject`: this
39//! protocol advertises no verb with which a model can refuse (`wait_for_more` is holding still,
40//! which is an action).
41
42pub mod actions;
43
44#[cfg(all(unix, feature = "libusb"))]
45pub mod handler;
46
47// Re-export protocol struct for registration
48#[cfg(all(unix, feature = "libusb"))]
49pub use actions::UsbMouseProtocol;
50
51#[cfg(all(unix, feature = "libusb"))]
52use anyhow::Result;
53#[cfg(all(unix, feature = "libusb"))]
54use std::collections::HashMap;
55#[cfg(all(unix, feature = "libusb"))]
56use std::net::SocketAddr;
57#[cfg(all(unix, feature = "libusb"))]
58use std::sync::Arc;
59#[cfg(all(unix, feature = "libusb"))]
60use tokio::sync::{mpsc, Mutex};
61#[cfg(all(unix, feature = "libusb"))]
62use tracing::{debug, error, info};
63
64#[cfg(all(unix, feature = "libusb"))]
65use crate::console_error;
66#[cfg(all(unix, feature = "libusb"))]
67use crate::llm::action_helper::call_llm;
68#[cfg(all(unix, feature = "libusb"))]
69use crate::llm::ollama_client::OllamaClient;
70#[cfg(all(unix, feature = "libusb"))]
71use crate::protocol::Event;
72#[cfg(all(unix, feature = "libusb"))]
73use crate::server::accept_bounded::{accept_bounded, ConnectionLimiter};
74#[cfg(all(unix, feature = "libusb"))]
75use crate::server::connection::ConnectionId;
76#[cfg(all(unix, feature = "libusb"))]
77use crate::server::usb::guard::{MAX_USBIP_CONNECTIONS, USBIP_NO_REFUSAL};
78#[cfg(all(unix, feature = "libusb"))]
79use crate::state::app_state::AppState;
80#[cfg(all(unix, feature = "libusb"))]
81use actions::{USB_MOUSE_ATTACHED_EVENT, USB_MOUSE_DETACHED_EVENT};
82
83/// Connection state for LLM processing
84#[cfg(all(unix, feature = "libusb"))]
85#[derive(Debug, Clone, PartialEq)]
86#[allow(dead_code)]
87enum ConnectionState {
88 Idle,
89 Processing,
90 Accumulating,
91}
92
93/// Per-connection data for USB mouse
94#[cfg(all(unix, feature = "libusb"))]
95struct ConnectionData {
96 state: ConnectionState,
97 #[allow(dead_code)]
98 memory: String,
99}
100
101/// USB HID Mouse server
102#[cfg(all(unix, feature = "libusb"))]
103pub struct UsbMouseServer;
104
105#[cfg(all(unix, feature = "libusb"))]
106impl UsbMouseServer {
107 /// Spawn the USB mouse server with LLM integration
108 ///
109 /// This creates a USB/IP server that exports a virtual HID mouse device.
110 /// The LLM can control the mouse through actions like move, click, and scroll.
111 pub async fn spawn_with_llm_actions(
112 listen_addr: SocketAddr,
113 llm_client: OllamaClient,
114 app_state: Arc<AppState>,
115 status_tx: mpsc::UnboundedSender<String>,
116 server_id: crate::state::ServerId,
117 first_byte_timeout_secs: Option<u64>,
118 idle_timeout_secs: Option<u64>,
119 ) -> Result<SocketAddr> {
120 // Create and bind TCP server for USB/IP protocol
121 let listener =
122 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
123 let local_addr = listener.local_addr()?;
124 info!("USB Mouse server listening on {}", local_addr);
125 let _ = status_tx.send(format!("USB Mouse server listening on {}", local_addr));
126
127 let connections = Arc::new(Mutex::new(HashMap::new()));
128 let protocol = Arc::new(crate::server::usb::mouse::UsbMouseProtocol::new());
129
130 // The two read deadlines, argued in `src/server/usb/guard.rs` and overridable per
131 // server. Copied into each connection task; the screen is the only thing that reads
132 // from the socket, so it is the only place they can be applied.
133 let deadlines = crate::server::usb::guard::UsbIpDeadlines::from_secs(
134 first_byte_timeout_secs,
135 idle_timeout_secs,
136 );
137 let task_registrar = app_state.clone();
138 // One emulated device per connection, so the cap is small and lives with the screen.
139 let limiter = ConnectionLimiter::new(MAX_USBIP_CONNECTIONS);
140 // Spawn accept loop for USB/IP connections
141 let accept_handle = tokio::spawn(async move {
142 loop {
143 match accept_bounded(
144 &listener,
145 &limiter,
146 USBIP_NO_REFUSAL,
147 "USB mouse",
148 Some(&status_tx),
149 )
150 .await
151 {
152 Ok((stream, remote_addr, permit)) => {
153 let connection_id =
154 ConnectionId::new(app_state.get_next_unified_id().await);
155 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
156 info!(
157 "USB/IP connection {} from {} (USB mouse device)",
158 connection_id, remote_addr
159 );
160
161 // Add connection to ServerInstance
162 use crate::state::server::{
163 ConnectionState as ServerConnectionState, ConnectionStatus,
164 ProtocolConnectionInfo,
165 };
166 let now = crate::utils::clock::Instant::now();
167 let conn_state = ServerConnectionState {
168 id: connection_id,
169 remote_addr,
170 local_addr: local_addr_conn,
171 bytes_sent: 0,
172 bytes_received: 0,
173 packets_sent: 0,
174 packets_received: 0,
175 last_activity: now,
176 status: ConnectionStatus::Active,
177 status_changed_at: now,
178 protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
179 "state": "WaitingForImport"
180 })),
181 };
182 app_state
183 .add_connection_to_server(server_id, conn_state)
184 .await;
185 let _ = status_tx.send("__UPDATE_UI__".to_string());
186
187 // Handle USB/IP connection
188 let llm_client_clone = llm_client.clone();
189 let app_state_clone = app_state.clone();
190 let status_tx_clone = status_tx.clone();
191 let connections_clone = connections.clone();
192 let protocol_clone = protocol.clone();
193
194 // Tracked, not detached: stop_server must abort this task too.
195 let task_owner = app_state.clone();
196 task_owner
197 .spawn_server_task(server_id, async move {
198 // Held for the life of the connection: dropping it early
199 // releases the slot while the device is still exported.
200 let _permit = permit;
201 if let Err(e) = Self::handle_connection(
202 stream,
203 connection_id,
204 remote_addr,
205 llm_client_clone,
206 app_state_clone,
207 status_tx_clone,
208 connections_clone,
209 protocol_clone,
210 server_id,
211 deadlines,
212 )
213 .await
214 {
215 error!("USB mouse connection {} error: {}", connection_id, e);
216 }
217 })
218 .await;
219 }
220 Err(e) => {
221 // A persistent accept error recurs immediately, so continuing spins a
222 // hot loop on an unbounded status channel. Give up the listener.
223 error!("USB mouse accept failed, stopping accept loop: {}", e);
224 break;
225 }
226 }
227 }
228 });
229
230 // Without this, stop_server has no handle to abort and the listener stays bound.
231 task_registrar
232 .register_server_task(server_id, accept_handle)
233 .await;
234
235 Ok(local_addr)
236 }
237
238 /// Handle USB/IP server lifecycle
239 ///
240 /// This creates a USB/IP server that exports a virtual HID mouse device.
241 /// The server handles USB/IP protocol operations and integrates with LLM actions.
242 #[allow(clippy::too_many_arguments)]
243 async fn handle_connection(
244 stream: tokio::net::TcpStream,
245 connection_id: ConnectionId,
246 remote_addr: SocketAddr,
247 llm_client: OllamaClient,
248 app_state: Arc<AppState>,
249 status_tx: mpsc::UnboundedSender<String>,
250 connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
251 protocol: Arc<crate::server::usb::mouse::UsbMouseProtocol>,
252 server_id: crate::state::ServerId,
253 deadlines: crate::server::usb::guard::UsbIpDeadlines,
254 ) -> Result<()> {
255 info!(
256 "USB mouse connection {} from {} - device ready for USB/IP import",
257 connection_id, remote_addr
258 );
259
260 // Initialize connection data
261 connections.lock().await.insert(
262 connection_id,
263 ConnectionData {
264 state: ConnectionState::Idle,
265 memory: String::new(),
266 },
267 );
268
269 let local_addr = stream.local_addr().unwrap_or(remote_addr);
270
271 // netget's own HID mouse handler. `usbip` 0.9 ships a keyboard handler and no mouse
272 // equivalent, which is what the "not yet functional" placeholder here was waiting for;
273 // handler.rs has implemented one for some time.
274 let handler = Arc::new(std::sync::Mutex::new(
275 Box::new(handler::UsbHidMouseHandler::new())
276 as Box<dyn usbip::UsbInterfaceHandler + Send>,
277 ));
278 protocol.set_handler(connection_id, handler.clone()).await;
279
280 let device = usbip::UsbDevice::new(0).with_interface(
281 usbip::ClassCode::HID as u8,
282 0x01, // Subclass: boot interface
283 0x02, // Protocol: mouse
284 Some("NetGet Virtual Mouse"),
285 handler::UsbHidMouseHandler::endpoints(),
286 handler.clone(),
287 );
288
289 let usbip_server = Arc::new(usbip::UsbIpServer::new_simulated(vec![device]));
290
291 let _ = status_tx.send(format!(
292 "USB mouse ready on {} - run: sudo usbip attach -r {} -b 0-0-0",
293 local_addr,
294 local_addr.ip()
295 ));
296
297 // Drive USB/IP on the socket netget already accepted. Calling `usbip::server()` here
298 // would bind a second listener on the client's own address; the previous version did
299 // neither and simply dropped the socket.
300 // Not `usbip::handler(&mut stream, ...)` directly: the crate allocates from the peer's
301 // `transfer_buffer_length` and `number_of_packets` without checking either, on a socket
302 // USB/IP never authenticates. `run_guarded_usbip` screens both before a byte reaches it
303 // -- see `src/server/usb/guard.rs`.
304 //
305 // The attach event hangs off `import_rx`, not off the accept: see the loop below.
306 let guard_status_tx = status_tx.clone();
307 let (import_tx, mut import_rx) = tokio::sync::oneshot::channel();
308 let usbip_task = tokio::spawn(async move {
309 match crate::server::usb::guard::run_guarded_usbip(
310 stream,
311 usbip_server,
312 "USB mouse",
313 connection_id.to_string(),
314 guard_status_tx,
315 Some(import_tx),
316 deadlines,
317 )
318 .await
319 {
320 Ok(()) => debug!(
321 "USB/IP session ended for mouse connection {}",
322 connection_id
323 ),
324 Err(e) => debug!(
325 "USB/IP session for mouse connection {} ended with error: {}",
326 connection_id, e
327 ),
328 }
329 });
330
331 // Serve the host until the USB/IP session ends (host detached, or socket closed). This
332 // replaced `sleep(u64::MAX)`, which is why usb_mouse_detached could never fire.
333 //
334 // The attach event is raised here rather than on the accept. USB/IP authenticates
335 // nothing, so a bare TCP connect must cost nothing: `import_rx` fires only once the
336 // screen has admitted an `OP_REQ_IMPORT`, which is the first moment the peer has asked
337 // for the mouse. `import_pending` is the select! guard — a `oneshot::Receiver` must not
338 // be polled again once it has resolved — and `imported` is the record, because the
339 // detach event is the other half of an attachment and must not fire without one.
340 let mut usbip_task = usbip_task;
341 let mut import_pending = true;
342 let mut imported = false;
343 loop {
344 tokio::select! {
345 asked = &mut import_rx, if import_pending => {
346 import_pending = false;
347 if asked.is_ok() {
348 imported = true;
349 if let Err(e) = Self::call_llm_on_attach(
350 connection_id,
351 &llm_client,
352 &app_state,
353 &status_tx,
354 &connections,
355 &protocol,
356 server_id,
357 )
358 .await
359 {
360 error!(
361 "Failed to call LLM on mouse attach for connection {}: {}",
362 connection_id, e
363 );
364 }
365 }
366 }
367 _ = &mut usbip_task => break,
368 }
369 }
370
371 info!(
372 "USB mouse host detached on connection {} from {}",
373 connection_id, remote_addr
374 );
375
376 // Only for a host that actually attached: a peer that opened a socket and closed it
377 // again detached nothing.
378 if imported {
379 Self::call_llm_on_detach(
380 connection_id,
381 &llm_client,
382 &app_state,
383 &status_tx,
384 &connections,
385 &protocol,
386 server_id,
387 )
388 .await;
389 }
390
391 // The USB/IP session owned this handler; drop it so a later action cannot move a mouse
392 // that no longer exists.
393 protocol.remove_handler(connection_id).await;
394 connections.lock().await.remove(&connection_id);
395 app_state
396 .close_connection_on_server(server_id, connection_id)
397 .await;
398 let _ = status_tx.send("__UPDATE_UI__".to_string());
399
400 Ok(())
401 }
402
403 /// Raise `usb_mouse_detached` once the USB/IP session has ended.
404 ///
405 /// This is the event's only emit site, and it did not exist before. The event is declared
406 /// `with_no_actions()`: there is no wire left to write to, so the model's vocabulary here is
407 /// the common action set.
408 async fn call_llm_on_detach(
409 connection_id: ConnectionId,
410 llm_client: &OllamaClient,
411 app_state: &Arc<AppState>,
412 status_tx: &mpsc::UnboundedSender<String>,
413 connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
414 protocol: &Arc<crate::server::usb::mouse::UsbMouseProtocol>,
415 server_id: crate::state::ServerId,
416 ) {
417 {
418 let mut conns = connections.lock().await;
419 if let Some(conn) = conns.get_mut(&connection_id) {
420 conn.state = ConnectionState::Processing;
421 }
422 }
423
424 let event = Event::new(
425 &USB_MOUSE_DETACHED_EVENT,
426 serde_json::json!({ "connection_id": connection_id.to_string() }),
427 );
428
429 match call_llm(
430 llm_client,
431 app_state,
432 server_id,
433 Some(connection_id),
434 &event,
435 protocol.as_ref(),
436 )
437 .await
438 {
439 Ok(execution_result) => {
440 let acted = !execution_result.protocol_results.is_empty()
441 || !execution_result.raw_actions.is_empty();
442 info!(
443 "USB mouse LLM call completed (detach) for connection {} decision={}",
444 connection_id,
445 if acted { "model_action" } else { "no_action" }
446 )
447 }
448 Err(e) => console_error!(
449 status_tx,
450 "LLM call failed for USB mouse detach on connection {} \
451 decision=fail_closed_llm_error: {}",
452 connection_id,
453 e
454 ),
455 }
456
457 let mut conns = connections.lock().await;
458 if let Some(conn) = conns.get_mut(&connection_id) {
459 conn.state = ConnectionState::Idle;
460 }
461 }
462
463 /// Call LLM when device is attached
464 async fn call_llm_on_attach(
465 connection_id: ConnectionId,
466 llm_client: &OllamaClient,
467 app_state: &Arc<AppState>,
468 status_tx: &mpsc::UnboundedSender<String>,
469 connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
470 protocol: &Arc<crate::server::usb::mouse::UsbMouseProtocol>,
471 server_id: crate::state::ServerId,
472 ) -> Result<()> {
473 // Check if already processing
474 {
475 let conns = connections.lock().await;
476 if let Some(conn_data) = conns.get(&connection_id) {
477 if conn_data.state != ConnectionState::Idle {
478 debug!(
479 "USB mouse connection {} already processing, skipping LLM call",
480 connection_id
481 );
482 return Ok(());
483 }
484 }
485 }
486
487 // Set state to processing
488 {
489 let mut conns = connections.lock().await;
490 if let Some(conn_data) = conns.get_mut(&connection_id) {
491 conn_data.state = ConnectionState::Processing;
492 }
493 }
494
495 // Get instruction and memory
496 let (_instruction, _memory) = {
497 if let Some(server) = app_state.get_server(server_id).await {
498 (server.instruction.clone(), String::new())
499 } else {
500 return Err(anyhow::anyhow!("Server not found"));
501 }
502 };
503
504 // Create attached event
505 let event = Event::new(
506 &USB_MOUSE_ATTACHED_EVENT,
507 serde_json::json!({
508 "connection_id": connection_id.to_string(),
509 }),
510 );
511
512 info!(
513 "Calling LLM for USB mouse attached event on connection {}",
514 connection_id
515 );
516
517 // Call LLM
518 let result = call_llm(
519 llm_client,
520 app_state,
521 server_id,
522 Some(connection_id),
523 &event,
524 protocol.as_ref(),
525 )
526 .await;
527
528 // Process result
529 match result {
530 Ok(execution_result) => {
531 // Actions have already been executed by call_llm. Distinguish "the model asked
532 // for pointer movement" from "the model answered with nothing" in the log: on
533 // the wire the two are identical (a mouse that does not move), so the log is
534 // the only place an operator can tell them apart. This protocol has no verb
535 // that refuses, so `decision=model_reject` cannot occur here; `wait_for_more`
536 // is the model deliberately holding still and counts as an action.
537 let acted = !execution_result.protocol_results.is_empty()
538 || !execution_result.raw_actions.is_empty();
539 info!(
540 "USB mouse LLM call completed for connection {} decision={} ({} action \
541 results)",
542 connection_id,
543 if acted { "model_action" } else { "no_action" },
544 execution_result.protocol_results.len()
545 );
546
547 // Set state back to idle
548 let mut conns = connections.lock().await;
549 if let Some(conn_data) = conns.get_mut(&connection_id) {
550 conn_data.state = ConnectionState::Idle;
551 }
552 }
553 Err(e) => {
554 // Silence is the correct wire behaviour here, and it is deliberate — see the
555 // module docs. What must not happen is that it goes unrecorded, so this is
556 // dual-logged at ERROR rather than only reaching `netget.log`.
557 console_error!(
558 status_tx,
559 "LLM call failed for USB mouse connection {} decision=fail_closed_llm_error: \
560 {}; no HID report will be sent, so the host's pointer does not move",
561 connection_id,
562 e
563 );
564
565 // Set state back to idle
566 let mut conns = connections.lock().await;
567 if let Some(conn_data) = conns.get_mut(&connection_id) {
568 conn_data.state = ConnectionState::Idle;
569 }
570 }
571 }
572
573 Ok(())
574 }
575}