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().

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()

  • 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, while 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 (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)

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 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

Composable native-USB device built on ESP-IDF’s `esp_tinyusb` managed component and the ESP32-S3 / -S2 / -P4 USB-OTG peripheral.

`esppUsbDevice` assembles a USB device from a *set of selectable functions* rather than hard-coding a single class. Today it can enable a **CDC-ACM** (virtual serial port) function and/or a **vendor-specific** function (bInterfaceClass 0xFF, one bulk IN + one bulk OUT) that optionally advertises **WebUSB** + **MS OS 2.0** descriptors so a browser can talk to it driverlessly, and/or 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). Interface numbers, endpoint addresses and string indices are allocated *sequentially* as functions are enabled, and the device checks the result against the USB-OTG endpoint budget (reporting an error via `std::error_code` if it is exceeded).

The design also leaves room for an **MSC** function to be added later without changing the descriptor-building model (see `MscFunction` below and the endpoint-budget table in the README).

The VID/PID and manufacturer / product / serial strings are configurable so a device can advertise its own identifiers (e.g. ODrive-like) on a link that is completely separate from the ESP console (which normally rides the built-in USB-Serial-JTAG peripheral or a UART).

The class is idiomatic espp: it does not throw, reports initialization failures via `std::error_code`, and marshals the TinyUSB RX callbacks (which run in the TinyUSB device task context) into per-function user callbacks.

UsbDevice (composite CDC + Vendor/WebUSB) 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 `esppUsbDevice` (or `espp::UsbCdc`) may exist at a time; the TinyUSB device stack, the vendor RX routing and the BOS/WebUSB control requests are all global. USB-OTG is only available on the ESP32-S2, ESP32-S3 and ESP32-P4 targets.

Note

Receive callbacks are invoked from the TinyUSB device task. Keep them short and non-blocking; it is safe to call the matching write() from within them.

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 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.

Parameters:
  • data – Bytes to send.

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

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.

Parameters:
  • data – Bytes to send.

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

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.

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).

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).

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.

void handle_cdc_rx()

Internal: drain the CDC RX FIFO and dispatch to the CDC callback.

void handle_vendor_rx(const uint8_t *buffer = nullptr, size_t bufsize = 0)

Internal: dispatch received vendor bytes to the vendor callback.

Parameters:
  • buffer – When non-null (TinyUSB zero-copy RX variant, RX_BUFSIZE==0), the just-received bytes to dispatch directly. When null (the FIFO variant), the FIFO is drained via `tud_vendor_read()` instead.

  • bufsize – Number of bytes at buffer (0 when buffer is null).

const uint8_t *bos_descriptor() const

Internal: pointer to the BOS descriptor bytes (nullptr if none).

const uint8_t *ms_os_20_descriptor(uint16_t &total_len) const

Internal: pointer to the MS OS 2.0 descriptor bytes (nullptr if none).

const uint8_t *webusb_url_descriptor(uint8_t &length) const

Internal: pointer to the WebUSB URL descriptor bytes (nullptr if none).

const uint8_t *hid_report_descriptor() const

Internal: pointer to the stored HID report descriptor bytes (nullptr if the HID function is not enabled). Returned to the TinyUSB HID class driver from `tud_hid_descriptor_report_cb`.

inline const std::optional<VendorFunction> &vendor_config() const

Internal: config for the vendor control-request handler.

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

Public Static Functions

static UsbDevice *instance()

Internal: the singleton instance handling the global USB callbacks.

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.

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<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.

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.

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.