Skip to main content

netget/client/usb/
actions.rs

1//! USB client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// Most bytes one transfer may carry in either direction.
15///
16/// `length` is handed straight to `nusb::transfer::RequestBuffer::new`, which allocates it up
17/// front, and it was read as `as_u64().unwrap() as usize` with nothing between the model and
18/// the allocator: `{"type": "bulk_transfer_in", "length": 4000000000}` asked for four gigabytes
19/// per call. 64 KiB is larger than any single USB transfer a device will complete in one go
20/// and leaves a wrong answer costing nothing.
21pub const MAX_TRANSFER_BYTES: usize = 64 * 1024;
22
23/// Read a field that becomes a single byte on the wire, refusing a value that would wrap.
24///
25/// Each of these used to be `as_u64()? as u8`, which is silent: `request_type: 384` reached the
26/// device as 128 -- a device-to-host standard request, a different transfer entirely from what
27/// the model asked for -- and no error said so.
28fn byte_field(action: &serde_json::Value, field: &str, action_type: &str) -> Result<u8> {
29    let value = action
30        .get(field)
31        .and_then(|v| v.as_u64())
32        .with_context(|| format!("Missing '{field}' field"))?;
33    u8::try_from(value).map_err(|_| {
34        anyhow::anyhow!("{action_type} '{field}' is {value}, outside the 0..=255 a USB byte holds")
35    })
36}
37
38/// Read a field that becomes a 16-bit wire value, refusing a value that would wrap.
39fn word_field(action: &serde_json::Value, field: &str, action_type: &str) -> Result<u16> {
40    let value = action
41        .get(field)
42        .and_then(|v| v.as_u64())
43        .with_context(|| format!("Missing '{field}' field"))?;
44    u16::try_from(value).map_err(|_| {
45        anyhow::anyhow!(
46            "{action_type} '{field}' is {value}, outside the 0..=65535 a USB 16-bit field holds"
47        )
48    })
49}
50
51/// Read a transfer length, bounded by [`MAX_TRANSFER_BYTES`].
52///
53/// `required_default` is used when the field is absent; pass 0 where the field is optional.
54fn transfer_length(action: &serde_json::Value, action_type: &str, default: u64) -> Result<usize> {
55    let value = action
56        .get("length")
57        .and_then(|v| v.as_u64())
58        .unwrap_or(default);
59    if value as u128 > MAX_TRANSFER_BYTES as u128 {
60        return Err(anyhow::anyhow!(
61            "{action_type} 'length' is {value}; at most {MAX_TRANSFER_BYTES} bytes may be \
62             requested in one transfer"
63        ));
64    }
65    Ok(value as usize)
66}
67
68/// USB device opened event
69pub static USB_DEVICE_OPENED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
70    EventType::new(
71        "usb_device_opened",
72        "USB device successfully opened and interface claimed",
73        json!({
74            "type": "control_transfer",
75            "request_type": 0x80,
76            "request": 0x06,
77            "value": 0x0100,
78            "index": 0,
79            "length": 18
80        }),
81    )
82    .with_parameters(vec![
83        Parameter {
84            name: "vendor_id".to_string(),
85            type_hint: "string".to_string(),
86            description: "USB vendor ID (hex)".to_string(),
87            required: true,
88        },
89        Parameter {
90            name: "product_id".to_string(),
91            type_hint: "string".to_string(),
92            description: "USB product ID (hex)".to_string(),
93            required: true,
94        },
95        Parameter {
96            name: "manufacturer".to_string(),
97            type_hint: "string".to_string(),
98            description: "Device manufacturer string".to_string(),
99            required: false,
100        },
101        Parameter {
102            name: "product".to_string(),
103            type_hint: "string".to_string(),
104            description: "Device product string".to_string(),
105            required: false,
106        },
107    ])
108});
109
110/// USB control transfer response event
111pub static USB_CONTROL_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
112    EventType::new(
113        "usb_control_response",
114        "Response received from USB control transfer",
115        json!({
116            "type": "wait_for_more"
117        }),
118    )
119    .with_parameters(vec![
120        Parameter {
121            name: "data_hex".to_string(),
122            type_hint: "string".to_string(),
123            description: "Response data (as hex string)".to_string(),
124            required: true,
125        },
126        Parameter {
127            name: "data_length".to_string(),
128            type_hint: "number".to_string(),
129            description: "Length of response in bytes".to_string(),
130            required: true,
131        },
132    ])
133});
134
135/// USB bulk data received event
136pub static USB_BULK_DATA_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
137    EventType::new(
138        "usb_bulk_data_received",
139        "Data received from USB bulk endpoint",
140        json!({
141            "type": "bulk_transfer_out",
142            "endpoint": 0x02,
143            "data_hex": "48656c6c6f"
144        }),
145    )
146    .with_parameters(vec![
147        Parameter {
148            name: "data_hex".to_string(),
149            type_hint: "string".to_string(),
150            description: "The data received (as hex string)".to_string(),
151            required: true,
152        },
153        Parameter {
154            name: "data_length".to_string(),
155            type_hint: "number".to_string(),
156            description: "Length of data in bytes".to_string(),
157            required: true,
158        },
159        Parameter {
160            name: "endpoint".to_string(),
161            type_hint: "number".to_string(),
162            description: "Endpoint address".to_string(),
163            required: true,
164        },
165    ])
166});
167
168/// USB interrupt data received event
169pub static USB_INTERRUPT_DATA_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
170    EventType::new(
171        "usb_interrupt_data_received",
172        "Data received from USB interrupt endpoint",
173        json!({
174            "type": "wait_for_more"
175        }),
176    )
177    .with_parameters(vec![
178        Parameter {
179            name: "data_hex".to_string(),
180            type_hint: "string".to_string(),
181            description: "The data received (as hex string)".to_string(),
182            required: true,
183        },
184        Parameter {
185            name: "data_length".to_string(),
186            type_hint: "number".to_string(),
187            description: "Length of data in bytes".to_string(),
188            required: true,
189        },
190        Parameter {
191            name: "endpoint".to_string(),
192            type_hint: "number".to_string(),
193            description: "Endpoint address".to_string(),
194            required: true,
195        },
196    ])
197});
198
199/// USB client protocol action handler
200pub struct UsbClientProtocol;
201
202impl UsbClientProtocol {
203    pub fn new() -> Self {
204        Self
205    }
206}
207
208// Implement Protocol trait (common functionality)
209impl Protocol for UsbClientProtocol {
210    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
211        // `list_usb_devices` used to be advertised here and could not run: `execute_action`
212        // below has no arm for it, so it came back "Unknown action" and cost the model a
213        // retry. Device enumeration is a real capability worth adding, but it needs rusb work
214        // in `mod.rs`, not an action declaration on its own.
215        vec![ActionDefinition {
216            name: "detach_device".to_string(),
217            description: "Detach from the USB device and close connection".to_string(),
218            parameters: vec![],
219            example: json!({
220                "type": "detach_device"
221            }),
222            log_template: None,
223        }]
224    }
225
226    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
227        vec![
228            ActionDefinition {
229                name: "control_transfer".to_string(),
230                description: "Send a USB control transfer request".to_string(),
231                parameters: vec![
232                    Parameter {
233                        name: "request_type".to_string(),
234                        type_hint: "number".to_string(),
235                        description: "Request type byte (bmRequestType)".to_string(),
236                        required: true,
237                    },
238                    Parameter {
239                        name: "request".to_string(),
240                        type_hint: "number".to_string(),
241                        description: "Request byte (bRequest)".to_string(),
242                        required: true,
243                    },
244                    Parameter {
245                        name: "value".to_string(),
246                        type_hint: "number".to_string(),
247                        description: "Value word (wValue)".to_string(),
248                        required: true,
249                    },
250                    Parameter {
251                        name: "index".to_string(),
252                        type_hint: "number".to_string(),
253                        description: "Index word (wIndex)".to_string(),
254                        required: true,
255                    },
256                    Parameter {
257                        name: "data_hex".to_string(),
258                        type_hint: "string".to_string(),
259                        description: "Data to send (hex encoded, empty for IN transfers)"
260                            .to_string(),
261                        required: false,
262                    },
263                    Parameter {
264                        name: "length".to_string(),
265                        type_hint: "number".to_string(),
266                        description: "Expected response length (for IN transfers)".to_string(),
267                        required: false,
268                    },
269                ],
270                example: json!({
271                    "type": "control_transfer",
272                    "request_type": 0x80,
273                    "request": 0x06,
274                    "value": 0x0100,
275                    "index": 0,
276                    "length": 18
277                }),
278                log_template: None,
279            },
280            ActionDefinition {
281                name: "bulk_transfer_out".to_string(),
282                description: "Send data via USB bulk OUT endpoint".to_string(),
283                parameters: vec![
284                    Parameter {
285                        name: "endpoint".to_string(),
286                        type_hint: "number".to_string(),
287                        description: "Bulk OUT endpoint address".to_string(),
288                        required: true,
289                    },
290                    Parameter {
291                        name: "data_hex".to_string(),
292                        type_hint: "string".to_string(),
293                        description: "Data to send (hex encoded)".to_string(),
294                        required: true,
295                    },
296                ],
297                example: json!({
298                    "type": "bulk_transfer_out",
299                    "endpoint": 0x02,
300                    "data_hex": "48656c6c6f"
301                }),
302                log_template: None,
303            },
304            ActionDefinition {
305                name: "bulk_transfer_in".to_string(),
306                description: "Read data from USB bulk IN endpoint".to_string(),
307                parameters: vec![
308                    Parameter {
309                        name: "endpoint".to_string(),
310                        type_hint: "number".to_string(),
311                        description: "Bulk IN endpoint address".to_string(),
312                        required: true,
313                    },
314                    Parameter {
315                        name: "length".to_string(),
316                        type_hint: "number".to_string(),
317                        description: "Number of bytes to read".to_string(),
318                        required: true,
319                    },
320                ],
321                example: json!({
322                    "type": "bulk_transfer_in",
323                    "endpoint": 0x81,
324                    "length": 64
325                }),
326                log_template: None,
327            },
328            ActionDefinition {
329                name: "interrupt_transfer_in".to_string(),
330                description: "Read data from USB interrupt IN endpoint".to_string(),
331                parameters: vec![
332                    Parameter {
333                        name: "endpoint".to_string(),
334                        type_hint: "number".to_string(),
335                        description: "Interrupt IN endpoint address".to_string(),
336                        required: true,
337                    },
338                    Parameter {
339                        name: "length".to_string(),
340                        type_hint: "number".to_string(),
341                        description: "Number of bytes to read".to_string(),
342                        required: true,
343                    },
344                ],
345                example: json!({
346                    "type": "interrupt_transfer_in",
347                    "endpoint": 0x83,
348                    "length": 8
349                }),
350                log_template: None,
351            },
352            ActionDefinition {
353                name: "claim_interface".to_string(),
354                description: "Claim a USB interface for exclusive access".to_string(),
355                parameters: vec![Parameter {
356                    name: "interface_number".to_string(),
357                    type_hint: "number".to_string(),
358                    description: "Interface number to claim".to_string(),
359                    required: true,
360                }],
361                example: json!({
362                    "type": "claim_interface",
363                    "interface_number": 0
364                }),
365                log_template: None,
366            },
367            ActionDefinition {
368                name: "wait_for_more".to_string(),
369                description: "Wait for more data before responding".to_string(),
370                parameters: vec![],
371                example: json!({
372                    "type": "wait_for_more"
373                }),
374                log_template: None,
375            },
376        ]
377    }
378
379    fn protocol_name(&self) -> &'static str {
380        "USB"
381    }
382
383    fn get_event_types(&self) -> Vec<EventType> {
384        vec![
385            EventType::new(
386                "usb_device_opened",
387                "Triggered when USB device is opened and interface claimed",
388                json!({"type": "placeholder", "event_id": "usb_device_opened"}),
389            ),
390            EventType::new(
391                "usb_control_response",
392                "Triggered when USB control transfer completes",
393                json!({"type": "placeholder", "event_id": "usb_control_response"}),
394            ),
395            EventType::new(
396                "usb_bulk_data_received",
397                "Triggered when data is received from bulk endpoint",
398                json!({"type": "placeholder", "event_id": "usb_bulk_data_received"}),
399            ),
400            EventType::new(
401                "usb_interrupt_data_received",
402                "Triggered when data is received from interrupt endpoint",
403                json!({"type": "placeholder", "event_id": "usb_interrupt_data_received"}),
404            ),
405        ]
406    }
407
408    fn stack_name(&self) -> &'static str {
409        "USB"
410    }
411
412    fn keywords(&self) -> Vec<&'static str> {
413        vec!["usb", "usb device", "usb client", "connect to usb"]
414    }
415
416    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
417        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
418
419        ProtocolMetadataV2::builder()
420            .state(DevelopmentState::Experimental)
421            .implementation("nusb pure-Rust USB library for device access")
422            .llm_control("Full control over USB transfers (control, bulk, interrupt)")
423            .e2e_testing("Requires actual USB device for testing")
424            .build()
425    }
426
427    fn description(&self) -> &'static str {
428        "USB client for low-level USB device interaction"
429    }
430
431    fn example_prompt(&self) -> &'static str {
432        "Connect to USB device with vendor ID 0x1234, product ID 0x5678"
433    }
434
435    fn group_name(&self) -> &'static str {
436        "Hardware"
437    }
438    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
439        use crate::llm::actions::StartupExamples;
440        use serde_json::json;
441
442        StartupExamples::new(
443            // LLM mode: LLM handles USB device operations
444            json!({
445                "type": "open_client",
446                "remote_addr": "usb:1234:5678",
447                "base_stack": "usb",
448                "instruction": "Connect to USB device and get device descriptor"
449            }),
450            // Script mode: Code-based USB handling
451            json!({
452                "type": "open_client",
453                "remote_addr": "usb:1234:5678",
454                "base_stack": "usb",
455                "event_handlers": [{
456                    "event_pattern": "usb_device_opened",
457                    "handler": {
458                        "type": "script",
459                        "language": "python",
460                        "code": "<usb_client_handler>"
461                    }
462                }]
463            }),
464            // Static mode: Fixed USB action
465            json!({
466                "type": "open_client",
467                "remote_addr": "usb:1234:5678",
468                "base_stack": "usb",
469                "event_handlers": [{
470                    "event_pattern": "usb_device_opened",
471                    "handler": {
472                        "type": "static",
473                        "actions": [{
474                            "type": "control_transfer",
475                            "request_type": 128,
476                            "request": 6,
477                            "value": 256,
478                            "index": 0,
479                            "length": 18
480                        }]
481                    }
482                }]
483            }),
484        )
485    }
486}
487
488// Implement Client trait (client-specific functionality)
489impl Client for UsbClientProtocol {
490    fn connect(
491        &self,
492        ctx: crate::protocol::ConnectContext,
493    ) -> std::pin::Pin<
494        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
495    > {
496        Box::pin(async move {
497            use crate::client::usb::UsbClient;
498            UsbClient::connect_with_llm_actions(
499                ctx.remote_addr,
500                ctx.llm_client,
501                ctx.state,
502                ctx.status_tx,
503                ctx.client_id,
504            )
505            .await
506        })
507    }
508
509    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
510        let action_type = action
511            .get("type")
512            .and_then(|v| v.as_str())
513            .context("Missing 'type' field in action")?;
514
515        match action_type {
516            "control_transfer" => {
517                let request_type = byte_field(&action, "request_type", action_type)?;
518                let request = byte_field(&action, "request", action_type)?;
519                let value = word_field(&action, "value", action_type)?;
520                let index = word_field(&action, "index", action_type)?;
521
522                let data_hex = action
523                    .get("data_hex")
524                    .and_then(|v| v.as_str())
525                    .unwrap_or("");
526
527                let length = transfer_length(&action, action_type, 0)?;
528
529                let data = if !data_hex.is_empty() {
530                    let bytes = hex::decode(data_hex).context("Invalid hex data")?;
531                    if bytes.len() > MAX_TRANSFER_BYTES {
532                        return Err(anyhow::anyhow!(
533                            "control_transfer 'data_hex' decodes to {} bytes; at most {} are \
534                             accepted",
535                            bytes.len(),
536                            MAX_TRANSFER_BYTES
537                        ));
538                    }
539                    bytes
540                } else {
541                    Vec::new()
542                };
543
544                Ok(ClientActionResult::Custom {
545                    name: "control_transfer".to_string(),
546                    data: json!({
547                        "request_type": request_type,
548                        "request": request,
549                        "value": value,
550                        "index": index,
551                        "data": data,
552                        "length": length,
553                    }),
554                })
555            }
556            "bulk_transfer_out" => {
557                let endpoint = byte_field(&action, "endpoint", action_type)?;
558
559                let data_hex = action
560                    .get("data_hex")
561                    .and_then(|v| v.as_str())
562                    .context("Missing 'data_hex' field")?;
563
564                let data = hex::decode(data_hex).context("Invalid hex data")?;
565                if data.len() > MAX_TRANSFER_BYTES {
566                    return Err(anyhow::anyhow!(
567                        "bulk_transfer_out 'data_hex' decodes to {} bytes; at most {} are \
568                         accepted",
569                        data.len(),
570                        MAX_TRANSFER_BYTES
571                    ));
572                }
573
574                Ok(ClientActionResult::Custom {
575                    name: "bulk_transfer_out".to_string(),
576                    data: json!({
577                        "endpoint": endpoint,
578                        "data": data,
579                    }),
580                })
581            }
582            "bulk_transfer_in" => {
583                let endpoint = byte_field(&action, "endpoint", action_type)?;
584                let length = transfer_length(&action, action_type, 64)?;
585
586                Ok(ClientActionResult::Custom {
587                    name: "bulk_transfer_in".to_string(),
588                    data: json!({
589                        "endpoint": endpoint,
590                        "length": length,
591                    }),
592                })
593            }
594            "interrupt_transfer_in" => {
595                let endpoint = byte_field(&action, "endpoint", action_type)?;
596                let length = transfer_length(&action, action_type, 64)?;
597
598                Ok(ClientActionResult::Custom {
599                    name: "interrupt_transfer_in".to_string(),
600                    data: json!({
601                        "endpoint": endpoint,
602                        "length": length,
603                    }),
604                })
605            }
606            "claim_interface" => {
607                let interface_number = byte_field(&action, "interface_number", action_type)?;
608
609                Ok(ClientActionResult::Custom {
610                    name: "claim_interface".to_string(),
611                    data: json!({
612                        "interface_number": interface_number,
613                    }),
614                })
615            }
616            "detach_device" => Ok(ClientActionResult::Disconnect),
617            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
618            _ => Err(anyhow::anyhow!(
619                "Unknown USB client action: {}",
620                action_type
621            )),
622        }
623    }
624}