Skip to main content

netget/server/usb/serial/
mod.rs

1//! USB CDC ACM Serial server implementation.
2//!
3//! Exports a virtual serial port over USB/IP. A Linux host that imports it sees
4//! `/dev/ttyACM0`; anything speaking USB/IP over TCP can drive it without a kernel module.
5//!
6//! The USB/IP session runs on the socket netget already accepted (`usbip::handler`) rather
7//! than through `usbip::server()`, which would bind a second listener on a fixed port.
8//!
9//! Event flow:
10//!
11//! * `usb_serial_attached` — a host connected and the port is live.
12//! * `usb_serial_data_received` — the host wrote to the port. `handle_urb` is synchronous and
13//!   cannot call the LLM, so the handler forwards bytes over a channel and this module raises
14//!   the event from the connection task.
15//! * `usb_serial_detached` — the USB/IP session ended (host detached, or the socket closed).
16
17pub mod actions;
18
19#[cfg(all(unix, feature = "libusb"))]
20pub mod handler;
21
22// Re-export protocol struct for registration
23#[cfg(all(unix, feature = "libusb"))]
24pub use actions::UsbSerialProtocol;
25
26#[cfg(all(unix, feature = "libusb"))]
27use anyhow::Result;
28#[cfg(all(unix, feature = "libusb"))]
29use std::{collections::HashMap, net::SocketAddr, sync::Arc};
30#[cfg(all(unix, feature = "libusb"))]
31use tokio::sync::{mpsc, Mutex};
32#[cfg(all(unix, feature = "libusb"))]
33use tracing::{debug, error, info, warn};
34
35#[cfg(all(unix, feature = "libusb"))]
36use crate::{
37    console_error,
38    llm::action_helper::call_llm,
39    llm::OllamaClient,
40    protocol::Event,
41    server::accept_bounded::{accept_bounded, ConnectionLimiter},
42    server::connection::ConnectionId,
43    server::usb::guard::{MAX_USBIP_CONNECTIONS, USBIP_NO_REFUSAL},
44    state::app_state::AppState,
45};
46
47#[cfg(all(unix, feature = "libusb"))]
48use actions::{
49    USB_SERIAL_ATTACHED_EVENT, USB_SERIAL_DATA_RECEIVED_EVENT, USB_SERIAL_DETACHED_EVENT,
50};
51
52/// Most host-written bytes one `usb_serial_data_received` event will carry.
53///
54/// The loop below coalesces everything the host wrote while the previous LLM call was in
55/// flight, so this is a bound on a backlog that grows at the host's line rate for the length
56/// of a model round-trip -- and it was unbounded. The bytes were then copied a second time by
57/// `from_utf8_lossy` and a third into the event JSON, all of which the model then has to read.
58/// 8 KiB is well past any line a serial peer sends in one go; the remainder is reported to the
59/// host as a CDC overrun, which is exactly what a real port says when its receive buffer fills.
60#[cfg(all(unix, feature = "libusb"))]
61const MAX_EVENT_BYTES: usize = 8 * 1024;
62
63#[cfg(all(unix, feature = "libusb"))]
64#[derive(Debug, Clone, PartialEq)]
65enum ConnectionState {
66    Idle,
67    Processing,
68}
69
70#[cfg(all(unix, feature = "libusb"))]
71struct ConnectionData {
72    state: ConnectionState,
73}
74
75#[cfg(all(unix, feature = "libusb"))]
76pub struct UsbSerialServer;
77
78#[cfg(all(unix, feature = "libusb"))]
79impl UsbSerialServer {
80    pub async fn spawn_with_llm_actions(
81        listen_addr: SocketAddr,
82        llm_client: OllamaClient,
83        app_state: Arc<AppState>,
84        status_tx: mpsc::UnboundedSender<String>,
85        server_id: crate::state::ServerId,
86        first_byte_timeout_secs: Option<u64>,
87        idle_timeout_secs: Option<u64>,
88    ) -> Result<SocketAddr> {
89        let listener =
90            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
91        let local_addr = listener.local_addr()?;
92        info!("USB Serial server listening on {}", local_addr);
93        let _ = status_tx.send(format!("USB Serial server listening on {}", local_addr));
94
95        let connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>> =
96            Arc::new(Mutex::new(HashMap::new()));
97        let protocol = Arc::new(UsbSerialProtocol::new());
98
99        // The two read deadlines, argued in `src/server/usb/guard.rs` and overridable per
100        // server. Copied into each connection task; the screen is the only thing that reads
101        // from the socket, so it is the only place they can be applied.
102        let deadlines = crate::server::usb::guard::UsbIpDeadlines::from_secs(
103            first_byte_timeout_secs,
104            idle_timeout_secs,
105        );
106        let task_registrar = app_state.clone();
107        // One emulated device per connection, so the cap is small and lives with the screen.
108        let limiter = ConnectionLimiter::new(MAX_USBIP_CONNECTIONS);
109        let accept_handle = tokio::spawn(async move {
110            loop {
111                match accept_bounded(
112                    &listener,
113                    &limiter,
114                    USBIP_NO_REFUSAL,
115                    "USB serial",
116                    Some(&status_tx),
117                )
118                .await
119                {
120                    Ok((stream, remote_addr, permit)) => {
121                        let connection_id =
122                            ConnectionId::new(app_state.get_next_unified_id().await);
123                        info!(
124                            "USB/IP connection {} from {} (USB serial)",
125                            connection_id, remote_addr
126                        );
127
128                        use crate::state::server::{
129                            ConnectionState as ServerConnectionState, ConnectionStatus,
130                            ProtocolConnectionInfo,
131                        };
132                        let now = crate::utils::clock::Instant::now();
133                        let conn_state = ServerConnectionState {
134                            id: connection_id,
135                            remote_addr,
136                            local_addr: stream.local_addr().unwrap_or(local_addr),
137                            bytes_sent: 0,
138                            bytes_received: 0,
139                            packets_sent: 0,
140                            packets_received: 0,
141                            last_activity: now,
142                            status: ConnectionStatus::Active,
143                            status_changed_at: now,
144                            protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
145                                "state": "WaitingForImport",
146                                "baud_rate": 115200
147                            })),
148                        };
149                        app_state
150                            .add_connection_to_server(server_id, conn_state)
151                            .await;
152                        let _ = status_tx.send("__UPDATE_UI__".to_string());
153
154                        let llm_client_clone = llm_client.clone();
155                        let app_state_clone = app_state.clone();
156                        let status_tx_clone = status_tx.clone();
157                        let connections_clone = connections.clone();
158                        let protocol_clone = protocol.clone();
159
160                        // Tracked, not detached: stop_server must abort this task too.
161                        let task_owner = app_state.clone();
162                        task_owner
163                            .spawn_server_task(server_id, async move {
164                                // Held for the life of the connection: dropping it early
165                                // releases the slot while the device is still exported.
166                                let _permit = permit;
167                                if let Err(e) = Self::handle_connection(
168                                    stream,
169                                    connection_id,
170                                    remote_addr,
171                                    llm_client_clone,
172                                    app_state_clone,
173                                    status_tx_clone,
174                                    connections_clone,
175                                    protocol_clone,
176                                    server_id,
177                                    deadlines,
178                                )
179                                .await
180                                {
181                                    error!("USB serial connection {} error: {}", connection_id, e);
182                                }
183                            })
184                            .await;
185                    }
186                    Err(e) => {
187                        // A persistent accept error (EMFILE, socket torn down) recurs
188                        // immediately, so continuing spins a hot loop on an unbounded status
189                        // channel. Give up the listener instead.
190                        error!("USB serial accept failed, stopping accept loop: {}", e);
191                        break;
192                    }
193                }
194            }
195        });
196
197        task_registrar
198            .register_server_task(server_id, accept_handle)
199            .await;
200
201        Ok(local_addr)
202    }
203
204    /// Drive one USB/IP session and the events that hang off it.
205    #[allow(clippy::too_many_arguments)]
206    async fn handle_connection(
207        stream: tokio::net::TcpStream,
208        connection_id: ConnectionId,
209        remote_addr: SocketAddr,
210        llm_client: OllamaClient,
211        app_state: Arc<AppState>,
212        status_tx: mpsc::UnboundedSender<String>,
213        connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
214        protocol: Arc<UsbSerialProtocol>,
215        server_id: crate::state::ServerId,
216        deadlines: crate::server::usb::guard::UsbIpDeadlines,
217    ) -> Result<()> {
218        let local_addr = stream.local_addr().unwrap_or(remote_addr);
219
220        connections.lock().await.insert(
221            connection_id,
222            ConnectionData {
223                state: ConnectionState::Idle,
224            },
225        );
226
227        // Host writes arrive on a synchronous URB callback, which cannot await an LLM call.
228        // The channel is the seam between the two.
229        let (rx_tx, mut rx_rx) = mpsc::unbounded_channel::<Vec<u8>>();
230
231        let serial_handler = Arc::new(std::sync::Mutex::new(Box::new(
232            handler::UsbCdcAcmSerialHandler::new(rx_tx),
233        )
234            as Box<dyn usbip::UsbInterfaceHandler + Send>));
235
236        protocol.set_handler(connection_id, serial_handler.clone());
237
238        let device = usbip::UsbDevice::new(0).with_interface(
239            usbip::ClassCode::CDC as u8,
240            usbip::cdc::CDC_ACM_SUBCLASS,
241            0x01, // AT command protocol, what a CDC ACM port conventionally reports
242            Some("NetGet Virtual Serial Port"),
243            handler::UsbCdcAcmSerialHandler::endpoints(),
244            serial_handler.clone(),
245        );
246
247        let usbip_server = Arc::new(usbip::UsbIpServer::new_simulated(vec![device]));
248
249        let _ = status_tx.send(format!(
250            "USB serial port ready on {} - run: sudo usbip attach -r {} -b 0-0-0",
251            local_addr,
252            local_addr.ip()
253        ));
254
255        // Not `usbip::handler(&mut stream, ...)` directly: the crate allocates from the peer's
256        // `transfer_buffer_length` and `number_of_packets` without checking either, on a socket
257        // USB/IP never authenticates. `run_guarded_usbip` screens both before a byte reaches it
258        // -- see `src/server/usb/guard.rs`.
259        //
260        // The attach event hangs off `import_rx`, not off the accept: see the loop below.
261        let guard_status_tx = status_tx.clone();
262        let (import_tx, mut import_rx) = tokio::sync::oneshot::channel();
263        let mut usbip_task = tokio::spawn(async move {
264            match crate::server::usb::guard::run_guarded_usbip(
265                stream,
266                usbip_server,
267                "USB serial",
268                connection_id.to_string(),
269                guard_status_tx,
270                Some(import_tx),
271                deadlines,
272            )
273            .await
274            {
275                Ok(()) => debug!(
276                    "USB/IP session ended for serial connection {}",
277                    connection_id
278                ),
279                Err(e) => debug!(
280                    "USB/IP session for serial connection {} ended with error: {}",
281                    connection_id, e
282                ),
283            }
284        });
285
286        // Serve host writes until the USB/IP session ends. This loop is why the detach event
287        // exists at all: the rest of the USB family parks on `sleep(u64::MAX)` here and never
288        // notices the host going away.
289        //
290        // It is also where the attach event is raised. USB/IP authenticates nothing, so a bare
291        // TCP connect must cost nothing: `import_rx` fires only once the screen has admitted an
292        // `OP_REQ_IMPORT`, which is the first moment the peer has asked for the port.
293        // `import_pending` is the select! guard — a `oneshot::Receiver` must not be polled again
294        // once it has resolved — and `imported` is the record, because the detach event is the
295        // other half of an attachment and must not fire without one.
296        let mut import_pending = true;
297        let mut imported = false;
298        loop {
299            tokio::select! {
300                asked = &mut import_rx, if import_pending => {
301                    import_pending = false;
302                    if asked.is_ok() {
303                        imported = true;
304                        Self::call_llm_for_event(
305                            connection_id,
306                            &llm_client,
307                            &app_state,
308                            &connections,
309                            &protocol,
310                            &status_tx,
311                            server_id,
312                            Event::new(
313                                &USB_SERIAL_ATTACHED_EVENT,
314                                serde_json::json!({ "connection_id": connection_id.to_string() }),
315                            ),
316                            "attach",
317                        )
318                        .await;
319                    }
320                }
321                received = rx_rx.recv() => {
322                    let Some(mut data) = received else { break };
323
324                    // Coalesce whatever else is already queued: while the previous LLM call
325                    // was running the host may have written several times, and one event per
326                    // URB would turn a paste into a burst of model round-trips.
327                    //
328                    // Bounded on append. What is being coalesced is precisely the backlog that
329                    // accumulated while the model was busy, so an attached host writing at line
330                    // rate decided the size of this buffer, of the `from_utf8_lossy` copy of it
331                    // and of the event JSON built from that.
332                    let mut overran = data.len() > MAX_EVENT_BYTES;
333                    data.truncate(MAX_EVENT_BYTES);
334                    while data.len() < MAX_EVENT_BYTES {
335                        let Ok(more) = rx_rx.try_recv() else { break };
336                        let room = MAX_EVENT_BYTES - data.len();
337                        if more.len() > room {
338                            data.extend_from_slice(&more[..room]);
339                            overran = true;
340                        } else {
341                            data.extend_from_slice(&more);
342                        }
343                    }
344                    if overran {
345                        // Say so in CDC's own vocabulary rather than silently serving a
346                        // truncated prefix to the model and nothing to the host.
347                        warn!(
348                            "USB serial connection {} clamped the host's backlog to {} bytes",
349                            connection_id, MAX_EVENT_BYTES
350                        );
351                        if protocol.signal_overrun(connection_id) {
352                            let _ = status_tx.send(format!(
353                                "[ERROR] USB serial port {connection_id} signalled \
354                                 SERIAL_STATE overrun: the host wrote more than \
355                                 {MAX_EVENT_BYTES} bytes while the model was busy"
356                            ));
357                        }
358                    }
359
360                    let text = String::from_utf8_lossy(&data).to_string();
361                    debug!(
362                        "USB serial connection {} received {} byte(s) from host",
363                        connection_id,
364                        data.len()
365                    );
366
367                    Self::call_llm_for_event(
368                        connection_id,
369                        &llm_client,
370                        &app_state,
371                        &connections,
372                        &protocol,
373                        &status_tx,
374                        server_id,
375                        Event::new(
376                            &USB_SERIAL_DATA_RECEIVED_EVENT,
377                            serde_json::json!({
378                                "connection_id": connection_id.to_string(),
379                                "data": text,
380                            }),
381                        ),
382                        "data_received",
383                    )
384                    .await;
385                }
386                _ = &mut usbip_task => break,
387            }
388        }
389
390        info!(
391            "USB serial host detached on connection {} from {}",
392            connection_id, remote_addr
393        );
394
395        // Only for a host that actually attached: a peer that opened a socket and closed it
396        // again detached nothing.
397        if imported {
398            Self::call_llm_for_event(
399                connection_id,
400                &llm_client,
401                &app_state,
402                &connections,
403                &protocol,
404                &status_tx,
405                server_id,
406                Event::new(
407                    &USB_SERIAL_DETACHED_EVENT,
408                    serde_json::json!({ "connection_id": connection_id.to_string() }),
409                ),
410                "detach",
411            )
412            .await;
413        }
414
415        // The session owned this handler; drop it so a later action cannot write to a port
416        // that no longer exists.
417        protocol.remove_handler(connection_id);
418        connections.lock().await.remove(&connection_id);
419        app_state
420            .close_connection_on_server(server_id, connection_id)
421            .await;
422        let _ = status_tx.send("__UPDATE_UI__".to_string());
423
424        Ok(())
425    }
426
427    /// Raise one event with the LLM, guarding against overlapping calls on the same port.
428    ///
429    /// On failure the port answers in CDC's own vocabulary — see the `Err` arm.
430    #[allow(clippy::too_many_arguments)]
431    async fn call_llm_for_event(
432        connection_id: ConnectionId,
433        llm_client: &OllamaClient,
434        app_state: &Arc<AppState>,
435        connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
436        protocol: &Arc<UsbSerialProtocol>,
437        status_tx: &mpsc::UnboundedSender<String>,
438        server_id: crate::state::ServerId,
439        event: Event,
440        what: &str,
441    ) {
442        {
443            let mut conns = connections.lock().await;
444            match conns.get_mut(&connection_id) {
445                Some(conn) if conn.state != ConnectionState::Idle => {
446                    debug!(
447                        "USB serial connection {} already processing, skipping {} event",
448                        connection_id, what
449                    );
450                    return;
451                }
452                Some(conn) => conn.state = ConnectionState::Processing,
453                // Detach removes the entry before its own event; that is not an error.
454                None => {}
455            }
456        }
457
458        let result = call_llm(
459            llm_client,
460            app_state,
461            server_id,
462            Some(connection_id),
463            &event,
464            protocol.as_ref(),
465        )
466        .await;
467
468        match result {
469            Ok(_) => info!(
470                "USB serial LLM call completed for connection {} ({}) decision=model_answered",
471                connection_id, what
472            ),
473            Err(e) => {
474                // `decision=` tags, as `src/server/radius/` does. A CDC overrun is all the
475                // wire can carry and it says nothing about *why* the data was dropped, so
476                // the distinction has to live in the log.
477                console_error!(
478                    status_tx,
479                    "LLM call failed for USB serial connection {} ({}) \
480                     decision=fail_closed_llm_error: {}",
481                    connection_id,
482                    what,
483                    e
484                );
485
486                // The host wrote and nobody could answer, so those bytes are gone. CDC PSTN 1.2
487                // has a word for that — `SERIAL_STATE` with `bOverRun` — and it is the only
488                // thing a serial port *can* say, since the class has no request/response framing
489                // to fail. Silence would be indistinguishable from a peer with nothing to send.
490                //
491                // Only `data_received` earns one: attach has had nothing written to it yet, and
492                // by detach the port is gone.
493                if what == "data_received" && protocol.signal_overrun(connection_id) {
494                    let _ = status_tx.send(format!(
495                        "[ERROR] USB serial port {connection_id} signalled SERIAL_STATE overrun: \
496                         the host's data was dropped because the model could not be reached"
497                    ));
498                    warn!(
499                        "USB serial connection {} queued a SERIAL_STATE overrun notification \
500                         after the data_received handler failed",
501                        connection_id
502                    );
503                }
504            }
505        }
506
507        let mut conns = connections.lock().await;
508        if let Some(conn) = conns.get_mut(&connection_id) {
509            conn.state = ConnectionState::Idle;
510        }
511    }
512}