pub mod actions;
#[cfg(all(unix, feature = "libusb"))]
pub mod handler;
#[cfg(all(unix, feature = "libusb"))]
pub use actions::UsbSerialProtocol;
#[cfg(all(unix, feature = "libusb"))]
use anyhow::Result;
#[cfg(all(unix, feature = "libusb"))]
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
#[cfg(all(unix, feature = "libusb"))]
use tokio::sync::{mpsc, Mutex};
#[cfg(all(unix, feature = "libusb"))]
use tracing::{debug, error, info, warn};
#[cfg(all(unix, feature = "libusb"))]
use crate::{
console_error,
llm::action_helper::call_llm,
llm::OllamaClient,
protocol::Event,
server::accept_bounded::{accept_bounded, ConnectionLimiter},
server::connection::ConnectionId,
server::usb::guard::{MAX_USBIP_CONNECTIONS, USBIP_NO_REFUSAL},
state::app_state::AppState,
};
#[cfg(all(unix, feature = "libusb"))]
use actions::{
USB_SERIAL_ATTACHED_EVENT, USB_SERIAL_DATA_RECEIVED_EVENT, USB_SERIAL_DETACHED_EVENT,
};
#[cfg(all(unix, feature = "libusb"))]
const MAX_EVENT_BYTES: usize = 8 * 1024;
#[cfg(all(unix, feature = "libusb"))]
#[derive(Debug, Clone, PartialEq)]
enum ConnectionState {
Idle,
Processing,
}
#[cfg(all(unix, feature = "libusb"))]
struct ConnectionData {
state: ConnectionState,
}
#[cfg(all(unix, feature = "libusb"))]
pub struct UsbSerialServer;
#[cfg(all(unix, feature = "libusb"))]
impl UsbSerialServer {
pub async fn spawn_with_llm_actions(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
first_byte_timeout_secs: Option<u64>,
idle_timeout_secs: Option<u64>,
) -> Result<SocketAddr> {
let listener =
crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
let local_addr = listener.local_addr()?;
info!("USB Serial server listening on {}", local_addr);
let _ = status_tx.send(format!("USB Serial server listening on {}", local_addr));
let connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>> =
Arc::new(Mutex::new(HashMap::new()));
let protocol = Arc::new(UsbSerialProtocol::new());
let deadlines = crate::server::usb::guard::UsbIpDeadlines::from_secs(
first_byte_timeout_secs,
idle_timeout_secs,
);
let task_registrar = app_state.clone();
let limiter = ConnectionLimiter::new(MAX_USBIP_CONNECTIONS);
let accept_handle = tokio::spawn(async move {
loop {
match accept_bounded(
&listener,
&limiter,
USBIP_NO_REFUSAL,
"USB serial",
Some(&status_tx),
)
.await
{
Ok((stream, remote_addr, permit)) => {
let connection_id =
ConnectionId::new(app_state.get_next_unified_id().await);
info!(
"USB/IP connection {} from {} (USB serial)",
connection_id, remote_addr
);
use crate::state::server::{
ConnectionState as ServerConnectionState, ConnectionStatus,
ProtocolConnectionInfo,
};
let now = crate::utils::clock::Instant::now();
let conn_state = ServerConnectionState {
id: connection_id,
remote_addr,
local_addr: stream.local_addr().unwrap_or(local_addr),
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
"state": "WaitingForImport",
"baud_rate": 115200
})),
};
app_state
.add_connection_to_server(server_id, conn_state)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
let llm_client_clone = llm_client.clone();
let app_state_clone = app_state.clone();
let status_tx_clone = status_tx.clone();
let connections_clone = connections.clone();
let protocol_clone = protocol.clone();
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let _permit = permit;
if let Err(e) = Self::handle_connection(
stream,
connection_id,
remote_addr,
llm_client_clone,
app_state_clone,
status_tx_clone,
connections_clone,
protocol_clone,
server_id,
deadlines,
)
.await
{
error!("USB serial connection {} error: {}", connection_id, e);
}
})
.await;
}
Err(e) => {
error!("USB serial accept failed, stopping accept loop: {}", e);
break;
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
#[allow(clippy::too_many_arguments)]
async fn handle_connection(
stream: tokio::net::TcpStream,
connection_id: ConnectionId,
remote_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
protocol: Arc<UsbSerialProtocol>,
server_id: crate::state::ServerId,
deadlines: crate::server::usb::guard::UsbIpDeadlines,
) -> Result<()> {
let local_addr = stream.local_addr().unwrap_or(remote_addr);
connections.lock().await.insert(
connection_id,
ConnectionData {
state: ConnectionState::Idle,
},
);
let (rx_tx, mut rx_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let serial_handler = Arc::new(std::sync::Mutex::new(Box::new(
handler::UsbCdcAcmSerialHandler::new(rx_tx),
)
as Box<dyn usbip::UsbInterfaceHandler + Send>));
protocol.set_handler(connection_id, serial_handler.clone());
let device = usbip::UsbDevice::new(0).with_interface(
usbip::ClassCode::CDC as u8,
usbip::cdc::CDC_ACM_SUBCLASS,
0x01, Some("NetGet Virtual Serial Port"),
handler::UsbCdcAcmSerialHandler::endpoints(),
serial_handler.clone(),
);
let usbip_server = Arc::new(usbip::UsbIpServer::new_simulated(vec![device]));
let _ = status_tx.send(format!(
"USB serial port ready on {} - run: sudo usbip attach -r {} -b 0-0-0",
local_addr,
local_addr.ip()
));
let guard_status_tx = status_tx.clone();
let (import_tx, mut import_rx) = tokio::sync::oneshot::channel();
let mut usbip_task = tokio::spawn(async move {
match crate::server::usb::guard::run_guarded_usbip(
stream,
usbip_server,
"USB serial",
connection_id.to_string(),
guard_status_tx,
Some(import_tx),
deadlines,
)
.await
{
Ok(()) => debug!(
"USB/IP session ended for serial connection {}",
connection_id
),
Err(e) => debug!(
"USB/IP session for serial connection {} ended with error: {}",
connection_id, e
),
}
});
let mut import_pending = true;
let mut imported = false;
loop {
tokio::select! {
asked = &mut import_rx, if import_pending => {
import_pending = false;
if asked.is_ok() {
imported = true;
Self::call_llm_for_event(
connection_id,
&llm_client,
&app_state,
&connections,
&protocol,
&status_tx,
server_id,
Event::new(
&USB_SERIAL_ATTACHED_EVENT,
serde_json::json!({ "connection_id": connection_id.to_string() }),
),
"attach",
)
.await;
}
}
received = rx_rx.recv() => {
let Some(mut data) = received else { break };
let mut overran = data.len() > MAX_EVENT_BYTES;
data.truncate(MAX_EVENT_BYTES);
while data.len() < MAX_EVENT_BYTES {
let Ok(more) = rx_rx.try_recv() else { break };
let room = MAX_EVENT_BYTES - data.len();
if more.len() > room {
data.extend_from_slice(&more[..room]);
overran = true;
} else {
data.extend_from_slice(&more);
}
}
if overran {
warn!(
"USB serial connection {} clamped the host's backlog to {} bytes",
connection_id, MAX_EVENT_BYTES
);
if protocol.signal_overrun(connection_id) {
let _ = status_tx.send(format!(
"[ERROR] USB serial port {connection_id} signalled \
SERIAL_STATE overrun: the host wrote more than \
{MAX_EVENT_BYTES} bytes while the model was busy"
));
}
}
let text = String::from_utf8_lossy(&data).to_string();
debug!(
"USB serial connection {} received {} byte(s) from host",
connection_id,
data.len()
);
Self::call_llm_for_event(
connection_id,
&llm_client,
&app_state,
&connections,
&protocol,
&status_tx,
server_id,
Event::new(
&USB_SERIAL_DATA_RECEIVED_EVENT,
serde_json::json!({
"connection_id": connection_id.to_string(),
"data": text,
}),
),
"data_received",
)
.await;
}
_ = &mut usbip_task => break,
}
}
info!(
"USB serial host detached on connection {} from {}",
connection_id, remote_addr
);
if imported {
Self::call_llm_for_event(
connection_id,
&llm_client,
&app_state,
&connections,
&protocol,
&status_tx,
server_id,
Event::new(
&USB_SERIAL_DETACHED_EVENT,
serde_json::json!({ "connection_id": connection_id.to_string() }),
),
"detach",
)
.await;
}
protocol.remove_handler(connection_id);
connections.lock().await.remove(&connection_id);
app_state
.close_connection_on_server(server_id, connection_id)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn call_llm_for_event(
connection_id: ConnectionId,
llm_client: &OllamaClient,
app_state: &Arc<AppState>,
connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
protocol: &Arc<UsbSerialProtocol>,
status_tx: &mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
event: Event,
what: &str,
) {
{
let mut conns = connections.lock().await;
match conns.get_mut(&connection_id) {
Some(conn) if conn.state != ConnectionState::Idle => {
debug!(
"USB serial connection {} already processing, skipping {} event",
connection_id, what
);
return;
}
Some(conn) => conn.state = ConnectionState::Processing,
None => {}
}
}
let result = call_llm(
llm_client,
app_state,
server_id,
Some(connection_id),
&event,
protocol.as_ref(),
)
.await;
match result {
Ok(_) => info!(
"USB serial LLM call completed for connection {} ({}) decision=model_answered",
connection_id, what
),
Err(e) => {
console_error!(
status_tx,
"LLM call failed for USB serial connection {} ({}) \
decision=fail_closed_llm_error: {}",
connection_id,
what,
e
);
if what == "data_received" && protocol.signal_overrun(connection_id) {
let _ = status_tx.send(format!(
"[ERROR] USB serial port {connection_id} signalled SERIAL_STATE overrun: \
the host's data was dropped because the model could not be reached"
));
warn!(
"USB serial connection {} queued a SERIAL_STATE overrun notification \
after the data_received handler failed",
connection_id
);
}
}
}
let mut conns = connections.lock().await;
if let Some(conn) = conns.get_mut(&connection_id) {
conn.state = ConnectionState::Idle;
}
}
}