pub mod actions;
#[cfg(all(unix, feature = "libusb"))]
pub mod handler;
#[cfg(all(unix, feature = "libusb"))]
pub use actions::UsbKeyboardProtocol;
#[cfg(all(unix, feature = "libusb"))]
use anyhow::Result;
#[cfg(all(unix, feature = "libusb"))]
use std::collections::HashMap;
#[cfg(all(unix, feature = "libusb"))]
use std::net::SocketAddr;
#[cfg(all(unix, feature = "libusb"))]
use std::sync::Arc;
#[cfg(all(unix, feature = "libusb"))]
use tokio::sync::{mpsc, Mutex};
#[cfg(all(unix, feature = "libusb"))]
use tracing::{debug, error, info};
#[cfg(all(unix, feature = "libusb"))]
use crate::console_error;
#[cfg(all(unix, feature = "libusb"))]
use crate::llm::action_helper::call_llm;
#[cfg(all(unix, feature = "libusb"))]
use crate::llm::ollama_client::OllamaClient;
#[cfg(all(unix, feature = "libusb"))]
use crate::protocol::Event;
#[cfg(all(unix, feature = "libusb"))]
use crate::server::accept_bounded::{accept_bounded, ConnectionLimiter};
#[cfg(all(unix, feature = "libusb"))]
use crate::server::connection::ConnectionId;
#[cfg(all(unix, feature = "libusb"))]
use crate::server::usb::guard::{MAX_USBIP_CONNECTIONS, USBIP_NO_REFUSAL};
#[cfg(all(unix, feature = "libusb"))]
use crate::state::app_state::AppState;
#[cfg(all(unix, feature = "libusb"))]
use actions::{
USB_KEYBOARD_ATTACHED_EVENT, USB_KEYBOARD_DETACHED_EVENT, USB_KEYBOARD_LED_STATUS_EVENT,
};
#[cfg(all(unix, feature = "libusb"))]
use handler::{LedState, NetGetKeyboardHandler};
#[cfg(all(unix, feature = "libusb"))]
#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)]
enum ConnectionState {
Idle,
Processing,
Accumulating,
}
#[cfg(all(unix, feature = "libusb"))]
struct ConnectionData {
state: ConnectionState,
#[allow(dead_code)]
memory: String,
led_status: u8,
}
#[cfg(all(unix, feature = "libusb"))]
pub struct UsbKeyboardServer;
#[cfg(all(unix, feature = "libusb"))]
impl UsbKeyboardServer {
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 Keyboard server listening on {}", local_addr);
let _ = status_tx.send(format!("USB Keyboard server listening on {}", local_addr));
let connections = Arc::new(Mutex::new(HashMap::new()));
let protocol = Arc::new(crate::server::usb::keyboard::UsbKeyboardProtocol::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 keyboard",
Some(&status_tx),
)
.await
{
Ok((stream, remote_addr, permit)) => {
let connection_id =
ConnectionId::new(app_state.get_next_unified_id().await);
let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
info!(
"USB/IP connection {} from {} (USB keyboard device)",
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: local_addr_conn,
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",
"led_status": 0
})),
};
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 keyboard connection {} error: {}",
connection_id, e
);
}
})
.await;
}
Err(e) => {
error!("USB keyboard 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<crate::server::usb::keyboard::UsbKeyboardProtocol>,
server_id: crate::state::ServerId,
deadlines: crate::server::usb::guard::UsbIpDeadlines,
) -> Result<()> {
info!(
"USB keyboard connection {} from {} - device ready for USB/IP import",
connection_id, remote_addr
);
let local_addr = stream.local_addr().unwrap_or(remote_addr);
connections.lock().await.insert(
connection_id,
ConnectionData {
state: ConnectionState::Idle,
memory: String::new(),
led_status: 0,
},
);
let (led_tx, mut led_rx) = mpsc::unbounded_channel::<LedState>();
let handler = Arc::new(std::sync::Mutex::new(Box::new(
NetGetKeyboardHandler::new().with_led_events(led_tx),
)
as Box<dyn usbip::UsbInterfaceHandler + Send>));
protocol.set_handler(connection_id, handler.clone());
let device = usbip::UsbDevice::new(0).with_interface(
usbip::ClassCode::HID as u8,
0x00, 0x00, Some("NetGet Virtual Keyboard"),
vec![usbip::UsbEndpoint {
address: 0x81, attributes: 0x03, max_packet_size: 0x08, interval: 10, }],
handler.clone(),
);
let usbip_server = Arc::new(usbip::UsbIpServer::new_simulated(vec![device]));
info!(
"USB keyboard device ready for connection {} from {}",
connection_id, remote_addr
);
let _ = status_tx.send(format!(
"USB keyboard 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 usbip_task = tokio::spawn(async move {
match crate::server::usb::guard::run_guarded_usbip(
stream,
usbip_server,
"USB keyboard",
connection_id.to_string(),
guard_status_tx,
Some(import_tx),
deadlines,
)
.await
{
Ok(()) => debug!(
"USB/IP session ended for keyboard connection {}",
connection_id
),
Err(e) => debug!(
"USB/IP session for keyboard connection {} ended with error: {}",
connection_id, e
),
}
});
let mut usbip_task = usbip_task;
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;
if let Err(e) = Self::call_llm_on_attach(
connection_id,
&llm_client,
&app_state,
&status_tx,
&connections,
&protocol,
server_id,
)
.await
{
error!(
"Failed to call LLM on keyboard attach for connection {}: {}",
connection_id, e
);
}
}
}
received = led_rx.recv() => {
let Some(mut leds) = received else { break };
while let Ok(newer) = led_rx.try_recv() {
leds = newer;
}
Self::call_llm_on_led_status(
connection_id,
leds,
&llm_client,
&app_state,
&status_tx,
&connections,
&protocol,
server_id,
)
.await;
}
_ = &mut usbip_task => break,
}
}
info!(
"USB keyboard host detached on connection {} from {}",
connection_id, remote_addr
);
if imported {
if let Err(e) = Self::call_llm_on_detach(
connection_id,
&llm_client,
&app_state,
&status_tx,
&connections,
&protocol,
server_id,
)
.await
{
error!(
"Failed to call LLM on keyboard detach for connection {}: {}",
connection_id, e
);
}
}
protocol.remove_handler(connection_id);
connections.lock().await.remove(&connection_id);
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn call_llm_on_led_status(
connection_id: ConnectionId,
leds: LedState,
llm_client: &OllamaClient,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
protocol: &Arc<crate::server::usb::keyboard::UsbKeyboardProtocol>,
server_id: crate::state::ServerId,
) {
{
let mut conns = connections.lock().await;
match conns.get_mut(&connection_id) {
Some(conn) if conn.state != ConnectionState::Idle => {
debug!(
"USB keyboard connection {} already processing, skipping LED event",
connection_id
);
return;
}
Some(conn) => {
conn.state = ConnectionState::Processing;
conn.led_status = leds.raw;
}
None => {}
}
}
let event = Event::new(
&USB_KEYBOARD_LED_STATUS_EVENT,
serde_json::json!({
"connection_id": connection_id.to_string(),
"num_lock": leds.num_lock(),
"caps_lock": leds.caps_lock(),
"scroll_lock": leds.scroll_lock(),
}),
);
match call_llm(
llm_client,
app_state,
server_id,
Some(connection_id),
&event,
protocol.as_ref(),
)
.await
{
Ok(execution_result) => {
let acted =
execution_result.protocol_results.len() + execution_result.raw_actions.len();
info!(
"USB keyboard LLM call completed (led_status) for connection {} decision={} actions={}",
connection_id,
if acted == 0 {
"model_no_actions"
} else {
"model_actions"
},
acted
);
}
Err(e) => console_error!(
status_tx,
"LLM call failed for USB keyboard led_status on connection {} decision=fail_closed_llm_error: \
{}; the keyboard stays silent (see the module docs for why silence is the \
refusal here)",
connection_id,
e
),
}
let mut conns = connections.lock().await;
if let Some(conn) = conns.get_mut(&connection_id) {
conn.state = ConnectionState::Idle;
}
}
async fn call_llm_on_detach(
connection_id: ConnectionId,
llm_client: &OllamaClient,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
protocol: &Arc<crate::server::usb::keyboard::UsbKeyboardProtocol>,
server_id: crate::state::ServerId,
) -> Result<()> {
{
let mut conns = connections.lock().await;
if let Some(conn_data) = conns.get_mut(&connection_id) {
conn_data.state = ConnectionState::Processing;
}
}
let event = Event::new(
&USB_KEYBOARD_DETACHED_EVENT,
serde_json::json!({
"connection_id": connection_id.to_string(),
}),
);
let result = call_llm(
llm_client,
app_state,
server_id,
Some(connection_id),
&event,
protocol.as_ref(),
)
.await;
match &result {
Err(e) => console_error!(
status_tx,
"LLM call failed for USB keyboard detach on connection {} decision=fail_closed_llm_error: {}",
connection_id,
e
),
Ok(execution_result) => {
let acted =
execution_result.protocol_results.len() + execution_result.raw_actions.len();
info!(
"USB keyboard detach LLM call completed for connection {} decision={} actions={}",
connection_id,
if acted == 0 {
"model_no_actions"
} else {
"model_actions"
},
acted
);
}
}
let mut conns = connections.lock().await;
if let Some(conn_data) = conns.get_mut(&connection_id) {
conn_data.state = ConnectionState::Idle;
}
Ok(())
}
async fn call_llm_on_attach(
connection_id: ConnectionId,
llm_client: &OllamaClient,
app_state: &Arc<AppState>,
status_tx: &mpsc::UnboundedSender<String>,
connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
protocol: &Arc<crate::server::usb::keyboard::UsbKeyboardProtocol>,
server_id: crate::state::ServerId,
) -> Result<()> {
{
let conns = connections.lock().await;
if let Some(conn_data) = conns.get(&connection_id) {
if conn_data.state != ConnectionState::Idle {
debug!(
"USB keyboard connection {} already processing, skipping LLM call",
connection_id
);
return Ok(());
}
}
}
{
let mut conns = connections.lock().await;
if let Some(conn_data) = conns.get_mut(&connection_id) {
conn_data.state = ConnectionState::Processing;
}
}
let (_instruction, _memory) = {
if let Some(server) = app_state.get_server(server_id).await {
(server.instruction.clone(), String::new())
} else {
return Err(anyhow::anyhow!("Server not found"));
}
};
let event = Event::new(
&USB_KEYBOARD_ATTACHED_EVENT,
serde_json::json!({
"connection_id": connection_id.to_string(),
}),
);
info!(
"Calling LLM for USB keyboard attached event on connection {}",
connection_id
);
let result = call_llm(
llm_client,
app_state,
server_id,
Some(connection_id),
&event,
protocol.as_ref(),
)
.await;
match result {
Ok(execution_result) => {
let acted =
execution_result.protocol_results.len() + execution_result.raw_actions.len();
if acted == 0 {
info!(
"USB keyboard LLM call completed for connection {} decision=model_no_actions (nothing typed)",
connection_id
);
} else {
info!(
"USB keyboard LLM call completed for connection {} decision=model_actions actions={}",
connection_id, acted
);
}
let mut conns = connections.lock().await;
if let Some(conn_data) = conns.get_mut(&connection_id) {
conn_data.state = ConnectionState::Idle;
}
}
Err(e) => {
console_error!(
status_tx,
"LLM call failed for USB keyboard connection {} decision=fail_closed_llm_error: {}; no \
HID report will be sent, so the host sees a keyboard nobody is typing on",
connection_id,
e
);
let mut conns = connections.lock().await;
if let Some(conn_data) = conns.get_mut(&connection_id) {
conn_data.state = ConnectionState::Idle;
}
}
}
Ok(())
}
}