Skip to main content

netget/server/usb/keyboard/
mod.rs

1//! USB HID Keyboard server implementation
2//!
3//! This module implements a virtual USB HID keyboard using the USB/IP protocol.
4//! The keyboard can be controlled by the LLM to type text, press keys, and handle
5//! key combinations (Ctrl+C, Alt+Tab, etc.).
6//!
7//! ## When the LLM call fails, the keyboard stays silent — deliberately
8//!
9//! Most protocols owe the peer an answer, and going quiet on an LLM failure leaves it hanging
10//! until its own timeout. A HID keyboard owes nothing. The host polls the interrupt IN endpoint
11//! and is NAKed whenever no key is down; a keyboard with nothing to report is the *normal*
12//! state of every keyboard in the world, not an error condition. There is no HID vocabulary for
13//! "the device's brain is unreachable" either: STALLing the interrupt endpoint is a fault
14//! condition that makes a host reset or unbind the device, which is a worse and less truthful
15//! answer than reporting no keystrokes. (`usbip` also has no way to express it — returning
16//! `Err` from `handle_urb` aborts the USB/IP session for the whole device.)
17//!
18//! The fail-closed property that *does* matter here is the one this direction makes free: a
19//! failed LLM call must never put a keystroke on the wire. Nothing below can synthesise a
20//! report; reports exist only where an action produced them.
21//!
22//! So the failure is dual-logged at ERROR and nothing is sent. The log carries a `decision=`
23//! tag so the three outcomes an operator would otherwise see as one quiet keyboard stay apart:
24//! `decision=fail_closed_llm_error` (the backend failed), `decision=model_no_actions` (the model answered
25//! and asked for no keystrokes) and `decision=model_actions` (something was typed).
26//! `tests/server/usb_keyboard/llm_failure_test.rs` pins both halves.
27
28pub mod actions;
29
30#[cfg(all(unix, feature = "libusb"))]
31pub mod handler;
32
33// Re-export protocol struct for registration
34#[cfg(all(unix, feature = "libusb"))]
35pub use actions::UsbKeyboardProtocol;
36
37#[cfg(all(unix, feature = "libusb"))]
38use anyhow::Result;
39#[cfg(all(unix, feature = "libusb"))]
40use std::collections::HashMap;
41#[cfg(all(unix, feature = "libusb"))]
42use std::net::SocketAddr;
43#[cfg(all(unix, feature = "libusb"))]
44use std::sync::Arc;
45#[cfg(all(unix, feature = "libusb"))]
46use tokio::sync::{mpsc, Mutex};
47#[cfg(all(unix, feature = "libusb"))]
48use tracing::{debug, error, info};
49
50#[cfg(all(unix, feature = "libusb"))]
51use crate::console_error;
52#[cfg(all(unix, feature = "libusb"))]
53use crate::llm::action_helper::call_llm;
54#[cfg(all(unix, feature = "libusb"))]
55use crate::llm::ollama_client::OllamaClient;
56#[cfg(all(unix, feature = "libusb"))]
57use crate::protocol::Event;
58#[cfg(all(unix, feature = "libusb"))]
59use crate::server::accept_bounded::{accept_bounded, ConnectionLimiter};
60#[cfg(all(unix, feature = "libusb"))]
61use crate::server::connection::ConnectionId;
62#[cfg(all(unix, feature = "libusb"))]
63use crate::server::usb::guard::{MAX_USBIP_CONNECTIONS, USBIP_NO_REFUSAL};
64#[cfg(all(unix, feature = "libusb"))]
65use crate::state::app_state::AppState;
66#[cfg(all(unix, feature = "libusb"))]
67use actions::{
68    USB_KEYBOARD_ATTACHED_EVENT, USB_KEYBOARD_DETACHED_EVENT, USB_KEYBOARD_LED_STATUS_EVENT,
69};
70#[cfg(all(unix, feature = "libusb"))]
71use handler::{LedState, NetGetKeyboardHandler};
72
73/// Connection state for LLM processing
74#[cfg(all(unix, feature = "libusb"))]
75#[derive(Debug, Clone, PartialEq)]
76#[allow(dead_code)]
77enum ConnectionState {
78    Idle,
79    Processing,
80    Accumulating,
81}
82
83/// Per-connection data for USB keyboard
84#[cfg(all(unix, feature = "libusb"))]
85struct ConnectionData {
86    state: ConnectionState,
87    #[allow(dead_code)]
88    memory: String,
89    /// Last LED byte the host set, kept so the TUI/state view can show it.
90    led_status: u8,
91}
92
93/// USB HID Keyboard server
94#[cfg(all(unix, feature = "libusb"))]
95pub struct UsbKeyboardServer;
96
97#[cfg(all(unix, feature = "libusb"))]
98impl UsbKeyboardServer {
99    /// Spawn the USB keyboard server with LLM integration
100    ///
101    /// This creates a USB/IP server that exports a virtual HID keyboard device.
102    /// The LLM can control the keyboard through actions like type_text and press_key.
103    pub async fn spawn_with_llm_actions(
104        listen_addr: SocketAddr,
105        llm_client: OllamaClient,
106        app_state: Arc<AppState>,
107        status_tx: mpsc::UnboundedSender<String>,
108        server_id: crate::state::ServerId,
109        first_byte_timeout_secs: Option<u64>,
110        idle_timeout_secs: Option<u64>,
111    ) -> Result<SocketAddr> {
112        // Create and bind TCP server for USB/IP protocol
113        let listener =
114            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
115        let local_addr = listener.local_addr()?;
116        info!("USB Keyboard server listening on {}", local_addr);
117        let _ = status_tx.send(format!("USB Keyboard server listening on {}", local_addr));
118
119        let connections = Arc::new(Mutex::new(HashMap::new()));
120        let protocol = Arc::new(crate::server::usb::keyboard::UsbKeyboardProtocol::new());
121
122        // The two read deadlines, argued in `src/server/usb/guard.rs` and overridable per
123        // server. Copied into each connection task; the screen is the only thing that reads
124        // from the socket, so it is the only place they can be applied.
125        let deadlines = crate::server::usb::guard::UsbIpDeadlines::from_secs(
126            first_byte_timeout_secs,
127            idle_timeout_secs,
128        );
129        let task_registrar = app_state.clone();
130        // One emulated device per connection, so the cap is small and lives with the screen.
131        let limiter = ConnectionLimiter::new(MAX_USBIP_CONNECTIONS);
132        // Spawn accept loop for USB/IP connections
133        let accept_handle = tokio::spawn(async move {
134            loop {
135                match accept_bounded(
136                    &listener,
137                    &limiter,
138                    USBIP_NO_REFUSAL,
139                    "USB keyboard",
140                    Some(&status_tx),
141                )
142                .await
143                {
144                    Ok((stream, remote_addr, permit)) => {
145                        let connection_id =
146                            ConnectionId::new(app_state.get_next_unified_id().await);
147                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
148                        info!(
149                            "USB/IP connection {} from {} (USB keyboard device)",
150                            connection_id, remote_addr
151                        );
152
153                        // Add connection to ServerInstance
154                        use crate::state::server::{
155                            ConnectionState as ServerConnectionState, ConnectionStatus,
156                            ProtocolConnectionInfo,
157                        };
158                        let now = crate::utils::clock::Instant::now();
159                        let conn_state = ServerConnectionState {
160                            id: connection_id,
161                            remote_addr,
162                            local_addr: local_addr_conn,
163                            bytes_sent: 0,
164                            bytes_received: 0,
165                            packets_sent: 0,
166                            packets_received: 0,
167                            last_activity: now,
168                            status: ConnectionStatus::Active,
169                            status_changed_at: now,
170                            protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
171                                "state": "WaitingForImport",
172                                "led_status": 0
173                            })),
174                        };
175                        app_state
176                            .add_connection_to_server(server_id, conn_state)
177                            .await;
178                        let _ = status_tx.send("__UPDATE_UI__".to_string());
179
180                        // Handle USB/IP connection
181                        let llm_client_clone = llm_client.clone();
182                        let app_state_clone = app_state.clone();
183                        let status_tx_clone = status_tx.clone();
184                        let connections_clone = connections.clone();
185                        let protocol_clone = protocol.clone();
186
187                        // Tracked, not detached: stop_server must abort this task too.
188                        let task_owner = app_state.clone();
189                        task_owner
190                            .spawn_server_task(server_id, async move {
191                                // Held for the life of the connection: dropping it early
192                                // releases the slot while the device is still exported.
193                                let _permit = permit;
194                                if let Err(e) = Self::handle_connection(
195                                    stream,
196                                    connection_id,
197                                    remote_addr,
198                                    llm_client_clone,
199                                    app_state_clone,
200                                    status_tx_clone,
201                                    connections_clone,
202                                    protocol_clone,
203                                    server_id,
204                                    deadlines,
205                                )
206                                .await
207                                {
208                                    error!(
209                                        "USB keyboard connection {} error: {}",
210                                        connection_id, e
211                                    );
212                                }
213                            })
214                            .await;
215                    }
216                    Err(e) => {
217                        // A persistent accept error recurs immediately, so continuing spins a
218                        // hot loop on an unbounded status channel. Give up the listener.
219                        error!("USB keyboard accept failed, stopping accept loop: {}", e);
220                        break;
221                    }
222                }
223            }
224        });
225
226        // Without this, stop_server has no handle to abort and the listener stays bound.
227        task_registrar
228            .register_server_task(server_id, accept_handle)
229            .await;
230
231        Ok(local_addr)
232    }
233
234    /// Handle USB/IP server lifecycle
235    ///
236    /// This creates a USB/IP server that exports a virtual HID keyboard device.
237    /// The server handles USB/IP protocol operations and integrates with LLM actions.
238    #[allow(clippy::too_many_arguments)]
239    async fn handle_connection(
240        stream: tokio::net::TcpStream,
241        connection_id: ConnectionId,
242        remote_addr: SocketAddr,
243        llm_client: OllamaClient,
244        app_state: Arc<AppState>,
245        status_tx: mpsc::UnboundedSender<String>,
246        connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
247        protocol: Arc<crate::server::usb::keyboard::UsbKeyboardProtocol>,
248        server_id: crate::state::ServerId,
249        deadlines: crate::server::usb::guard::UsbIpDeadlines,
250    ) -> Result<()> {
251        info!(
252            "USB keyboard connection {} from {} - device ready for USB/IP import",
253            connection_id, remote_addr
254        );
255
256        let local_addr = stream.local_addr().unwrap_or(remote_addr);
257
258        // Initialize connection data
259        connections.lock().await.insert(
260            connection_id,
261            ConnectionData {
262                state: ConnectionState::Idle,
263                memory: String::new(),
264                led_status: 0,
265            },
266        );
267
268        // LED reports arrive on a synchronous URB callback, which cannot raise an event by
269        // itself. The channel is the seam between the two, exactly as in usb/msc.
270        let (led_tx, mut led_rx) = mpsc::unbounded_channel::<LedState>();
271
272        // A wrapper around the crate's keyboard handler: it keeps the key-down/key-up state
273        // machine but takes over endpoint 0, so LED SET_REPORTs are seen (the crate discards
274        // them, which is why usb_keyboard_led_status could never fire) and no control request
275        // reaches the crate's `unimplemented!()`.
276        let handler = Arc::new(std::sync::Mutex::new(Box::new(
277            NetGetKeyboardHandler::new().with_led_events(led_tx),
278        )
279            as Box<dyn usbip::UsbInterfaceHandler + Send>));
280
281        // Store handler in protocol for action execution
282        protocol.set_handler(connection_id, handler.clone());
283
284        // Create USB device with HID keyboard interface
285        let device = usbip::UsbDevice::new(0).with_interface(
286            usbip::ClassCode::HID as u8,
287            0x00, // Subclass: no subclass
288            0x00, // Protocol: none
289            Some("NetGet Virtual Keyboard"),
290            vec![usbip::UsbEndpoint {
291                address: 0x81,         // EP1 IN (interrupt)
292                attributes: 0x03,      // Interrupt transfer
293                max_packet_size: 0x08, // 8 bytes (keyboard report)
294                interval: 10,          // 10ms polling interval
295            }],
296            handler.clone(),
297        );
298
299        let usbip_server = Arc::new(usbip::UsbIpServer::new_simulated(vec![device]));
300
301        info!(
302            "USB keyboard device ready for connection {} from {}",
303            connection_id, remote_addr
304        );
305        let _ = status_tx.send(format!(
306            "USB keyboard ready on {} - run: sudo usbip attach -r {} -b 0-0-0",
307            local_addr,
308            local_addr.ip()
309        ));
310
311        // Drive the USB/IP protocol on the socket we already accepted.
312        //
313        // This deliberately does not call `usbip::server()`: that binds a *second*
314        // listener, which forced a hardcoded port (3240) and limited the protocol to one
315        // instance per host. `usbip::handler` speaks the same protocol over an existing
316        // socket, so the netget listener is the USB/IP listener and the port is whatever
317        // the caller asked for.
318        // Not `usbip::handler(&mut stream, ...)` directly: the crate allocates from the peer's
319        // `transfer_buffer_length` and `number_of_packets` without checking either, on a socket
320        // USB/IP never authenticates. `run_guarded_usbip` screens both before a byte reaches it
321        // -- see `src/server/usb/guard.rs`.
322        //
323        // The attach event hangs off `import_rx`, not off the accept: see the note above
324        // `import_pending` below.
325        let guard_status_tx = status_tx.clone();
326        let (import_tx, mut import_rx) = tokio::sync::oneshot::channel();
327        let usbip_task = tokio::spawn(async move {
328            match crate::server::usb::guard::run_guarded_usbip(
329                stream,
330                usbip_server,
331                "USB keyboard",
332                connection_id.to_string(),
333                guard_status_tx,
334                Some(import_tx),
335                deadlines,
336            )
337            .await
338            {
339                Ok(()) => debug!(
340                    "USB/IP session ended for keyboard connection {}",
341                    connection_id
342                ),
343                Err(e) => debug!(
344                    "USB/IP session for keyboard connection {} ended with error: {}",
345                    connection_id, e
346                ),
347            }
348        });
349
350        // Serve the host until the USB/IP session ends. This loop is why
351        // usb_keyboard_led_status can fire at all: the previous version awaited the session
352        // handle directly and never looked at the LED channel.
353        //
354        // It is also where the attach event is raised. USB/IP authenticates nothing, so a bare
355        // TCP connect must cost nothing: `import_rx` fires only once the screen has admitted an
356        // `OP_REQ_IMPORT`, which is the first moment the peer has asked for the keyboard.
357        // `import_pending` is the select! guard — a `oneshot::Receiver` must not be polled
358        // again once it has resolved — and `imported` is the record, because the detach event
359        // is the other half of an attachment and must not fire without one.
360        let mut usbip_task = usbip_task;
361        let mut import_pending = true;
362        let mut imported = false;
363        loop {
364            tokio::select! {
365                asked = &mut import_rx, if import_pending => {
366                    import_pending = false;
367                    if asked.is_ok() {
368                        imported = true;
369                        if let Err(e) = Self::call_llm_on_attach(
370                            connection_id,
371                            &llm_client,
372                            &app_state,
373                            &status_tx,
374                            &connections,
375                            &protocol,
376                            server_id,
377                        )
378                        .await
379                        {
380                            error!(
381                                "Failed to call LLM on keyboard attach for connection {}: {}",
382                                connection_id, e
383                            );
384                        }
385                    }
386                }
387                received = led_rx.recv() => {
388                    let Some(mut leds) = received else { break };
389                    // A burst of LED writes (Caps then Num within a millisecond) is folded
390                    // into the last state, so one keypress does not become several LLM calls.
391                    while let Ok(newer) = led_rx.try_recv() {
392                        leds = newer;
393                    }
394                    Self::call_llm_on_led_status(
395                        connection_id,
396                        leds,
397                        &llm_client,
398                        &app_state,
399                        &status_tx,
400                        &connections,
401                        &protocol,
402                        server_id,
403                    )
404                    .await;
405                }
406                _ = &mut usbip_task => break,
407            }
408        }
409
410        info!(
411            "USB keyboard host detached on connection {} from {}",
412            connection_id, remote_addr
413        );
414
415        // Call LLM on device detach — only for a host that actually attached. A peer that
416        // opened a socket and closed it again detached nothing.
417        if imported {
418            if let Err(e) = Self::call_llm_on_detach(
419                connection_id,
420                &llm_client,
421                &app_state,
422                &status_tx,
423                &connections,
424                &protocol,
425                server_id,
426            )
427            .await
428            {
429                error!(
430                    "Failed to call LLM on keyboard detach for connection {}: {}",
431                    connection_id, e
432                );
433            }
434        }
435
436        // The USB/IP session owned this handler; drop it so a later connection with the
437        // same id cannot reach a dead device.
438        protocol.remove_handler(connection_id);
439        connections.lock().await.remove(&connection_id);
440
441        Ok(())
442    }
443
444    /// Raise `usb_keyboard_led_status` after the host changed a lock LED.
445    ///
446    /// This is the event's only emit site, and it did not exist before: the event was declared,
447    /// advertised to the model with a full action vocabulary, and could never fire, because the
448    /// crate's keyboard handler discards HID output reports.
449    #[allow(clippy::too_many_arguments)]
450    async fn call_llm_on_led_status(
451        connection_id: ConnectionId,
452        leds: LedState,
453        llm_client: &OllamaClient,
454        app_state: &Arc<AppState>,
455        status_tx: &mpsc::UnboundedSender<String>,
456        connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
457        protocol: &Arc<crate::server::usb::keyboard::UsbKeyboardProtocol>,
458        server_id: crate::state::ServerId,
459    ) {
460        {
461            let mut conns = connections.lock().await;
462            match conns.get_mut(&connection_id) {
463                Some(conn) if conn.state != ConnectionState::Idle => {
464                    debug!(
465                        "USB keyboard connection {} already processing, skipping LED event",
466                        connection_id
467                    );
468                    return;
469                }
470                Some(conn) => {
471                    conn.state = ConnectionState::Processing;
472                    conn.led_status = leds.raw;
473                }
474                None => {}
475            }
476        }
477
478        let event = Event::new(
479            &USB_KEYBOARD_LED_STATUS_EVENT,
480            serde_json::json!({
481                "connection_id": connection_id.to_string(),
482                "num_lock": leds.num_lock(),
483                "caps_lock": leds.caps_lock(),
484                "scroll_lock": leds.scroll_lock(),
485            }),
486        );
487
488        match call_llm(
489            llm_client,
490            app_state,
491            server_id,
492            Some(connection_id),
493            &event,
494            protocol.as_ref(),
495        )
496        .await
497        {
498            Ok(execution_result) => {
499                let acted =
500                    execution_result.protocol_results.len() + execution_result.raw_actions.len();
501                info!(
502                    "USB keyboard LLM call completed (led_status) for connection {} decision={} actions={}",
503                    connection_id,
504                    if acted == 0 {
505                        "model_no_actions"
506                    } else {
507                        "model_actions"
508                    },
509                    acted
510                );
511            }
512            Err(e) => console_error!(
513                status_tx,
514                "LLM call failed for USB keyboard led_status on connection {} decision=fail_closed_llm_error: \
515                 {}; the keyboard stays silent (see the module docs for why silence is the \
516                 refusal here)",
517                connection_id,
518                e
519            ),
520        }
521
522        let mut conns = connections.lock().await;
523        if let Some(conn) = conns.get_mut(&connection_id) {
524            conn.state = ConnectionState::Idle;
525        }
526    }
527
528    /// Call LLM when the USB/IP host detaches.
529    ///
530    /// `usb_keyboard_detached` is declared `with_no_actions()`, so the model's vocabulary
531    /// here is the common action set (`show_message`, memory operations); there is no
532    /// wire left to write to.
533    async fn call_llm_on_detach(
534        connection_id: ConnectionId,
535        llm_client: &OllamaClient,
536        app_state: &Arc<AppState>,
537        status_tx: &mpsc::UnboundedSender<String>,
538        connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
539        protocol: &Arc<crate::server::usb::keyboard::UsbKeyboardProtocol>,
540        server_id: crate::state::ServerId,
541    ) -> Result<()> {
542        {
543            let mut conns = connections.lock().await;
544            if let Some(conn_data) = conns.get_mut(&connection_id) {
545                conn_data.state = ConnectionState::Processing;
546            }
547        }
548
549        let event = Event::new(
550            &USB_KEYBOARD_DETACHED_EVENT,
551            serde_json::json!({
552                "connection_id": connection_id.to_string(),
553            }),
554        );
555
556        let result = call_llm(
557            llm_client,
558            app_state,
559            server_id,
560            Some(connection_id),
561            &event,
562            protocol.as_ref(),
563        )
564        .await;
565
566        match &result {
567            Err(e) => console_error!(
568                status_tx,
569                "LLM call failed for USB keyboard detach on connection {} decision=fail_closed_llm_error: {}",
570                connection_id,
571                e
572            ),
573            Ok(execution_result) => {
574                let acted =
575                    execution_result.protocol_results.len() + execution_result.raw_actions.len();
576                info!(
577                    "USB keyboard detach LLM call completed for connection {} decision={} actions={}",
578                    connection_id,
579                    if acted == 0 {
580                        "model_no_actions"
581                    } else {
582                        "model_actions"
583                    },
584                    acted
585                );
586            }
587        }
588
589        let mut conns = connections.lock().await;
590        if let Some(conn_data) = conns.get_mut(&connection_id) {
591            conn_data.state = ConnectionState::Idle;
592        }
593
594        Ok(())
595    }
596
597    /// Call LLM when device is attached
598    async fn call_llm_on_attach(
599        connection_id: ConnectionId,
600        llm_client: &OllamaClient,
601        app_state: &Arc<AppState>,
602        status_tx: &mpsc::UnboundedSender<String>,
603        connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
604        protocol: &Arc<crate::server::usb::keyboard::UsbKeyboardProtocol>,
605        server_id: crate::state::ServerId,
606    ) -> Result<()> {
607        // Check if already processing
608        {
609            let conns = connections.lock().await;
610            if let Some(conn_data) = conns.get(&connection_id) {
611                if conn_data.state != ConnectionState::Idle {
612                    debug!(
613                        "USB keyboard connection {} already processing, skipping LLM call",
614                        connection_id
615                    );
616                    return Ok(());
617                }
618            }
619        }
620
621        // Set state to processing
622        {
623            let mut conns = connections.lock().await;
624            if let Some(conn_data) = conns.get_mut(&connection_id) {
625                conn_data.state = ConnectionState::Processing;
626            }
627        }
628
629        // Get instruction and memory
630        let (_instruction, _memory) = {
631            if let Some(server) = app_state.get_server(server_id).await {
632                (server.instruction.clone(), String::new())
633            } else {
634                return Err(anyhow::anyhow!("Server not found"));
635            }
636        };
637
638        // Create attached event
639        let event = Event::new(
640            &USB_KEYBOARD_ATTACHED_EVENT,
641            serde_json::json!({
642                "connection_id": connection_id.to_string(),
643            }),
644        );
645
646        info!(
647            "Calling LLM for USB keyboard attached event on connection {}",
648            connection_id
649        );
650
651        // Call LLM
652        let result = call_llm(
653            llm_client,
654            app_state,
655            server_id,
656            Some(connection_id),
657            &event,
658            protocol.as_ref(),
659        )
660        .await;
661
662        // Process result
663        match result {
664            Ok(execution_result) => {
665                // Actions have already been executed by call_llm. Whether the model actually
666                // asked for keystrokes is the distinction an operator needs in the log: a
667                // model that answered with nothing looks exactly like a working keyboard
668                // nobody is typing on, and so does a backend outage. Tag all three.
669                let acted =
670                    execution_result.protocol_results.len() + execution_result.raw_actions.len();
671                if acted == 0 {
672                    info!(
673                        "USB keyboard LLM call completed for connection {} decision=model_no_actions (nothing typed)",
674                        connection_id
675                    );
676                } else {
677                    info!(
678                        "USB keyboard LLM call completed for connection {} decision=model_actions actions={}",
679                        connection_id, acted
680                    );
681                }
682
683                // Set state back to idle
684                let mut conns = connections.lock().await;
685                if let Some(conn_data) = conns.get_mut(&connection_id) {
686                    conn_data.state = ConnectionState::Idle;
687                }
688            }
689            Err(e) => {
690                // Silence is the correct wire behaviour here, and it is deliberate — see the
691                // module docs. What must not happen is that it goes unrecorded, so this is
692                // dual-logged at ERROR rather than only reaching `netget.log`.
693                console_error!(
694                    status_tx,
695                    "LLM call failed for USB keyboard connection {} decision=fail_closed_llm_error: {}; no \
696                     HID report will be sent, so the host sees a keyboard nobody is typing on",
697                    connection_id,
698                    e
699                );
700
701                // Set state back to idle
702                let mut conns = connections.lock().await;
703                if let Some(conn_data) = conns.get_mut(&connection_id) {
704                    conn_data.state = ConnectionState::Idle;
705                }
706            }
707        }
708
709        Ok(())
710    }
711}