netget/client/usb/mod.rs
1//! USB client implementation
2pub mod actions;
3
4pub use actions::UsbClientProtocol;
5
6use anyhow::{anyhow, Context, Result};
7use nusb::transfer::{ControlIn, ControlOut, ControlType, Recipient, RequestBuffer};
8use std::net::SocketAddr;
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::{mpsc, Mutex};
12use tracing::{debug, error, info, trace, warn};
13
14use crate::client::llm_budget::call_llm_for_client;
15use crate::client::usb::actions::{
16 USB_BULK_DATA_RECEIVED_EVENT, USB_CONTROL_RESPONSE_EVENT, USB_DEVICE_OPENED_EVENT,
17 USB_INTERRUPT_DATA_RECEIVED_EVENT,
18};
19use crate::llm::ollama_client::OllamaClient;
20use crate::llm::ClientLlmResult;
21use crate::protocol::Event;
22use crate::state::app_state::AppState;
23use crate::state::{ClientId, ClientStatus};
24
25/// Read a byte-wide field out of an already-validated action payload, refusing a wrapped value.
26///
27/// The payload is built by `UsbClientProtocol::execute_action`, so in the ordinary case every
28/// field is present and in range. This exists because the read used to be `.unwrap() as u8`,
29/// and this code runs inside `tokio::spawn`: a panic there is swallowed, the client task dies,
30/// and the client goes on reporting `Connected` with nothing in the log to explain it.
31fn byte_of(data: &serde_json::Value, field: &str) -> Option<u8> {
32 data.get(field)
33 .and_then(|v| v.as_u64())
34 .and_then(|v| u8::try_from(v).ok())
35}
36
37/// The 16-bit counterpart of [`byte_of`].
38fn word_of(data: &serde_json::Value, field: &str) -> Option<u16> {
39 data.get(field)
40 .and_then(|v| v.as_u64())
41 .and_then(|v| u16::try_from(v).ok())
42}
43
44/// Read a transfer length, re-applying the same bound `execute_action` enforces.
45///
46/// Checked twice on purpose: this value reaches `RequestBuffer::new`, which allocates it
47/// outright, and the two readers are far enough apart that a future caller could reach this one
48/// without passing the first.
49fn length_of(data: &serde_json::Value) -> Option<usize> {
50 let value = data.get("length").and_then(|v| v.as_u64())?;
51 if value > crate::client::usb::actions::MAX_TRANSFER_BYTES as u64 {
52 return None;
53 }
54 Some(value as usize)
55}
56
57/// Connection state for LLM processing
58#[derive(Debug, Clone, PartialEq)]
59#[allow(dead_code)]
60enum ConnectionState {
61 Idle,
62 Processing,
63 Accumulating,
64}
65
66/// Per-client data for LLM handling
67struct ClientData {
68 #[allow(dead_code)]
69 state: ConnectionState,
70 #[allow(dead_code)]
71 queued_events: Vec<Event>,
72 memory: String,
73}
74
75/// What applying one action to the claimed USB interface actually did.
76///
77/// `Sent` is reported only for an OUT transfer whose completion status came back
78/// `Ok` — those bytes really reached the device. Everything else (an IN transfer,
79/// a no-op, a failed transfer) reports `Executed` with a specific reason, because
80/// nothing left the host.
81pub enum UsbApplied {
82 /// This many payload bytes were delivered to the device.
83 Sent(usize),
84 /// Invalid input was refused before a device operation.
85 Rejected(String),
86 /// The action ran but wrote nothing; the string says what it did.
87 Executed(String),
88 /// The device was detached.
89 Disconnected,
90}
91
92/// USB device information parsed from remote_addr or instruction
93#[derive(Debug, Clone)]
94struct UsbDeviceInfo {
95 vendor_id: u16,
96 product_id: u16,
97 interface_number: u8,
98}
99
100impl UsbDeviceInfo {
101 /// Parse USB device info from string like "vid:1234,pid:5678" or "1234:5678"
102 fn from_string(s: &str) -> Result<Self> {
103 // Try formats:
104 // - "vid:1234,pid:5678,interface:0"
105 // - "1234:5678:0"
106 // - "0x1234:0x5678:0"
107
108 let parts: Vec<&str> = s.split(',').collect();
109
110 let mut vendor_id: Option<u16> = None;
111 let mut product_id: Option<u16> = None;
112 let mut interface_number: u8 = 0; // Default to interface 0
113
114 if parts.len() == 1 {
115 // Colon-separated format: "1234:5678" or "1234:5678:0"
116 let colon_parts: Vec<&str> = s.split(':').collect();
117 if colon_parts.len() >= 2 {
118 vendor_id = Some(Self::parse_hex_u16(colon_parts[0])?);
119 product_id = Some(Self::parse_hex_u16(colon_parts[1])?);
120 if colon_parts.len() >= 3 {
121 interface_number = colon_parts[2].parse()?;
122 }
123 }
124 } else {
125 // Comma-separated key:value format
126 for part in parts {
127 let kv: Vec<&str> = part.trim().split(':').collect();
128 if kv.len() == 2 {
129 match kv[0].trim().to_lowercase().as_str() {
130 "vid" | "vendor" => vendor_id = Some(Self::parse_hex_u16(kv[1].trim())?),
131 "pid" | "product" => product_id = Some(Self::parse_hex_u16(kv[1].trim())?),
132 "interface" | "if" => interface_number = kv[1].trim().parse()?,
133 _ => {}
134 }
135 }
136 }
137 }
138
139 Ok(UsbDeviceInfo {
140 vendor_id: vendor_id.ok_or_else(|| anyhow!("Missing vendor_id"))?,
141 product_id: product_id.ok_or_else(|| anyhow!("Missing product_id"))?,
142 interface_number,
143 })
144 }
145
146 /// Parse hex string with optional 0x prefix
147 fn parse_hex_u16(s: &str) -> Result<u16> {
148 let s = s.trim();
149 if s.starts_with("0x") || s.starts_with("0X") {
150 u16::from_str_radix(&s[2..], 16).context("Invalid hex number")
151 } else {
152 // Try hex first, then decimal
153 u16::from_str_radix(s, 16)
154 .or_else(|_| s.parse::<u16>())
155 .context("Invalid number")
156 }
157 }
158}
159
160/// USB client that connects to a USB device
161pub struct UsbClient;
162
163impl UsbClient {
164 /// Connect to a USB device with integrated LLM actions
165 pub async fn connect_with_llm_actions(
166 remote_addr: String,
167 llm_client: OllamaClient,
168 app_state: Arc<AppState>,
169 status_tx: mpsc::UnboundedSender<String>,
170 client_id: ClientId,
171 ) -> Result<SocketAddr> {
172 // Parse USB device info
173 let device_info = UsbDeviceInfo::from_string(&remote_addr)
174 .context("Failed to parse USB device info from remote_addr")?;
175
176 info!(
177 "USB client {} connecting to device VID:{:04x} PID:{:04x}",
178 client_id, device_info.vendor_id, device_info.product_id
179 );
180
181 // Find and open USB device
182 let device_info_clone = device_info.clone();
183 let device = tokio::task::spawn_blocking(move || {
184 let devices = nusb::list_devices().context("Failed to list USB devices")?;
185
186 for dev_info in devices {
187 if dev_info.vendor_id() == device_info_clone.vendor_id
188 && dev_info.product_id() == device_info_clone.product_id
189 {
190 return dev_info.open().context("Failed to open USB device");
191 }
192 }
193
194 Err(anyhow!(
195 "USB device VID:{:04x} PID:{:04x} not found",
196 device_info_clone.vendor_id,
197 device_info_clone.product_id
198 ))
199 })
200 .await??;
201
202 // Get manufacturer/product strings
203 // Use language ID 0x0409 (English - United States)
204 const LANG_EN_US: u16 = 0x0409;
205
206 let (manufacturer, product) = tokio::task::spawn_blocking({
207 let device = device.clone();
208 move || -> Result<(Option<String>, Option<String>)> {
209 // Try to get manufacturer string (index 1 is common)
210 let manufacturer = device
211 .get_string_descriptor(1, LANG_EN_US, Duration::from_secs(1))
212 .ok();
213
214 // Try to get product string (index 2 is common)
215 let product = device
216 .get_string_descriptor(2, LANG_EN_US, Duration::from_secs(1))
217 .ok();
218
219 Ok((manufacturer, product))
220 }
221 })
222 .await??;
223
224 info!(
225 "USB client {} opened device: {} {}",
226 client_id,
227 manufacturer.as_deref().unwrap_or("Unknown"),
228 product.as_deref().unwrap_or("Unknown")
229 );
230
231 // Claim interface
232 let interface = tokio::task::spawn_blocking({
233 let device = device.clone();
234 let interface_num = device_info.interface_number;
235 move || {
236 device
237 .claim_interface(interface_num)
238 .context(format!("Failed to claim interface {}", interface_num))
239 }
240 })
241 .await??;
242
243 info!(
244 "USB client {} claimed interface {}",
245 client_id, device_info.interface_number
246 );
247
248 // Update client state
249 app_state
250 .update_client_status(client_id, ClientStatus::Connected)
251 .await;
252 let _ = status_tx.send(format!(
253 "[CLIENT] USB client {} connected to VID:{:04x} PID:{:04x}",
254 client_id, device_info.vendor_id, device_info.product_id
255 ));
256 let _ = status_tx.send("__UPDATE_UI__".to_string());
257
258 // Initialize client data
259 let client_data = Arc::new(Mutex::new(ClientData {
260 state: ConnectionState::Idle,
261 queued_events: Vec::new(),
262 memory: String::new(),
263 }));
264
265 // Create a fake socket address (USB doesn't use sockets)
266 // We'll use the vendor/product IDs encoded in the address
267 let fake_addr: SocketAddr = format!(
268 "127.{}.{}.{}:{}",
269 (device_info.vendor_id >> 8) & 0xFF,
270 device_info.vendor_id & 0xFF,
271 (device_info.product_id >> 8) & 0xFF,
272 device_info.product_id & 0xFF
273 )
274 .parse()
275 .unwrap();
276
277 // Send initial connected event
278 let protocol = Arc::new(UsbClientProtocol::new());
279
280 // Command channel for injected actions (the dashboard's [ send ]).
281 // Registered BEFORE the connected-event LLM call below: a dashboard-created
282 // client defaults to a `*` -> manual rule, so that call can park for minutes
283 // waiting for a human, and the operator must be able to reach the device while
284 // it waits.
285 let command_rx =
286 crate::client::command_support::register_command_channel(&app_state, client_id).await;
287 let cmd_task = tokio::spawn({
288 let protocol = protocol.clone();
289 let interface = interface.clone();
290 let app_state = app_state.clone();
291 let llm_client = llm_client.clone();
292 let status_tx = status_tx.clone();
293 let client_data = client_data.clone();
294 async move {
295 Self::command_loop(
296 command_rx,
297 protocol,
298 interface,
299 client_id,
300 app_state,
301 llm_client,
302 status_tx,
303 client_data,
304 )
305 .await;
306 }
307 });
308 app_state.register_client_task(client_id, cmd_task).await;
309 let event = Event::new(
310 &USB_DEVICE_OPENED_EVENT,
311 serde_json::json!({
312 "vendor_id": format!("{:04x}", device_info.vendor_id),
313 "product_id": format!("{:04x}", device_info.product_id),
314 "manufacturer": manufacturer.unwrap_or_else(|| "Unknown".to_string()),
315 "product": product.unwrap_or_else(|| "Unknown".to_string()),
316 }),
317 );
318
319 // Call LLM with connected event
320 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
321 match call_llm_for_client(
322 &llm_client,
323 &app_state,
324 client_id.to_string(),
325 &instruction,
326 &client_data.lock().await.memory,
327 Some(&event),
328 protocol.as_ref(),
329 &status_tx,
330 )
331 .await
332 {
333 Ok(ClientLlmResult {
334 actions,
335 memory_updates,
336 }) => {
337 // Update memory
338 if let Some(mem) = memory_updates {
339 client_data.lock().await.memory = mem;
340 }
341
342 // Execute initial actions
343 Self::execute_actions(
344 actions,
345 protocol.clone(),
346 &interface,
347 client_id,
348 &app_state,
349 &llm_client,
350 &status_tx,
351 &client_data,
352 )
353 .await;
354 }
355 Err(e) => {
356 error!("LLM error for USB client {}: {}", client_id, e);
357 }
358 }
359 }
360
361 // The command loop is now the task that keeps this client alive: USB devices
362 // don't push data at us, so there is nothing else to wait on, and an idle
363 // `sleep(60)` loop kept the client alive while being unable to do anything.
364 // It ends when the channel closes (the client was removed) or an injected
365 // `detach_device` disconnects.
366
367 Ok(fake_addr)
368 }
369
370 /// Drain injected commands until the channel closes (the client was removed,
371 /// which drops the handle) or an injected `detach_device` ends the session.
372 ///
373 /// The generic `command_support::handle_stream_client_command` cannot serve this
374 /// client: it owns no socket, and every USB verb yields a
375 /// `ClientActionResult::Custom` that only nusb can carry out. So the action goes
376 /// through [`Self::apply_usb_result`] — the exact function the LLM path uses,
377 /// including the follow-up events — and the outcome is recorded and replied the
378 /// way the generic arm does it.
379 #[allow(clippy::too_many_arguments)]
380 async fn command_loop(
381 mut command_rx: tokio::sync::mpsc::Receiver<crate::state::client_handles::ClientCommand>,
382 protocol: Arc<UsbClientProtocol>,
383 interface: nusb::Interface,
384 client_id: ClientId,
385 app_state: Arc<AppState>,
386 llm_client: OllamaClient,
387 status_tx: mpsc::UnboundedSender<String>,
388 client_data: Arc<Mutex<ClientData>>,
389 ) {
390 use crate::llm::actions::client_trait::Client;
391 use crate::llm::actions::protocol_trait::Protocol;
392 use crate::state::client_handles::ClientSendOutcome;
393 use crate::state::AccessLogOwner;
394
395 while let Some(command) = command_rx.recv().await {
396 let action = command.action.clone();
397
398 // `execute_action` is the only step that can fail before the device is
399 // touched, so its error is a rejection (unknown verb / bad params) rather
400 // than a transfer failure.
401 let outcome = match protocol.as_ref().execute_action(action.clone()) {
402 Err(e) => ClientSendOutcome::Rejected {
403 error: e.to_string(),
404 },
405 Ok(result) => match Self::apply_usb_result(
406 result,
407 protocol.clone(),
408 &interface,
409 client_id,
410 &app_state,
411 &llm_client,
412 &status_tx,
413 &client_data,
414 )
415 .await
416 {
417 UsbApplied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
418 UsbApplied::Rejected(error) => ClientSendOutcome::Rejected { error },
419 UsbApplied::Executed(detail) => ClientSendOutcome::Executed { detail },
420 UsbApplied::Disconnected => ClientSendOutcome::Disconnected,
421 },
422 };
423
424 let outcome_json = serde_json::to_value(&outcome).unwrap_or(serde_json::Value::Null);
425 app_state
426 .record_access_log(
427 AccessLogOwner::Client(client_id.as_u32()),
428 protocol.protocol_name(),
429 None,
430 "injected_action",
431 action,
432 vec![outcome_json],
433 )
434 .await;
435
436 let disconnect = matches!(outcome, ClientSendOutcome::Disconnected);
437 let _ = status_tx.send("__UPDATE_UI__".to_string());
438 crate::client::command_support::reply(command, Ok(outcome));
439
440 if disconnect {
441 break;
442 }
443 }
444
445 // Every exit path lands here: drop the command handle so the dashboard stops
446 // offering [ send ] on a detached device and a late send fails fast.
447 app_state.remove_client_handle(client_id).await;
448 let _ = status_tx.send("__UPDATE_UI__".to_string());
449 }
450
451 /// Execute USB actions from LLM
452 #[allow(clippy::too_many_arguments)]
453 async fn execute_actions(
454 actions: Vec<serde_json::Value>,
455 protocol: Arc<UsbClientProtocol>,
456 interface: &nusb::Interface,
457 client_id: ClientId,
458 app_state: &Arc<AppState>,
459 llm_client: &OllamaClient,
460 status_tx: &mpsc::UnboundedSender<String>,
461 client_data: &Arc<Mutex<ClientData>>,
462 ) {
463 use crate::llm::actions::client_trait::Client;
464
465 for action in actions {
466 match protocol.as_ref().execute_action(action) {
467 Ok(result) => {
468 if matches!(
469 Self::apply_usb_result(
470 result,
471 protocol.clone(),
472 interface,
473 client_id,
474 app_state,
475 llm_client,
476 status_tx,
477 client_data,
478 )
479 .await,
480 UsbApplied::Disconnected
481 ) {
482 break;
483 }
484 }
485 Err(e) => {
486 error!("USB client {} action error: {}", client_id, e);
487 }
488 }
489 }
490 }
491
492 /// Carry one already-decoded action out against the claimed interface. Shared by
493 /// the connected-event path, the follow-up event path and injected commands, so
494 /// the nusb transfer calls exist exactly once.
495 #[allow(clippy::too_many_arguments)]
496 async fn apply_usb_result(
497 action_result: crate::llm::actions::client_trait::ClientActionResult,
498 protocol: Arc<UsbClientProtocol>,
499 interface: &nusb::Interface,
500 client_id: ClientId,
501 app_state: &Arc<AppState>,
502 llm_client: &OllamaClient,
503 status_tx: &mpsc::UnboundedSender<String>,
504 client_data: &Arc<Mutex<ClientData>>,
505 ) -> UsbApplied {
506 {
507 match action_result {
508 crate::llm::actions::client_trait::ClientActionResult::Custom { name, data } => {
509 match name.as_str() {
510 "control_transfer" => {
511 // Read, never unwrapped. These fields arrive from
512 // `UsbClientProtocol::execute_action`, which validates them --
513 // but this runs inside `tokio::spawn`, where a panic is swallowed:
514 // the client task would die, the client would stay `Connected`,
515 // and nothing would say why. A malformed field is now an error the
516 // model is told about.
517 let (request_type, request, value, index) = match (
518 byte_of(&data, "request_type"),
519 byte_of(&data, "request"),
520 word_of(&data, "value"),
521 word_of(&data, "index"),
522 ) {
523 (Some(rt), Some(r), Some(v), Some(i)) => (rt, r, v, i),
524 _ => {
525 error!(
526 "USB client {} control_transfer has malformed fields: {}",
527 client_id, data
528 );
529 return UsbApplied::Executed(
530 "control_transfer: request_type, request, value and \
531 index must each be an integer in range"
532 .to_string(),
533 );
534 }
535 };
536 let out_data = match data.get("data") {
537 None => Vec::new(),
538 Some(value) => match value
539 .as_array()
540 .ok_or_else(|| anyhow::anyhow!("data must be a byte array"))
541 .and_then(|array| crate::client::wire_values::bytes(array))
542 {
543 Ok(bytes) => bytes,
544 Err(error) => return UsbApplied::Rejected(error.to_string()),
545 },
546 };
547 let length = match u16::try_from(length_of(&data).unwrap_or(0)) {
548 Ok(length) if out_data.len() <= u16::MAX as usize => length,
549 _ => {
550 return UsbApplied::Rejected(
551 "USB control transfer length exceeds u16".into(),
552 )
553 }
554 };
555
556 trace!(
557 "USB client {} control transfer: type={:02x} req={:02x} val={:04x} idx={:04x}",
558 client_id, request_type, request, value, index
559 );
560
561 // Execute control transfer. The completion's `status` is
562 // checked on BOTH directions: the OUT branch used to
563 // `let _ =` the completion, so a stalled or refused
564 // control-OUT was indistinguishable from a delivered one
565 // and could not be reported truthfully.
566 let interface_clone = interface.clone();
567 let is_in = out_data.is_empty() && length > 0;
568 let out_len = out_data.len();
569 let result: std::result::Result<Vec<u8>, String> = if is_in {
570 // IN transfer
571 let control_in = ControlIn {
572 control_type: ControlType::Vendor,
573 recipient: Recipient::Device,
574 request,
575 value,
576 index,
577 length,
578 };
579 let completion = interface_clone.control_in(control_in).await;
580 match completion.status {
581 Ok(()) => Ok(completion.data.to_vec()),
582 Err(e) => Err(e.to_string()),
583 }
584 } else {
585 // OUT transfer
586 let control_out = ControlOut {
587 control_type: ControlType::Vendor,
588 recipient: Recipient::Device,
589 request,
590 value,
591 index,
592 data: &out_data,
593 };
594 let completion = interface_clone.control_out(control_out).await;
595 match completion.status {
596 Ok(()) => Ok(Vec::new()),
597 Err(e) => Err(e.to_string()),
598 }
599 };
600
601 match result {
602 Ok(response_data) if !response_data.is_empty() => {
603 let received = response_data.len();
604 debug!(
605 "USB client {} control transfer received {} bytes",
606 client_id, received
607 );
608
609 // Send event to LLM
610 let event = Event::new(
611 &USB_CONTROL_RESPONSE_EVENT,
612 serde_json::json!({
613 "data_hex": hex::encode(&response_data),
614 "data_length": received,
615 }),
616 );
617
618 if let Some(instruction) =
619 app_state.get_instruction_for_client(client_id).await
620 {
621 // Copy the memory out before the call: the command
622 // loop shares this mutex and a guard held across an
623 // LLM round-trip would stall it.
624 let memory = client_data.lock().await.memory.clone();
625 if let Ok(ClientLlmResult {
626 actions: new_actions,
627 memory_updates,
628 }) = call_llm_for_client(
629 llm_client,
630 app_state,
631 client_id.to_string(),
632 &instruction,
633 &memory,
634 Some(&event),
635 protocol.as_ref(),
636 status_tx,
637 )
638 .await
639 {
640 if let Some(mem) = memory_updates {
641 client_data.lock().await.memory = mem;
642 }
643 Box::pin(Self::execute_actions(
644 new_actions,
645 protocol.clone(),
646 interface,
647 client_id,
648 app_state,
649 llm_client,
650 status_tx,
651 client_data,
652 ))
653 .await;
654 }
655 }
656 UsbApplied::Executed(format!(
657 "control_transfer IN: {received} bytes received"
658 ))
659 }
660 Ok(_) => {
661 trace!("USB client {} control transfer completed", client_id);
662 if is_in {
663 UsbApplied::Executed(
664 "control_transfer IN: 0 bytes received".to_string(),
665 )
666 } else {
667 // A control-OUT whose completion status is Ok really
668 // put `out_len` payload bytes on the USB wire.
669 UsbApplied::Sent(out_len)
670 }
671 }
672 Err(e) => {
673 error!(
674 "USB client {} control transfer error: {}",
675 client_id, e
676 );
677 UsbApplied::Executed(format!("control_transfer failed: {e}"))
678 }
679 }
680 }
681 "bulk_transfer_out" => {
682 let Some(endpoint) = byte_of(&data, "endpoint") else {
683 error!(
684 "USB client {} bulk_transfer_out has no usable endpoint: {}",
685 client_id, data
686 );
687 return UsbApplied::Executed(
688 "bulk_transfer_out: 'endpoint' must be an integer in 0..=255"
689 .to_string(),
690 );
691 };
692 let out_data = match data.get("data") {
693 None => Vec::new(),
694 Some(value) => match value
695 .as_array()
696 .ok_or_else(|| anyhow::anyhow!("data must be a byte array"))
697 .and_then(|array| crate::client::wire_values::bytes(array))
698 {
699 Ok(bytes) => bytes,
700 Err(error) => return UsbApplied::Rejected(error.to_string()),
701 },
702 };
703
704 trace!(
705 "USB client {} bulk OUT transfer: endpoint={:02x} length={}",
706 client_id,
707 endpoint,
708 out_data.len()
709 );
710
711 let interface_clone = interface.clone();
712 let out_len = out_data.len();
713 let result = interface_clone.bulk_out(endpoint, out_data).await;
714
715 match result.status {
716 Ok(_) => {
717 trace!("USB client {} bulk OUT completed", client_id);
718 // The transfer completed, so these bytes really
719 // reached the device.
720 UsbApplied::Sent(out_len)
721 }
722 Err(e) => {
723 error!("USB client {} bulk OUT error: {}", client_id, e);
724 UsbApplied::Executed(format!(
725 "bulk_transfer_out ep=0x{endpoint:02x} failed: {e}"
726 ))
727 }
728 }
729 }
730 "bulk_transfer_in" => {
731 let (Some(endpoint), Some(length)) =
732 (byte_of(&data, "endpoint"), length_of(&data))
733 else {
734 error!(
735 "USB client {} bulk_transfer_in has malformed fields: {}",
736 client_id, data
737 );
738 return UsbApplied::Executed(
739 "bulk_transfer_in: 'endpoint' must be 0..=255 and 'length' a \
740 bounded byte count"
741 .to_string(),
742 );
743 };
744
745 trace!(
746 "USB client {} bulk IN transfer: endpoint={:02x} length={}",
747 client_id,
748 endpoint,
749 length
750 );
751
752 let interface_clone = interface.clone();
753 let buffer = RequestBuffer::new(length);
754 let result = interface_clone.bulk_in(endpoint, buffer).await;
755
756 let response_data = result.data.to_vec();
757 let received = response_data.len();
758 if !response_data.is_empty() {
759 debug!(
760 "USB client {} bulk IN received {} bytes",
761 client_id, received
762 );
763
764 // Send event to LLM
765 let event = Event::new(
766 &USB_BULK_DATA_RECEIVED_EVENT,
767 serde_json::json!({
768 "data_hex": hex::encode(&response_data),
769 "data_length": response_data.len(),
770 "endpoint": endpoint,
771 }),
772 );
773
774 if let Some(instruction) =
775 app_state.get_instruction_for_client(client_id).await
776 {
777 // Copy the memory out before the call: the command
778 // loop shares this mutex and a guard held across an
779 // LLM round-trip would stall it.
780 let memory = client_data.lock().await.memory.clone();
781 if let Ok(ClientLlmResult {
782 actions: new_actions,
783 memory_updates,
784 }) = call_llm_for_client(
785 llm_client,
786 app_state,
787 client_id.to_string(),
788 &instruction,
789 &memory,
790 Some(&event),
791 protocol.as_ref(),
792 status_tx,
793 )
794 .await
795 {
796 if let Some(mem) = memory_updates {
797 client_data.lock().await.memory = mem;
798 }
799 Box::pin(Self::execute_actions(
800 new_actions,
801 protocol.clone(),
802 interface,
803 client_id,
804 app_state,
805 llm_client,
806 status_tx,
807 client_data,
808 ))
809 .await;
810 }
811 }
812 }
813 UsbApplied::Executed(format!(
814 "bulk_transfer_in ep=0x{endpoint:02x}: {received} bytes received"
815 ))
816 }
817 "interrupt_transfer_in" => {
818 let (Some(endpoint), Some(length)) =
819 (byte_of(&data, "endpoint"), length_of(&data))
820 else {
821 error!(
822 "USB client {} interrupt_transfer_in has malformed fields: {}",
823 client_id, data
824 );
825 return UsbApplied::Executed(
826 "interrupt_transfer_in: 'endpoint' must be 0..=255 and 'length' a \
827 bounded byte count"
828 .to_string(),
829 );
830 };
831
832 trace!(
833 "USB client {} interrupt IN transfer: endpoint={:02x} length={}",
834 client_id,
835 endpoint,
836 length
837 );
838
839 let interface_clone = interface.clone();
840 let buffer = RequestBuffer::new(length);
841 let result = interface_clone.interrupt_in(endpoint, buffer).await;
842
843 let response_data = result.data.to_vec();
844 let received = response_data.len();
845 if !response_data.is_empty() {
846 debug!(
847 "USB client {} interrupt IN received {} bytes",
848 client_id, received
849 );
850
851 // Send event to LLM
852 let event = Event::new(
853 &USB_INTERRUPT_DATA_RECEIVED_EVENT,
854 serde_json::json!({
855 "data_hex": hex::encode(&response_data),
856 "data_length": response_data.len(),
857 "endpoint": endpoint,
858 }),
859 );
860
861 if let Some(instruction) =
862 app_state.get_instruction_for_client(client_id).await
863 {
864 // Copy the memory out before the call: the command
865 // loop shares this mutex and a guard held across an
866 // LLM round-trip would stall it.
867 let memory = client_data.lock().await.memory.clone();
868 if let Ok(ClientLlmResult {
869 actions: new_actions,
870 memory_updates,
871 }) = call_llm_for_client(
872 llm_client,
873 app_state,
874 client_id.to_string(),
875 &instruction,
876 &memory,
877 Some(&event),
878 protocol.as_ref(),
879 status_tx,
880 )
881 .await
882 {
883 if let Some(mem) = memory_updates {
884 client_data.lock().await.memory = mem;
885 }
886 Box::pin(Self::execute_actions(
887 new_actions,
888 protocol.clone(),
889 interface,
890 client_id,
891 app_state,
892 llm_client,
893 status_tx,
894 client_data,
895 ))
896 .await;
897 }
898 }
899 }
900 UsbApplied::Executed(format!(
901 "interrupt_transfer_in ep=0x{endpoint:02x}: {received} bytes received"
902 ))
903 }
904 "claim_interface" => {
905 let interface_num =
906 byte_of(&data, "interface_number").unwrap_or_default();
907 info!(
908 "USB client {} interface {} already claimed, skipping",
909 client_id, interface_num
910 );
911 UsbApplied::Executed(format!(
912 "claim_interface {interface_num}: already claimed at connect"
913 ))
914 }
915 _ => {
916 warn!("USB client {} unknown custom action: {}", client_id, name);
917 UsbApplied::Executed(format!(
918 "custom result '{name}' has no USB handler"
919 ))
920 }
921 }
922 }
923 crate::llm::actions::client_trait::ClientActionResult::Disconnect => {
924 info!("USB client {} disconnecting", client_id);
925 app_state
926 .update_client_status(client_id, ClientStatus::Disconnected)
927 .await;
928 // Drop the command handle here rather than only in the command loop:
929 // the LLM can detach too, and a handle left behind would offer
930 // [ send ] into a detached device.
931 app_state.remove_client_handle(client_id).await;
932 let _ = status_tx.send("__UPDATE_UI__".to_string());
933 UsbApplied::Disconnected
934 }
935 other => {
936 debug!("USB client {} unhandled action result", client_id);
937 UsbApplied::Executed(format!("unhandled action result {other:?}"))
938 }
939 }
940 }
941 }
942}