Skip to main content

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}