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}