Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
12b33dc
feat(usb_device): native USB CDC transport (esp_tinyusb) + OdriveAsci…
finger563 Aug 17, 2026
e50ef49
feat(usb_device): vendor + WebUSB interface, composable multi-class d…
finger563 Aug 17, 2026
247534c
feat(usb_device): HID function + example-manifest cleanup + PR #720 r…
finger563 Aug 18, 2026
e2d13c4
docs(usb_device): fix stale example CMakeLists comment (no override_p…
finger563 Aug 18, 2026
dbfef87
feat(usb_device): Web Serial board console + ESP flasher (esptool-js)…
finger563 Aug 18, 2026
7376e82
fix(usb_device): handle vendor zero-copy RX + reject rx_chunk_size==0
finger563 Aug 18, 2026
22aa7e3
fix(usb_device): re-review fixes — atomic instance routing, WebUSB wI…
finger563 Aug 18, 2026
fe1235c
fix(usb_device): distinct FS/HS config descriptors + strict flash-off…
finger563 Aug 18, 2026
7b9a0b2
Merge remote-tracking branch 'origin/main' into feat/usb-cdc-transport
finger563 Aug 18, 2026
d745000
fix(usb_device): reject empty HID report descriptor + 32-bit flash-of…
finger563 Aug 18, 2026
78a208b
Merge remote-tracking branch 'origin/main' into feat/usb-cdc-transport
finger563 Aug 18, 2026
ff18fa7
fix(usb_device): clear cppcheck unreachableCode in the HID validation
finger563 Aug 18, 2026
72415a5
fix(usb_device): atomically claim/release the singleton slot
finger563 Aug 18, 2026
5b2a454
docs(usb_device): annotate TinyUSB's (OUT, IN) argument order at the …
finger563 Aug 18, 2026
95aae52
Merge branch 'main' into feat/usb-cdc-transport
finger563 Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,8 @@ jobs:
target: esp32s3
- path: 'components/twai/example'
target: esp32
- path: 'components/usb_device/example'
target: esp32s3
- path: 'components/vl53l/example'
target: esp32s3
- path: 'components/wifi/example'
Expand Down
16 changes: 10 additions & 6 deletions .github/workflows/build_and_publish_docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,24 @@ jobs:
# copy the generated HTML files to the docs directory for GitHub Pages
mkdir -p ../docs
cp -r "${build_dir_from_doc}/html/"* ../docs/.
# Host the interactive single-file web consoles alongside the docs.
# GitHub Pages serves over https (a secure context), so the browser
# Web Serial / WebUSB APIs work directly from these hosted copies.
# Host the interactive single-file web apps (Web Serial / WebUSB /
# WebHID tools) alongside the docs. GitHub Pages serves over https
# (a secure context), so the browser APIs work directly from these
# hosted copies.
mkdir -p ../docs/apps
# Copy any hosted consoles. nullglob makes a "no matches" case a no-op
# (the glob expands to nothing) while a genuine copy error still fails
# the job, instead of being swallowed by `|| true`.
# nullglob makes a "no matches" case a no-op (the glob expands to
# nothing) while a genuine copy error still fails the job, instead
# of being swallowed by `|| true`.
shopt -s nullglob
consoles=(../components/odrive_ascii/web/*.html)
if [ ${#consoles[@]} -gt 0 ]; then
cp "${consoles[@]}" ../docs/apps/
else
echo "No hosted web consoles found to copy." >&2
fi
for f in ../components/usb_device/web/*.html ../components/usb_device/web/*.js; do
cp "$f" ../docs/apps/.
done
shopt -u nullglob

- name: Build Documentation (PDF)
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/upload_components.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ jobs:
components/tla2528
components/tt21100
components/twai
components/usb_device
components/utils
components/vl53l
components/wifi
Expand Down
5 changes: 5 additions & 0 deletions components/usb_device/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
idf_component_register(
INCLUDE_DIRS "include"
SRC_DIRS "src"
REQUIRES base_component esp_tinyusb
)
189 changes: 189 additions & 0 deletions components/usb_device/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# USB Device Component

[![Badge](https://components.espressif.com/components/espp/usb_device/badge.svg)](https://components.espressif.com/components/espp/usb_device)

`espp::UsbDevice` is an idiomatic wrapper around ESP-IDF's `esp_tinyusb` managed
component that assembles a **native USB device** from a *set of selectable
functions* on the ESP32-S3 / -S2 / -P4 USB-OTG peripheral, with a **configurable
VID/PID** and manufacturer / product / serial strings.

Today it can enable, in any combination (subject to the endpoint budget):

- A **CDC-ACM** function (virtual serial port).
- A **vendor-specific** function (`bInterfaceClass` 0xFF, one bulk IN + one bulk
OUT) carrying a raw byte stream, optionally advertising **WebUSB** + **MS OS
2.0** descriptors so a browser can talk to it driverlessly (and Windows binds
WinUSB with no driver).
- A **HID** function (one interrupt IN, optionally one interrupt OUT) carrying an
application-supplied report descriptor (e.g. a gamepad built with the espp
`hid-rp` component), with input reports sent via `write_hid_report()`.

Interface numbers, endpoint addresses and string indices are allocated
*sequentially* as functions are enabled, and the result is checked against the
USB-OTG endpoint budget. Because it uses the native USB-OTG peripheral (not the
built-in USB-Serial-JTAG that carries the ESP console), a device can advertise its
own USB identifiers (e.g. ODrive-like) on a link that is completely separate from
the logging console.

`espp::UsbCdc` is retained as a thin **CDC-only preset** over `espp::UsbDevice`
for back-compatibility.

<!-- markdown-toc start - Don't edit this section. Run M-x markdown-toc-refresh-toc -->
**Table of Contents**

- [USB Device Component](#usb-device-component)
- [Features](#features)
- [API](#api)
- [Enabling the vendor / WebUSB class](#enabling-the-vendor--webusb-class)
- [Endpoint budget (ESP32-S3 USB-OTG)](#endpoint-budget-esp32-s3-usb-otg)
- [Extending with HID / MSC](#extending-with-hid--msc)
- [Example](#example)
- [Notes](#notes)

<!-- markdown-toc end -->

## Features

- **Composable**: enable a CDC function and/or a vendor/WebUSB function and/or a
HID function (composite).
- **Vendor-specific interface** (class 0xFF): raw bulk IN + bulk OUT byte stream.
- **HID interface**: application-supplied report descriptor (built with `hid-rp`
in the example) on an interrupt IN endpoint; `write_hid_report()` sends reports.
- **WebUSB**: BOS + WebUSB URL + MS OS 2.0 descriptors for driverless browser
access, with a configurable landing-page URL.
- **Sequential allocation** of interfaces / endpoints / strings with an
endpoint-budget check (error via `std::error_code` if exceeded).
- **Configurable identity**: VID, PID, manufacturer / product / serial / interface
strings.
- **Idiomatic espp**: no exceptions; `initialize()` reports failures via
`std::error_code`.
- **Safe marshaling**: the TinyUSB RX callbacks (TinyUSB task context) are drained
and delivered to per-function user callbacks; the matching `write_*()` is safe to
call from within them.

## API

Composite CDC + vendor/WebUSB device (both interfaces carry the same raw stream):

```cpp
espp::UsbDevice::Config cfg;
cfg.vid = 0x1209; // pid.codes VID (ODrive uses this)
cfg.pid = 0x0d32; // ODrive-like PID

espp::UsbDevice::CdcFunction cdc;
cdc.on_receive = [&](std::span<const uint8_t> data) { /* serial rx */ };
cfg.cdc = cdc;

espp::UsbDevice::VendorFunction vendor;
vendor.webusb = true; // advertise WebUSB / MS OS 2.0 descriptors
// vendor.landing_page_url defaults to the espp docs-hosted ODrive WebUSB console,
// without a scheme; vendor.url_scheme selects http (0) or https (1).
vendor.on_receive = [&](std::span<const uint8_t> data) { /* vendor rx */ };
cfg.vendor = vendor;

espp::UsbDevice usb(cfg);
std::error_code ec;
if (!usb.initialize(ec)) { /* handle ec (e.g. endpoint budget exceeded) */ }

uint8_t hello[] = {'h','i','\n'};
usb.write_cdc(hello);
usb.write_vendor(hello);
```

Key methods:

- `bool initialize(std::error_code &ec)` — build descriptors from the enabled
functions, check the endpoint budget, install the TinyUSB driver.
- `bool write_cdc(...)` / `bool write_vendor(...)` — queue + non-blocking flush on
the respective interface.
- `bool write_hid_report(uint8_t report_id, std::span<const uint8_t> report, ...)` —
send a HID input report on the HID interrupt IN endpoint.
- `void set_cdc_receive_callback(...)` / `void set_vendor_receive_callback(...)`.
- `bool is_cdc_connected() const` / `bool is_vendor_connected() const` /
`bool is_hid_ready() const`.

CDC-only preset (`espp::UsbCdc`, unchanged API): `initialize()`, `write()`,
`set_receive_callback()`, `is_connected()`.

## Enabling the vendor / WebUSB class

The vendor class is gated in `esp_tinyusb` behind a Kconfig option. To use the
vendor function, set in your project's `sdkconfig.defaults`:

```
CONFIG_TINYUSB_CDC_ENABLED=y
CONFIG_TINYUSB_CDC_COUNT=1
CONFIG_TINYUSB_VENDOR_COUNT=1 # THE key enablement: compiles in the vendor class
```

Setting `CONFIG_TINYUSB_VENDOR_COUNT` > 0 makes `esp_tinyusb` define
`CFG_TUD_VENDOR` and compile the TinyUSB vendor class driver. No custom
`tusb_config` is needed — the BOS descriptor and the WebUSB / MS-OS-2.0 vendor
control requests are provided by `espp::UsbDevice` through the standard TinyUSB
weak-callback overrides (`tud_descriptor_bos_cb`, `tud_vendor_control_xfer_cb`,
`tud_vendor_rx_cb`). If the vendor function is requested but `CFG_TUD_VENDOR == 0`,
`initialize()` fails with `std::errc::function_not_supported`.

## Enabling the HID class

Like the vendor class, the HID class is gated in `esp_tinyusb` behind a Kconfig
option. To use the HID function, set in your project's `sdkconfig.defaults`:

```
CONFIG_TINYUSB_HID_COUNT=1 # compiles in the TinyUSB HID class driver (CFG_TUD_HID)
```

`espp::UsbDevice` provides the required TinyUSB HID weak-callback overrides
(`tud_hid_descriptor_report_cb` returns the stored report descriptor;
`tud_hid_get_report_cb` returns 0 and `tud_hid_set_report_cb` is a no-op since the
gamepad is input-only). Supply the report-descriptor bytes yourself (the example
builds them with the espp `hid-rp` component), assign them to
`HidFunction::report_descriptor`, and send input reports with
`write_hid_report(report_id, report)`. If the HID function is requested but
`CFG_TUD_HID == 0`, `initialize()` fails with `std::errc::function_not_supported`.

## Endpoint budget (ESP32-S3 USB-OTG)

The ESP32-S3 / -S2 USB-OTG core is full-speed and, besides EP0, provides roughly
**5 usable data IN endpoints** and **5 usable data OUT endpoints**. Each function
consumes:

| Function | IN endpoints | OUT endpoints |
|-------------------|---------------------------------------------|--------------------------------|
| CDC-ACM | 2 (1 interrupt-IN notif + 1 bulk-IN) | 1 (bulk-OUT) |
| Vendor / WebUSB | 1 (bulk-IN) | 1 (bulk-OUT) |
| HID | 1 (interrupt-IN) | 0 or 1 (optional interrupt-OUT) |
| MSC (future) | 1 (bulk-IN) | 1 (bulk-OUT) |

This is why the device is **selectable** ("not all at once"). Combinations that
fit comfortably: CDC+Vendor (3 IN / 2 OUT, used by the example), CDC+Vendor+HID,
CDC+Vendor+MSC. Enabling CDC+Vendor+HID+MSC reaches 5 IN endpoints — at the hard
limit, not recommended. `initialize()` returns `std::errc::value_too_large` if the
IN or OUT budget is exceeded.

## Extending with MSC

The **HID** function is implemented (see "Enabling the HID class" above).
`espp::UsbDevice::Config` still reserves a `std::optional` slot for an
`MscFunction` as a documented extension point; it is not implemented yet, and
enabling it today makes `initialize()` fail with
`std::errc::function_not_supported`. When implemented it slots into the same
sequential allocator: MSC appends one interface (SCSI + storage
read/write/capacity callbacks) claiming a bulk IN + bulk OUT endpoint, exactly
as HID appends one interface claiming an interrupt-IN endpoint (plus an optional
interrupt-OUT).

## Example

See `example/` for a full project that wires a **composite CDC + Vendor/WebUSB**
`espp::UsbDevice` to the transport-agnostic `espp::OdriveAscii` protocol server.
Both interfaces feed the same server (RX from either interface → `process_bytes`
→ response written back out the same interface), while the log console stays on
the USB-Serial-JTAG peripheral.

## Notes

- USB-OTG is only available on the ESP32-S2, ESP32-S3 and ESP32-P4 targets.
- Only one `espp::UsbDevice` / `espp::UsbCdc` instance may exist at a time.
- The receive callbacks run in the TinyUSB device task; keep them short and
non-blocking.
34 changes: 34 additions & 0 deletions components/usb_device/example/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# The following lines of boilerplate have to be in your project's CMakeLists
# in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.20)

# NOTE: the IDF component manager is intentionally left ENABLED here (unlike most
# espp examples) so that it can fetch the managed `espressif/esp_tinyusb`
# dependency declared by the usb_device component's idf_component.yml. To avoid
# the component manager scanning every espp component manifest (some board
# components declare target-specific constraints that would fail on esp32s3),
# EXTRA_COMPONENT_DIRS is narrowed to just the components this example uses; the
# in-repo espp components there satisfy the `espp/*` dependencies locally (no
# example manifest / override_path needed).
include($ENV{IDF_PATH}/tools/cmake/project.cmake)

# add only the component directories that we want to use
set(EXTRA_COMPONENT_DIRS
"../../../components/base_component"
"../../../components/format"
"../../../components/hid-rp"
"../../../components/logger"
"../../../components/odrive_ascii"
"../../../components/usb_device"
)

set(
COMPONENTS
"main esptool_py base_component format hid-rp logger odrive_ascii usb_device esp_tinyusb"
CACHE STRING
"List of components to include"
)

project(usb_cdc_example)

set(CMAKE_CXX_STANDARD 20)
90 changes: 90 additions & 0 deletions components/usb_device/example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# USB Device (CDC + Vendor/WebUSB) + ODrive ASCII Example

This example demonstrates a **composite** `espp::UsbDevice` that exposes both a
**CDC-ACM serial** interface and a **vendor-specific / WebUSB** interface, both
wired to the transport-agnostic `espp::OdriveAscii` protocol server. The device
enumerates with an ODrive-like VID/PID (0x1209 / 0x0d32), separate from the log
console which stays on the USB-Serial-JTAG peripheral.

Both interfaces carry the identical raw ODrive ASCII byte stream: RX from either
interface is fed to `process_bytes()`, and the response is written back out the
same interface.

<!-- markdown-toc start - Don't edit this section. Run M-x markdown-toc-refresh-toc -->
**Table of Contents**

- [USB Device (CDC + Vendor/WebUSB) + ODrive ASCII Example](#usb-device-cdc--vendorwebusb--odrive-ascii-example)
- [Requirements](#requirements)
- [Build](#build)
- [Flash and Monitor](#flash-and-monitor)
- [Usage](#usage)
- [How it works](#how-it-works)

<!-- markdown-toc end -->

## Requirements

- An ESP32-S3 (or -S2 / -P4) with access to the native USB-OTG pins.
- ESP-IDF installed and available in your shell.
- The IDF component manager is enabled for this example so it can fetch the
managed `espressif/esp_tinyusb` component.

The example's `sdkconfig.defaults` enables both the CDC and vendor classes:

```
CONFIG_TINYUSB_CDC_ENABLED=y
CONFIG_TINYUSB_CDC_COUNT=1
CONFIG_TINYUSB_VENDOR_COUNT=1
```

## Build

```sh
cd components/usb_device/example
idf.py set-target esp32s3
idf.py build
```

## Flash and Monitor

Flash / monitor over the USB-Serial-JTAG (or UART) console, which is kept separate
from the native USB interfaces:

```sh
idf.py flash monitor
```

The native USB-OTG connector will appear on the host as a new composite device:
a serial port (CDC) plus a vendor interface (WebUSB), manufacturer "espp",
product "espp ODrive ASCII".

## Usage

Serial: open the CDC serial port and send ODrive ASCII commands, e.g. from Python:

```python
import serial
ser = serial.Serial('/dev/tty.usbmodemXXXX', 115200, timeout=0.5)
ser.write(b'r axis0.encoder.pos_estimate\n'); print(ser.readline())
ser.write(b'w axis0.controller.input_pos 12.34\n'); print(ser.readline())
ser.write(b'p 0 1.0 0.5 0.1\n'); print(ser.readline())
ser.write(b'f 0\n'); print(ser.readline())
```

WebUSB: from a Chromium-based browser, open the WebUSB console and connect to the
vendor interface (class 0xFF, bulk IN + bulk OUT). The same ODrive ASCII commands
work over the vendor byte stream. The BOS/WebUSB descriptors point to a
configurable landing-page URL (default: the espp docs-hosted ODrive WebUSB
console).

## How it works

- `espp::UsbDevice` installs the TinyUSB driver and builds descriptors for the
enabled CDC + vendor functions, allocating interfaces / endpoints sequentially.
- The vendor function advertises WebUSB + MS OS 2.0 descriptors so a browser (and
Windows, via WinUSB) can bind it driverlessly.
- Each interface's receive callback feeds incoming bytes to
`espp::OdriveAscii::process_bytes()` and writes the response back out that same
interface (`write_cdc()` / `write_vendor()`).
- The log console remains on the USB-Serial-JTAG peripheral (see
`sdkconfig.defaults.esp32s3`).
5 changes: 5 additions & 0 deletions components/usb_device/example/main/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
idf_component_register(
SRC_DIRS "."
INCLUDE_DIRS "."
REQUIRES usb_device odrive_ascii hid-rp esp_tinyusb
)
Loading
Loading