Skip to main content

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}