USB Device Component

Overview

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) that carries a raw byte stream and optionally advertises 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 (for example a gamepad built with the espp hid-rp component), with input reports sent via write_hid_report().

  • An X-Input function that presents the device as a wired Xbox 360 controller (a custom TinyUSB application class driver built into this component — no CFG_TUD_* count needed). Gamepad state is sent with update_xinput_state() (see xinput.hpp) and rumble/LED reports arrive via an on_rumble callback. Because the host’s XUSB driver only binds a recognized Xbox 360 VID/PID and the built-in vendor class also claims interface class 0xFF, use X-Input as the only enabled function (Microsoft’s IDs, for emulation / testing of your own device only).

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 (an error is reported via std::error_code if it is exceeded). The model is designed so an MSC function can be added later without changing the descriptor-building approach.

Because it uses the native USB-OTG peripheral rather than the built-in USB-Serial-JTAG that carries the ESP console, a device can advertise its own USB identifiers (for example ODrive-like ones) on a link that is fully separate from the logging console.

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

Features

  • Composable: enable a CDC function and/or a vendor/WebUSB function and/or a HID function (composite)

  • Vendor-specific interface (class 0xFF) with a bulk IN + bulk OUT raw byte stream

  • HID interface with an application-supplied report descriptor (built with hid-rp in the example) and write_hid_report()

  • X-Input interface (wired Xbox 360 controller) via a custom application class driver, with update_xinput_state() and an on_rumble callback

  • Console over CDC: optionally route the ESP console (stdout) to the CDC interface (CdcFunction::route_console or route_console_to_cdc()) so one native USB cable carries the logs alongside a vendor / HID / XInput interface; non-blocking, and teed to the primary UART console by default

  • WebUSB: BOS descriptor + WebUSB URL descriptor + MS OS 2.0 descriptor for driverless browser access, with a configurable landing-page URL

  • Sequential interface / endpoint / string allocation with an endpoint-budget check

  • Configurable VID, PID, and manufacturer / product / serial / interface strings

  • No exceptions; initialize() reports failures via std::error_code

  • Safely marshals the TinyUSB RX callbacks (TinyUSB task context) into per-function user callbacks

Basic Usage

Composite CDC + vendor/WebUSB device, both interfaces carrying the same raw byte stream:

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) { /* handle serial rx */ };
cfg.cdc = cdc;

espp::UsbDevice::VendorFunction vendor;
vendor.webusb = true; // advertise WebUSB / MS OS 2.0 descriptors
// landing_page_url defaults to the espp docs-hosted ODrive WebUSB console
vendor.on_receive = [&](std::span<const uint8_t> data) { /* handle 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);

CDC-only preset (unchanged API):

espp::UsbCdc::Config cfg;
cfg.vid = 0x1209;
cfg.pid = 0x0d32;
cfg.on_receive = [](std::span<const uint8_t> data) { /* handle rx */ };
espp::UsbCdc usb(cfg);
std::error_code ec;
if (!usb.initialize(ec)) { /* handle ec */ }

Enabling the vendor / WebUSB class

The vendor class is gated in esp_tinyusb behind a Kconfig option. To use the vendor function you must set, in your project’s sdkconfig.defaults (in addition to the CDC options if you also enable CDC):

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 greater than 0 makes esp_tinyusb define CFG_TUD_VENDOR and compile the TinyUSB vendor class driver. If the vendor function is requested but CFG_TUD_VENDOR == 0, initialize() fails with std::errc::function_not_supported. No custom tusb_config is required; the BOS descriptor and the WebUSB / MS-OS-2.0 vendor control requests are provided by espp::UsbDevice via the standard TinyUSB weak-callback overrides.

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 you must 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 and tud_hid_get_report_cb returns 0. 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).

To receive host→device OUTPUT / SET_REPORT reports (for request/response HID protocols such as the Nintendo Switch Pro controller handshake), set HidFunction::on_receive (or set_hid_receive_callback()) and set HidFunction::has_out_endpoint for interrupt-OUT reports. The callback is invoked from the TinyUSB task with the report id as byte 0 of its span; reply by sending an INPUT report with write_hid_report(). If the HID function is requested but CFG_TUD_HID == 0, initialize() fails with std::errc::function_not_supported.

Enabling X-Input (Xbox 360)

X-Input needs no CFG_TUD_* count — it is served by a custom TinyUSB application class driver built into this component (registered via the weak usbd_app_driver_get_cb, forced into the link with -u). An X-Input-only project therefore enables no built-in USB class; the xinput_example disables them all (CONFIG_TINYUSB_CDC_ENABLED=n). Keep CFG_TUD_VENDOR at 0 so the built-in bulk vendor driver does not claim the X-Input 0xFF interface, and use X-Input as the only enabled function (it then advertises the Xbox 360 identity + 0xFF/0xFF/0xFF device class so the host’s XUSB driver binds it). Send gamepad state with update_xinput_state() and receive rumble/LED via on_rumble. The interface uses one interrupt-IN (0x81) + one interrupt-OUT endpoint with separate endpoint numbers, and the report DMA buffers are word-aligned as the ESP32-S3 DWC2 requires.

Routing the console over CDC

When the native USB port is given to TinyUSB for a vendor / HID / XInput interface, the ESP console can no longer live on USB-Serial-JTAG (on the ESP32-S3 it shares the USB-OTG PHY, so it contends and reboot-loops the device). Add a CDC function and route the console to it, and one native USB cable carries both the logs and the other interface:

espp::UsbDevice::CdcFunction cdc;
cdc.route_console = true;   // redirect stdout -> CDC at the end of initialize()
// cdc.tee_console = true;  // (default) also keep the primary UART console
usb_cfg.cdc = cdc;
usb_cfg.vendor = my_vendor; // CDC is just the log channel
espp::UsbDevice usb(usb_cfg);
usb.initialize(ec);         // console now on CDC (teed to UART)

Or call usb.route_console_to_cdc() yourself after a successful initialize(). printf / ESP_LOG / espp::Logger all write to stdout, which is freopen``ed onto a tiny write-only VFS device; its writes forward to ``write_cdc() only when the whole chunk fits the TX FIFO (never blocking on an absent reader, and not gated on DTR) and, with tee_console (default), are also written to the primary UART console so idf.py monitor keeps working. Recommended console config: UART0 primary (CONFIG_ESP_CONSOLE_UART_DEFAULT) with USB-Serial-JTAG as the secondary console for early-boot logs. The ota example uses this.

Endpoint budget (ESP32-S3 USB-OTG)

The ESP32-S3 (and -S2) USB-OTG core is full-speed and, besides the control endpoint 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 notification + 1 bulk-IN)

1 (bulk-OUT)

Vendor / WebUSB

1 (bulk-IN)

1 (bulk-OUT)

HID

1 (interrupt-IN)

0 or 1 (optional interrupt-OUT)

X-Input (Xbox 360)

1 (interrupt-IN)

1 (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: 4 IN / 2-3 OUT

  • CDC + Vendor + MSC: 4 IN / 3 OUT

Enabling CDC + Vendor + HID + MSC together reaches 5 IN endpoints, which is at the hard limit and is not recommended. espp::UsbDevice computes the totals as functions are enabled and 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): it appends one HID interface (application-supplied report descriptor) claiming an interrupt-IN endpoint, plus an optional interrupt-OUT endpoint. 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 interface / endpoint / string allocator: an MSC function appends one MSC interface (SCSI + storage read/write/capacity callbacks) claiming a bulk IN + bulk OUT endpoint.

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 TinyUSB stack and the BOS / vendor control callbacks are global).

  • The receive callbacks run in the TinyUSB device task; keep them short and non-blocking. It is safe to call the matching write_*() from within them.

  • The TinyUSB device lifecycle callbacks (tud_mount_cb / tud_umount_cb / tud_suspend_cb / tud_resume_cb) are owned by esp_tinyusb. Register mount / unmount handlers via set_mount_callback() / set_unmount_callback() rather than defining those callbacks yourself (which would be a duplicate symbol). On unmount the component clears the vendor + CDC TX FIFOs — so a departed host’s queued backlog is not delivered to the next host that mounts — before invoking your callback; both handlers run in the TinyUSB device task.

  • The WebUSB landing-page URL is configured without a scheme; the scheme is encoded separately via VendorFunction::url_scheme (0 = http, 1 = https).

API Reference

Header File

Classes

class UsbDevice : public espp::BaseComponent

Public Types

using receive_callback_fn = std::function<void(std::span<const uint8_t> data)>

Callback invoked with received bytes.

Param data:

Span of received bytes (valid only for the duration of the call).

using event_callback_fn = std::function<void()>

Callback for a device lifecycle event (mount / unmount). Invoked in the TinyUSB device-task context.

Public Functions

explicit UsbDevice(const Config &config)

Construct a UsbDevice. Does not touch hardware until initialize().

Parameters:

config – Configuration parameters.

~UsbDevice()

Uninstalls the enabled functions and the TinyUSB driver if initialized.

bool initialize(std::error_code &ec)

Install the TinyUSB driver and initialize the enabled functions using the configured descriptors / VID-PID / strings.

Parameters:

ec[out] Set on failure (invalid config, endpoint budget exceeded, driver install failure, or unsupported function requested).

Returns:

true on success, false otherwise (ec is set).

bool write_cdc(std::span<const uint8_t> data, std::error_code &ec)

Queue bytes for transmission over the CDC function and flush.

Note

Same backpressure contract as write_vendor(). A frame that fits in the TX FIFO (CONFIG_TINYUSB_CDC_TX_BUFSIZE) is written ALL-OR-NOTHING: the call sleep-waits (bounded, 250 ms) for room for the WHOLE frame and then enqueues it in a single write, so a drain-timeout or a mid-write disconnect returns false WITHOUT leaving a truncated prefix on the wire (a partial frame would poison the host-side framing parser). When called from TinyUSB-callback context (e.g. inside a receive callback, which runs on the TinyUSB task) the drain can never happen while this call blocks, so it fails fast with `no_buffer_space` if the whole frame does not ALREADY fit - again without enqueueing anything. A frame LARGER than the FIFO cannot be atomic and is streamed across drains (a mid-stream timeout may leave a prefix on the wire); keep framed payloads within the FIFO, or send large replies from your own task rather than a receive callback, for atomic writes.

Parameters:
  • data – Bytes to send.

  • ec[out] Set on failure (e.g. CDC not enabled / not initialized, or the TX FIFO could not accept all bytes - see note below).

Returns:

true if all bytes were queued, false otherwise.

bool write_cdc(std::span<const uint8_t> data)

Convenience overload of write_cdc() that ignores errors.

bool write_vendor(std::span<const uint8_t> data, std::error_code &ec)

Queue bytes for transmission over the vendor function and flush.

Note

A frame that fits in the TX FIFO (CONFIG_TINYUSB_VENDOR_TX_BUFSIZE) is written ALL-OR-NOTHING: the call sleep-waits (bounded, 250 ms) for room for the WHOLE frame and then enqueues it in a single write, so a drain-timeout or a mid-write unmount returns false WITHOUT leaving a truncated prefix on the wire (a partial frame would poison the host-side framing parser). When called from TinyUSB-callback context (e.g. inside a receive callback, which runs on the TinyUSB task) the drain can never happen while this call blocks, so it fails fast with `no_buffer_space` if the whole frame does not ALREADY fit - again without enqueueing anything. A frame LARGER than the FIFO cannot be atomic and is streamed across drains (a mid-stream timeout may leave a prefix on the wire); keep framed payloads within the FIFO, or send large replies from your own task rather than a receive callback, for atomic writes.

Parameters:
  • data – Bytes to send.

  • ec[out] Set on failure (e.g. vendor not enabled / not initialized, or the TX FIFO could not accept all bytes - see note below).

Returns:

true if all bytes were queued, false otherwise.

bool write_vendor(std::span<const uint8_t> data)

Convenience overload of write_vendor() that ignores errors.

size_t vendor_write_available() const

Bytes of free space currently in the vendor TX FIFO.

Returns:

How many bytes write_vendor() can accept right now without blocking, or 0 if not initialized / no vendor interface / not mounted. A point-in-time hint: with a single serialized writer it is stable, otherwise treat it as advisory. Use it to skip or defer a streaming frame when the host has stopped draining the endpoint, instead of building the frame and having write_vendor() drop it.

size_t cdc_write_available() const

Bytes of free space currently in the CDC TX FIFO.

Returns:

How many bytes write_cdc() can accept right now, or 0 if not initialized / no CDC interface / not mounted. See vendor_write_available() for usage notes.

void vendor_write_clear()

Discard any bytes queued in the vendor TX FIFO that have not been sent yet. Call this when the host goes away (e.g. on a detected disconnect / stream stall) so a stale backlog (queued telemetry) is not delivered to the next host that connects and mis-parsed as a reply to its first command.

void cdc_write_clear()

Discard any bytes queued in the CDC TX FIFO that have not been sent yet. See vendor_write_clear() for usage notes.

bool route_console_to_cdc(std::error_code &ec)

Redirect the ESP console (stdout) to the CDC interface, so the device’s logs travel over the same native USB cable as the other USB interface(s) (vendor / HID / XInput). Call this AFTER a successful `initialize()`; or just set `CdcFunction::route_console` and it is done for you at the end of `initialize()`.

`printf`, `ESP_LOG` (via its default vprintf), and `espp::Logger` (which uses `fmt::print`) all write to `stdout`, so redirecting stdout captures them all. A small write-only VFS device is registered and `stdout` is `freopen`ed onto it; its writes forward to `write_cdc()` only when the CDC TX FIFO can take the whole chunk right now, so logging NEVER blocks on an absent or slow reader (dropped console bytes are harmless). When `CdcFunction::tee_console` is set (the default) and the primary console is a UART, writes are also teed to that UART so `idf.py monitor` keeps working.

Idempotent (a second call is a no-op). Requires the CDC function to be enabled and the device initialized.

Note

Lifetime: routing points `stdout` at this device. On destruction the device detaches itself (later stdout writes degrade to the UART tee), but a write already in flight can still race destruction &#8212; so a console-routed UsbDevice must outlive concurrent logging. This is normally trivial: it is a program-lifetime singleton.

Parameters:

ec[out] Set on failure (CDC not enabled / not initialized, or the VFS device could not be registered / stdout could not be reopened).

Returns:

true if the console is now routed to CDC (or already was).

bool route_console_to_cdc()

Convenience overload of route_console_to_cdc() that ignores errors.

bool is_console_routed_to_cdc() const

Whether the console is currently routed to the CDC interface.

bool write_hid_report(uint8_t report_id, std::span<const uint8_t> report, std::error_code &ec)

Send a HID input report on the HID function’s interrupt IN endpoint.

Parameters:
  • report_id – HID report id (0 if the report descriptor has no report id; otherwise the id baked into the descriptor, e.g. 1 for the gamepad).

  • report – Report payload bytes (without the report-id prefix).

  • ec[out] Set on failure (HID not enabled / not initialized, host not ready, or the HID class driver is not compiled in).

Returns:

true if the report was queued for transmission, false otherwise.

bool write_hid_report(uint8_t report_id, std::span<const uint8_t> report)

Convenience overload of write_hid_report() that ignores errors.

bool is_hid_ready() const

Whether the HID function is enabled, mounted and ready to accept a new input report (no report in flight).

bool update_xinput_state(const espp::xinput::GamepadState &state, std::error_code &ec)

Send a fresh X-Input (Xbox 360) input report from a gamepad state.

Note

Single-writer: call from one task. The report bytes are held in an internal buffer for the duration of the (asynchronous) transfer.

Parameters:
  • state – Buttons / triggers / sticks to serialize into the 20-byte report.

  • ec[out] Set on failure (XInput not enabled / not initialized, host not ready / a previous report still in flight, or a transfer error).

Returns:

true if the report was queued for transmission, false otherwise.

bool update_xinput_state(const espp::xinput::GamepadState &state)

Convenience overload of update_xinput_state() that ignores errors.

bool is_xinput_ready() const

Whether the XInput function is enabled, mounted and ready to accept a new input report (no report in flight).

void set_cdc_receive_callback(const receive_callback_fn &cb)

Set or replace the CDC receive callback (nullptr to detach).

void set_vendor_receive_callback(const receive_callback_fn &cb)

Set or replace the vendor receive callback (nullptr to detach).

void set_hid_receive_callback(const receive_callback_fn &cb)

Set or replace the HID receive callback (received OUTPUT / SET_REPORT bytes, host -> device; nullptr to detach).

void set_mount_callback(const event_callback_fn &cb)

Register a callback invoked when the device is mounted (the host has configured it). Runs in the TinyUSB device-task context; nullptr detaches. esp_tinyusb owns the raw tud_mount_cb, so applications should register here rather than defining that callback themselves.

void set_unmount_callback(const event_callback_fn &cb)

Register a callback invoked when the device is unmounted (detached / re-enumerated). The component clears the vendor + CDC TX FIFOs before invoking it. Runs in the TinyUSB device-task context; nullptr detaches. Register here instead of defining tud_umount_cb (esp_tinyusb already defines it).

bool is_initialized() const

Whether initialize() has completed successfully.

bool is_cdc_connected() const

Whether the CDC function is enabled and a host has asserted DTR.

bool is_vendor_connected() const

Whether the vendor function is enabled and the device is mounted.

inline const std::string &get_name() const

Get the name of the component

Note

This is the tag of the logger

Returns:

A const reference to the name of the component

inline void set_log_tag(const std::string_view &tag)

Set the tag for the logger

Parameters:

tag – The tag to use for the logger

inline espp::Logger::Verbosity get_log_level() const

Get the log level for the logger

Returns:

The verbosity level of the logger

inline void set_log_level(espp::Logger::Verbosity level)

Set the log level for the logger

Parameters:

level – The verbosity level to use for the logger

inline void set_log_verbosity(espp::Logger::Verbosity level)

Set the log verbosity for the logger

See also

set_log_level

Note

This is a convenience method that calls set_log_level

Parameters:

level – The verbosity level to use for the logger

inline espp::Logger::Verbosity get_log_verbosity() const

Get the log verbosity for the logger

See also

get_log_level

Note

This is a convenience method that calls get_log_level

Returns:

The verbosity level of the logger

inline void set_log_rate_limit(std::chrono::duration<float> rate_limit)

Set the rate limit for the logger

Note

Only calls to the logger that have _rate_limit suffix will be rate limited

Parameters:

rate_limit – The rate limit to use for the logger

struct CdcFunction

CDC-ACM (virtual serial port) function.

Consumes 1 interrupt IN (notification) + 1 bulk IN + 1 bulk OUT endpoint (across two USB interfaces joined by an IAD).

Public Members

std::string interface_name = {"espp CDC"}

CDC interface string descriptor.

receive_callback_fn on_receive = {nullptr}

Callback invoked with received bytes.

size_t rx_chunk_size = {64}

Buffer size used to drain the CDC RX FIFO per read.

bool route_console = {false}

Route the ESP console to this CDC interface once `initialize()` succeeds (equivalent to calling `route_console_to_cdc()` yourself).

The native USB port is often handed to TinyUSB for a vendor / HID / XInput interface, which on the ESP32-S3 means the console can no longer live on USB-Serial-JTAG (it shares that USB PHY). Enabling this redirects the console (stdout — `printf`, `ESP_LOG`, and `espp::Logger`’s `fmt::print` all default there) to this CDC interface, so a single native USB cable carries both the logs and the other interface(s). Writes are non-blocking and are dropped when no host is draining the CDC endpoint.

bool tee_console = {true}

When `route_console` (or `route_console_to_cdc()`) redirects the console, also keep writing it to the ORIGINAL console (a tee), so `idf.py monitor` on the primary UART keeps working and nothing is lost when no CDC host is attached. Best-effort: teeing is only done when the primary console is a UART (it has an independent port); with a USB-Serial-JTAG or no console there is nothing to tee to.

struct Config

Configuration for the composable UsbDevice.

Public Members

uint16_t vid = {0x1209}

USB Vendor ID (defaults to the pid.codes VID used by ODrive).

uint16_t pid = {0x0d32}

USB Product ID (defaults to an ODrive-like PID).

std::string manufacturer = {"espp"}

Manufacturer string descriptor.

std::string product = {"espp USB Device"}

Product string descriptor.

std::string serial_number = {"000000000001"}

Serial number string descriptor.

std::optional<CdcFunction> cdc = {}

Enable a CDC-ACM function.

std::optional<VendorFunction> vendor = {}

Enable a vendor-specific / WebUSB function.

std::optional<HidFunction> hid = {}

Enable a HID function.

std::optional<XInputFunction> xinput = {}

Enable an X-Input (Xbox 360) function.

std::optional<MscFunction> msc = {}

(Future) enable an MSC function.

espp::Logger::Verbosity log_level = {espp::Logger::Verbosity::WARN}

Logger verbosity.

struct HidFunction

HID (Human Interface Device) function.

A HID function consumes 1 interrupt IN endpoint (and optionally 1 interrupt OUT if `has_out_endpoint` is set). It advertises the application-supplied `report_descriptor` bytes (the TinyUSB HID class driver returns them from `tud_hid_descriptor_report_cb`), and input reports are sent with `UsbDevicewrite_hid_report()`. The descriptor bytes are typically built with the espp `hid-rp` component (e.g. `espp::GamepadInputReport`); the component itself stays descriptor-bytes based and does not depend on hid-rp.

Requires the TinyUSB HID class driver to be compiled in (`CONFIG_TINYUSB_HID_COUNT` > 0, which defines `CFG_TUD_HID`); otherwise enabling this function makes `initialize()` fail with `std::errc::function_not_supported`.

Public Members

std::string interface_name = {"espp HID"}

HID interface string descriptor.

std::vector<uint8_t> report_descriptor = {}

HID report descriptor bytes.

bool has_out_endpoint = {false}

Whether to allocate an interrupt OUT endpoint.

uint8_t poll_interval_ms = {10}

Interrupt IN polling interval (bInterval), ms.

receive_callback_fn on_receive = {nullptr}

Callback invoked with received HID OUTPUT / SET_REPORT bytes (host -> device). Enables request/response HID protocols (e.g. the Nintendo Switch Pro controller handshake): reply by sending an INPUT report with `write_hid_report()`. When the report descriptor uses report IDs, byte 0 of the delivered span is the report id. Delivered from the TinyUSB device task; `write_hid_report()` is safe to call from within it. Requires `has_out_endpoint` for interrupt-OUT reports (control SET_REPORT is delivered regardless).

struct MscFunction

(Future) MSC (mass storage) function extension point. Not implemented yet.

An MSC function consumes 1 bulk IN + 1 bulk OUT endpoint and requires SCSI + storage callbacks (read10 / write10 / inquiry / capacity). Enabling it today makes initialize() fail with `std::errc::function_not_supported`.

struct VendorFunction

Vendor-specific function (bInterfaceClass 0xFF) carrying a raw byte stream over one bulk IN + one bulk OUT endpoint.

When `webusb` is true a BOS descriptor advertising the WebUSB platform capability (with `webusb_vendor_code` + landing-page index 1) and an MS OS 2.0 platform capability (with `ms_os_vendor_code`, so Windows binds WinUSB automatically with no driver) is exposed, and the WebUSB URL / MS-OS-2.0 descriptor vendor control requests are answered.

Public Members

std::string interface_name = {"espp Vendor"}

Vendor interface string descriptor.

receive_callback_fn on_receive = {nullptr}

Callback invoked with received bytes.

size_t rx_chunk_size = {64}

Buffer size used to drain the vendor RX FIFO per read.

bool webusb = {true}

Advertise WebUSB + MS OS 2.0 descriptors for driverless access.

std::string landing_page_url = {"esp-cpp.github.io/espp/apps/board_console.html"}

WebUSB landing-page URL. When `url_scheme` is 0 (http) or 1 (https) the URL must be given *without* a scheme (the scheme is prepended by the host from `url_scheme`). When `url_scheme` is 255 the URL must instead *include* its own scheme (e.g. “http://…”). Defaults to the espp docs-hosted board console + ESP flasher (scheme-less, https), a general-purpose Web Serial monitor and esptool-js flasher.

Note

The descriptor length (3 + URL bytes) must fit a uint8_t, so the URL is limited to 252 bytes; `initialize()` rejects a longer URL.

uint8_t url_scheme = {1}

0 = http, 1 = https, 255 = URL includes its own scheme.

uint8_t webusb_vendor_code = {1}

bRequest used for the WebUSB URL control request.

uint8_t ms_os_vendor_code{2}

bRequest used for the MS OS 2.0 descriptor control request.

struct XInputFunction

X-Input (Xbox 360 wired controller) function.

Presents a vendor-specific interface (bInterfaceClass 0xFF / SubClass 0x5D / Protocol 0x01) with one interrupt IN endpoint (20-byte input reports, sent with `UsbDeviceupdate_xinput_state()`) and one interrupt OUT endpoint (8-byte rumble / LED reports, delivered to `on_rumble`). Unlike HID it is served by a small custom TinyUSB application class driver built into this component (no `CFG_TUD_*` count is required).

A PC’s XUSB driver only binds a device whose VID/PID is a recognized Xbox 360 controller, so `vid` / `pid` default to Microsoft’s identifiers (`0x045E:0x028E`) &#8212; for emulation / testing of your own device only. When the XInput function is the ONLY enabled function these identifiers (and a 0xFF/0xFF/0xFF device class) override the top-level Config vid/pid so the host recognizes it; combine XInput with other functions only if you do not need XUSB to bind (the built-in vendor/WebUSB class also claims class 0xFF).

Consumes 1 interrupt IN + 1 interrupt OUT endpoint.

Public Members

std::string interface_name = {"espp XInput"}

XInput interface string descriptor.

uint16_t vid = {espp::xinput::kDefaultVid}

Xbox 360 controller VID (Microsoft).

uint16_t pid = {espp::xinput::kDefaultPid}

Xbox 360 controller PID.

receive_callback_fn on_rumble = {nullptr}

Callback invoked with received rumble / LED report bytes (8-byte reports on the interrupt OUT endpoint). Runs in the TinyUSB device task.

Header File

Classes

class UsbCdc : public espp::BaseComponent

Native USB CDC-ACM transport: a thin CDC-only preset over `espp::UsbDevice`.

`esppUsbCdc` presents a single dedicated CDC-ACM (virtual serial port) interface on the native USB peripheral with a *configurable* VID/PID and manufacturer / product / serial strings. It is kept for back-compatibility and is implemented on top of the composable `espp::UsbDevice` (which can also add a vendor-specific / WebUSB interface, HID, MSC, …). For anything beyond a plain serial port, prefer `espp::UsbDevice` directly.

Incoming bytes are delivered to a user callback and outgoing bytes are sent via write(). The class does not throw and reports initialization failures via `std::error_code`.

UsbCdc Example

  // One simulated motor state, shared by both protocol servers.
  struct {
    std::atomic<float> vbus{24.0f};
    std::atomic<float> position{0.0f};
    std::atomic<float> velocity{0.0f};
    std::atomic<float> torque{0.0f};
    std::atomic<float> vel_limit{20.0f};
    std::atomic<uint32_t> error{0};
    std::atomic<uint64_t> serial{0xA1B2C3D4E5ULL};
  } state;

  // --- ASCII protocol server (CDC / terminal / WebSerial) -------------------
  OdriveAscii::Config ascii_cfg;
  ascii_cfg.log_level = Logger::Verbosity::WARN;
  OdriveAscii ascii(ascii_cfg);
  ascii.register_float_property(
      "axis0.encoder.pos_estimate", [&]() { return state.position.load(); },
      [&](float v, std::error_code &ec) {
        ec.clear();
        state.position = v;
        return true;
      });
  ascii.register_float_property("axis0.encoder.vel_estimate",
                                [&]() { return state.velocity.load(); });
  ascii.register_float_property(
      "axis0.controller.input_pos", [&]() { return state.position.load(); },
      [&](float v, std::error_code &ec) {
        ec.clear();
        state.position = v;
        return true;
      });
  ascii.on_position_command([&](int axis, float pos, std::optional<float> vel_ff,
                                std::optional<float> torque_ff, std::error_code &ec) {
    (void)axis;
    ec.clear();
    state.position = pos;
    if (vel_ff.has_value())
      state.velocity = *vel_ff;
    if (torque_ff.has_value())
      state.torque = *torque_ff;
    return true;
  });
  ascii.on_feedback_request([&](int axis, float &pos_out, float &vel_out, std::error_code &ec) {
    (void)axis;
    ec.clear();
    pos_out = state.position.load();
    vel_out = state.velocity.load();
    return true;
  });

  // --- Native (Fibre) protocol server (vendor interface / odrivetool) -------
  // Register an ODrive-style endpoint tree; odrivetool / the fibre library
  // download this tree from endpoint 0 and read/write it by numeric id.
  OdriveNative::Config native_cfg;
  native_cfg.log_level = Logger::Verbosity::WARN;
  OdriveNative native(native_cfg);
  native.register_float_property("vbus_voltage", [&]() { return state.vbus.load(); });
  native.register_uint32_property("axis0.error", [&]() { return state.error.load(); });
  native.register_float_property("axis0.encoder.pos_estimate",
                                 [&]() { return state.position.load(); });
  native.register_float_property("axis0.encoder.vel_estimate",
                                 [&]() { return state.velocity.load(); });
  native.register_float_property(
      "axis0.controller.input_pos", [&]() { return state.position.load(); },
      [&](float v, std::error_code &ec) {
        ec.clear();
        state.position = v;
        return true;
      });
  native.register_float_property(
      "axis0.controller.config.vel_limit", [&]() { return state.vel_limit.load(); },
      [&](float v, std::error_code &ec) {
        ec.clear();
        state.vel_limit = v;
        return true;
      });
  native.register_uint64_property("serial_number", [&]() { return state.serial.load(); });

  // --- Composite native USB device ------------------------------------------
  UsbDevice::Config usb_cfg;
  usb_cfg.vid = 0x1209; // pid.codes VID used by ODrive
  usb_cfg.pid = 0x0d32; // ODrive v3-like PID
  usb_cfg.manufacturer = "espp";
  usb_cfg.product = "espp ODrive";
  usb_cfg.serial_number = "0001";
  usb_cfg.log_level = Logger::Verbosity::INFO;

  UsbDevice::CdcFunction cdc;
  cdc.interface_name = "espp ODrive ASCII (CDC)";
  usb_cfg.cdc = cdc;

  UsbDevice::VendorFunction vendor;
  vendor.interface_name = "espp ODrive native (Fibre)";
  vendor.webusb = true; // advertise BOS / WebUSB / MS OS 2.0 descriptors
  usb_cfg.vendor = vendor;

  // HID gamepad function. The report descriptor is built with the espp hid-rp
  // component: wrap espp::GamepadInputReport's descriptor fragment in a
  // generic-desktop GAMEPAD application collection and serialize it to bytes.
  static constexpr uint8_t kHidReportId = 1;
  static constexpr size_t kNumButtons = 15;
  using Gamepad = espp::GamepadInputReport<kNumButtons, std::uint16_t, std::uint16_t, 0, 65535, 0,
                                           1023, kHidReportId>;
  Gamepad gamepad;
  gamepad.reset();

  std::vector<uint8_t> hid_report_descriptor;
  {
    using namespace hid::page;
    using namespace hid::rdf;
    auto raw_descriptor = descriptor(usage_page<generic_desktop>(), usage(generic_desktop::GAMEPAD),
                                     collection::application(gamepad.get_descriptor()));
    hid_report_descriptor.assign(raw_descriptor.begin(), raw_descriptor.end());
  }
  logger.info("HID gamepad report descriptor: {} bytes", hid_report_descriptor.size());

  UsbDevice::HidFunction hid;
  hid.interface_name = "espp Gamepad HID";
  hid.report_descriptor = hid_report_descriptor;
  usb_cfg.hid = hid;

  UsbDevice usb(usb_cfg);

  // CDC RX -> ASCII protocol -> CDC write.
  usb.set_cdc_receive_callback([&](std::span<const uint8_t> data) {
    auto response = ascii.process_bytes(data);
    if (!response.empty())
      usb.write_cdc(response);
  });

  // Vendor RX -> native (Fibre) protocol -> vendor write.
  //
  // The Fibre packet protocol over USB relies on USB transfer boundaries: each
  // host bulk-OUT transfer is exactly one packet. odrivetool's requests are
  // small (< 64 B), so each vendor RX callback delivers one whole packet, which
  // is what process_bytes() expects. (If a future client sent packets larger
  // than a single bulk transfer, this callback would need a length-based
  // reassembly step.)
  usb.set_vendor_receive_callback([&](std::span<const uint8_t> data) {
    auto response = native.process_bytes(data);
    if (!response.empty())
      usb.write_vendor(response);
  });

  std::error_code ec;
  if (!usb.initialize(ec)) {
    logger.error("Failed to initialize USB device: {}", ec.message());
    return;
  }
  logger.info("ODrive-compatible native USB device ready:");
  logger.info("  CDC serial interface  -> ODrive ASCII  (terminal / WebSerial)");
  logger.info("  vendor interface (WebUSB) -> ODrive native/Fibre (odrivetool over USB)");
  logger.info("  HID interface -> animated gamepad (visualize with hid_visualizer.html)");
  logger.info("Native endpoint tree ({} bytes, json_crc=0x{:04x})", native.json().size(),
              native.json_crc());

Note

Only one `esppUsbCdc` / `espp::UsbDevice` instance may exist at a time. USB-OTG is only available on the ESP32-S2, ESP32-S3 and ESP32-P4 targets.

Note

The receive callback is invoked from the TinyUSB device task. Keep it short and non-blocking; it is safe to call write() from within it.

Public Types

using receive_callback_fn = std::function<void(std::span<const uint8_t> data)>

Callback invoked with received bytes.

Param data:

Span of received bytes (valid only for the duration of the call).

Public Functions

explicit UsbCdc(const Config &config)

Construct a UsbCdc transport. Does not touch hardware until initialize() is called.

Parameters:

config – Configuration parameters.

~UsbCdc()

Uninstalls the CDC-ACM interface and TinyUSB driver if initialized.

bool initialize(std::error_code &ec)

Install the TinyUSB driver and initialize the CDC-ACM interface using the configured descriptors / VID-PID / strings.

Parameters:

ec[out] Set on failure.

Returns:

true on success, false otherwise (ec is set).

bool write(std::span<const uint8_t> data, std::error_code &ec)

Queue bytes for transmission over the CDC interface and flush.

Parameters:
  • data – Bytes to send.

  • ec[out] Set on failure (e.g. not initialized).

Returns:

true if all bytes were queued, false otherwise.

bool write(std::span<const uint8_t> data)

Convenience overload of write() that ignores errors.

Parameters:

data – Bytes to send.

Returns:

true if all bytes were queued, false otherwise.

void set_receive_callback(const receive_callback_fn &cb)

Set or replace the receive callback.

Parameters:

cb – Callback to invoke with received bytes (may be nullptr to detach).

bool is_initialized() const

Whether initialize() has completed successfully.

bool is_connected() const

Whether a USB host has opened (asserted DTR on) the CDC port.

inline const std::string &get_name() const

Get the name of the component

Note

This is the tag of the logger

Returns:

A const reference to the name of the component

inline void set_log_tag(const std::string_view &tag)

Set the tag for the logger

Parameters:

tag – The tag to use for the logger

inline espp::Logger::Verbosity get_log_level() const

Get the log level for the logger

Returns:

The verbosity level of the logger

inline void set_log_level(espp::Logger::Verbosity level)

Set the log level for the logger

Parameters:

level – The verbosity level to use for the logger

inline void set_log_verbosity(espp::Logger::Verbosity level)

Set the log verbosity for the logger

See also

set_log_level

Note

This is a convenience method that calls set_log_level

Parameters:

level – The verbosity level to use for the logger

inline espp::Logger::Verbosity get_log_verbosity() const

Get the log verbosity for the logger

See also

get_log_level

Note

This is a convenience method that calls get_log_level

Returns:

The verbosity level of the logger

inline void set_log_rate_limit(std::chrono::duration<float> rate_limit)

Set the rate limit for the logger

Note

Only calls to the logger that have _rate_limit suffix will be rate limited

Parameters:

rate_limit – The rate limit to use for the logger

struct Config

Configuration for the UsbCdc transport.

Public Members

uint16_t vid = {0x1209}

USB Vendor ID advertised in the device descriptor. Defaults to the pid.codes VID used by ODrive.

uint16_t pid = {0x0d32}

USB Product ID advertised in the device descriptor. Defaults to an ODrive-like PID.

std::string manufacturer = {"espp"}

Manufacturer string descriptor.

std::string product = {"espp USB CDC"}

Product string descriptor.

std::string serial_number = {"000000000001"}

Serial number string descriptor.

std::string interface_name = {"espp CDC"}

CDC interface string descriptor.

receive_callback_fn on_receive = {nullptr}

Callback invoked with received bytes. May be set/replaced later via set_receive_callback().

size_t rx_chunk_size = {64}

Size of the buffer used to drain the CDC RX FIFO per read.

espp::Logger::Verbosity log_level = {espp::Logger::Verbosity::WARN}

Logger verbosity.

Header File