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

  • An MSC (mass storage) function exposing an SD card and/or a FAT partition in flash as USB drives, shared with the application through an ownership hand-over.

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

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

  • Mass storage (MSC): an SD card and/or a wear-levelled FAT flash partition as USB drives, handed between the application (VFS file access) and the host

  • 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, plus the descriptor details some hosts check: bcd_device (device release), max_power_ma (bMaxPower, clamped to 500 mA and rounded up to the next 2 mA unit) and remote_wakeup

  • 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
// optional descriptor details (defaults: 0x0100, 100 mA, remote wakeup on);
// e.g. a Nintendo Switch expects a Pro Controller to report 0x0210 and 500 mA
cfg.bcd_device = 0x0100;
cfg.max_power_ma = 100;   // clamped to 500, rounded up to a 2 mA unit
cfg.remote_wakeup = true;

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.

Enabling mass storage (MSC)

The MSC function exposes up to two media as USB drives: an SD card and/or a FAT data partition in flash (accessed through wear levelling). It is built on esp_tinyusb’s MSC storage backend, which provides the SCSI handling, so it needs CONFIG_TINYUSB_MSC_ENABLED=y; flash media additionally need CONFIG_TINYUSB_MSC_BUFSIZE >= CONFIG_WL_SECTOR_SIZE. Prefer 4096-byte wear-levelling sectors with a 4096-byte MSC buffer (the msc_example does): NOR flash erases in 4 KiB blocks and esp_tinyusb erases before writing, so a host write is then one erase and one write. 512-byte sectors need a read-modify-erase of the block per sector (4 erases in Safety mode; Performance mode loses the whole block on a reset mid-erase), making small host edits take seconds. 4 KiB sectors cost 4 KiB of RAM per volume / open file and at least 4 KiB of space per file, and the host sees 4 KiB logical sectors. SD cards avoid the trade-off entirely: the card’s controller manages erase blocks, so sectors are written directly with no ESP-side wear levelling.

A medium belongs to one side at a time, so the firmware and a PC never write the same FAT volume at once. While the application owns it, the volume is mounted at the medium’s base_path and ordinary file APIs work (fopen, std::fstream, std::filesystem); a connected host sees “no medium”. While the host owns it, base_path is unmounted and the PC sees the volume. With MscFunction::auto_handover (the default) the host takes the media when it mounts the device and the application gets them back when the host ejects the drive or the device is detached (for all media at once: esp_tinyusb ignores which drive was ejected). Set Config::connect_on_initialize = false and call connect() to finish application I/O before any host can take a medium; turn it off to decide with set_msc_owner(). Taking a medium from an attached host is refused (device_or_resource_busy), since host writes already queued could land under the application’s volume; eject the drive on the host first. msc_owner(), msc_capacity() and MscFunction::on_event report the state (the event callback runs in the TinyUSB task for host-driven hand-overs).

espp::UsbDevice::MscMedium card;
card.type = espp::UsbDevice::MscMedium::Type::SdCard;
card.sd_card = sd_card;     // an initialized sdmmc_card_t* (SDMMC or SDSPI host)
card.base_path = "/sdcard"; // do not also mount the card with esp_vfs_fat_*_mount()

espp::UsbDevice::MscMedium flash;
flash.type = espp::UsbDevice::MscMedium::Type::FlashPartition;
flash.partition_label = "storage"; // a `data, fat` partition
flash.base_path = "/data";
flash.volume_label = "MY DATA";   // drive name on the host; needs CONFIG_FATFS_USE_LABEL=y

espp::UsbDevice::MscFunction msc;
msc.media = {card, flash}; // LUN 0 and LUN 1
cfg.msc = msc;

Limits, all from esp_tinyusb’s backend: at most one SD card and one flash partition; SD card media need a target with an SDMMC host peripheral (ESP32-S3 / -P4), even for an SPI-wired card; the SCSI inquiry strings are fixed. Formatting (format_if_unformatted / format_msc_medium()) runs on FatFs drive 0 rather than the medium’s own drive, so only use it when no other FAT volume is mounted. A host can only read FAT, so LittleFS or SPIFFS partitions cannot be exposed as a drive. Destroying the UsbDevice releases the media: an application-owned medium’s base_path is unmounted (an SD card stays initialized, but must be mounted again if the application still needs it).

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

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.

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

enum class MscOwner : uint8_t

Which side currently has an MSC medium. A medium belongs to exactly one side: while the host has it, the application’s `base_path` is unmounted (open files there become invalid); while the application has it, the host sees the drive as “no medium”.

Values:

enumerator Host

Exposed to the USB host as a drive.

enumerator App

Mounted at MscMedium::base_path for the application (fopen, std::filesystem).

enum class MscEvent : uint8_t

Storage events reported through MscFunction::on_event / set_msc_event_callback().

Values:

enumerator OwnerChangeStarted

A hand-over between application and host is starting.

enumerator OwnerChanged

The hand-over completed; `owner` is the new owner.

enumerator OwnerChangeFailed

The hand-over failed (e.g. the FAT volume could not be mounted).

enumerator FormatRequired

The medium has no FAT filesystem and format_if_unformatted is off.

enumerator FormatFailed

Formatting the medium failed.

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.

using msc_event_callback_fn = std::function<void(size_t lun, MscEvent event, MscOwner owner)>

MSC storage event callback: the medium index (LUN), the event, and the owner at the time of the event: the previous owner for OwnerChangeStarted and OwnerChangeFailed (the side that still has the medium), the new one for OwnerChanged. Runs in the TinyUSB device task for host-driven hand-overs (mount / eject / disconnect) and in the caller’s task for set_msc_owner(); keep it short and do not call set_msc_owner() from it.

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.

Note

MSC media are released too: an application-owned medium’s `base_path` is unmounted, flash partitions are unmounted from wear levelling, and an SD card is left initialized (the caller owns it) but not mounted.

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

bool set_msc_owner(size_t lun, MscOwner owner, std::error_code &ec)

Hand an MSC medium to the application or the USB host.

Note

Taking a medium from an attached host is refused: esp_tinyusb accepts host writes and runs them later, without re-checking ownership, so a write already queued could land under the application’s mounted FAT volume. Have the host eject the drive (auto_handover then returns it), or detach / destroy the device, first. Handing a medium to the host is always allowed.

Note

Blocks for the mount / unmount. Call it from an application task, not from a USB callback. With auto_handover, the next host mount / eject / detach still moves the medium automatically &#8212; and a host mount or eject that happens during this call races it, so turn auto_handover off if the application drives ownership itself.

Parameters:
  • lun – Medium index (position in MscFunction::media).

  • owner – New owner. Handing it to the App mounts the FAT volume at the medium’s `base_path`; handing it to the Host unmounts it there first.

  • ec – [out] Set on failure: MSC not enabled / not initialized (`not_connected`), bad index (`invalid_argument`), the medium has no FAT filesystem (`no_such_device`, see format_msc_medium()), the host is attached and still has the medium (`device_or_resource_busy`, see below), or the volume could not be mounted / unmounted (`io_error`).

Returns:

true if `owner` now has the medium.

bool set_msc_owner(size_t lun, MscOwner owner)

Convenience overload of set_msc_owner() that ignores errors.

std::optional<MscOwner> msc_owner(size_t lun) const

Who currently has an MSC medium (nullopt if MSC is not enabled / initialized or the index is out of range). An unformatted medium waiting for format_msc_medium() reports App, with nothing mounted.

std::optional<MscCapacity> msc_capacity(size_t lun) const

Size of an MSC medium (nullopt if MSC is not enabled / initialized or the index is out of range).

size_t msc_lun_count() const

Number of MSC media (LUNs); 0 if MSC is not enabled / initialized.

bool format_msc_medium(size_t lun, std::error_code &ec)

Create a FAT filesystem on an MSC medium that has none (e.g. after MscEvent::FormatRequired), and mount it for the application.

Note

With auto_handover, the USB connection is dropped for the duration of the format (and restored after) so a host attaching mid-format cannot take the medium while esp_tinyusb is formatting it.

Warning

See MscMedium::format_if_unformatted: esp_tinyusb formats FatFs drive 0, so only use this when no other FAT volume is mounted.

Parameters:
  • lun – Medium index.

  • ec – [out] Set on failure: MSC not enabled / not initialized, bad index, the application does not own the medium (`operation_not_permitted`), a filesystem already exists (`file_exists`), every FatFs drive slot is in use (`device_or_resource_busy`), or formatting failed (`io_error`).

Returns:

true if the medium was formatted.

void set_msc_event_callback(const msc_event_callback_fn &cb)

Set or replace the MSC storage event callback (nullptr to detach).

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

Attach to the bus (enable the D+ pull-up) so a host can enumerate the device. Only needed after Config::connect_on_initialize = false or a disconnect().

Returns:

false if not initialized.

bool disconnect()

Detach from the bus (disable the D+ pull-up): the host sees the device unplugged.

Returns:

false if not initialized.

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.

uint16_t bcd_device = {0x0100}

bcdDevice (device release, BCD) in the device descriptor. Ignored for an XInput-only device (which reports the Xbox 360 value).

uint16_t max_power_ma = {100}

bMaxPower in the configuration descriptor, in mA; clamped to 500 and rounded up to the next 2 mA unit.

bool remote_wakeup = {true}

Advertise remote wakeup in the configuration attributes.

bool connect_on_initialize = {true}

Attach to the bus (enable the D+ pull-up) at the end of initialize(). Set false to stay invisible to the host until connect() &#8212; e.g. to finish application file I/O on an MSC medium before a host can take it.

int port = {-1}

USB peripheral port to use, as esp_tinyusb’s `tinyusb_port_t` (0 = the USB-OTG 1.1 full-speed port, 1 = the USB-OTG 2.0 high-speed port on targets that have one). -1 = TinyUSB’s default for the target: the high-speed port on the ESP32-P4, the full-speed port elsewhere. Boards do not always route the high-speed port to a device-capable connector (the M5Stack Tab5 wires it to its USB-A host jack; its USB-C carries the full-speed port, shared with the USB-Serial-JTAG console), so this lets the application pick the connector.

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 = {}

Enable an MSC (mass storage) 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 MscCapacity

Size of an MSC medium.

Public Functions

inline uint64_t bytes() const

Total size in bytes.

Public Members

uint32_t sector_count = {0}

Number of sectors.

uint32_t sector_size = {0}

Bytes per sector.

struct MscFunction

MSC (USB mass storage) function: exposes up to two media as USB drives.

Consumes 1 bulk IN + 1 bulk OUT endpoint. Built on esp_tinyusb’s MSC storage backend, which provides the SCSI handling, so it requires `CONFIG_TINYUSB_MSC_ENABLED=y`; a flash partition additionally needs `CONFIG_TINYUSB_MSC_BUFSIZE >= CONFIG_WL_SECTOR_SIZE`. Prefer 4096-byte wear levelling sectors with a 4096-byte MSC buffer: each host write is then one flash erase + write, while 512-byte sectors need a read-modify-erase of the 4 KiB block per sector (slow in the power-safe mode, and `CONFIG_WL_SECTOR_MODE_PERF` loses the block on a reset mid-erase). SD cards are written directly, with no wear-levelling layer.

Ownership: with `auto_handover` (the default) the media move to the host when the host mounts (configures) the device, and back to the application when the host ejects a drive or the device is detached. The hand-over is for ALL media at once: esp_tinyusb ignores which drive was ejected, so ejecting either one returns both to the application (the other drive disappears from the host too). Turn it off to decide yourself with set_msc_owner() (e.g. only expose the card while a “USB drive mode” screen is shown). Either way, never let the application and the host write the same volume at once &#8212; that is what the ownership model prevents.

Public Members

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

MSC interface string descriptor.

std::vector<MscMedium> media = {}

One or two media (LUN 0, LUN 1).

bool auto_handover = {true}

Host takes all media on mount; the app gets all of them back on any eject / detach.

msc_event_callback_fn on_event = {nullptr}

Optional storage event callback.

struct MscMedium

One medium exposed by the MSC function (one LUN).

The host only understands FAT, so the medium carries a FAT volume: an SD card, or a FAT data partition in flash (accessed through wear levelling). esp_tinyusb supports at most one medium of each type.

Public Types

enum class Type : uint8_t

The kind of storage behind this LUN.

Values:

enumerator SdCard

An already-initialized SD/MMC card (SDMMC or SDSPI host): `sd_card`.

enumerator FlashPartition

A FAT data partition in flash, by label: `partition_label`.

Public Members

Type type = {Type::FlashPartition}

Which storage backs this LUN.

sdmmc_card_t *sd_card = {nullptr}

For Type::SdCard: a caller-owned card initialized with sdmmc_card_init() on an SDMMC or SDSPI host, e.g. espp::SdCard::card() with its volume unmounted. Must outlive the UsbDevice. Do not pass the card from esp_vfs_fat_sdmmc_mount() / esp_vfs_fat_sdspi_mount(): the matching esp_vfs_fat_sdcard_unmount() frees it. Requires a target with an SDMMC host peripheral (e.g. ESP32-S3, ESP32-P4), even when the card is on SPI.

std::string partition_label = {"storage"}

For Type::FlashPartition: label of a `data, fat` partition. The device mounts wear levelling on it and unmounts it on destruction.

std::string base_path = {"/msc"}

VFS path where the application sees the files while it owns the medium. Must be unique per medium, and must not already be mounted by the app (unmount your own esp_vfs_fat mount of the card first).

int max_files = {5}

Files the application may keep open at once.

std::string volume_label = {}

FAT volume label: the name the host shows for the drive (up to 11 characters; FAT stores it upper-case). Written at initialize() and after format_msc_medium() when it differs from the medium’s current label. Empty = leave the label alone. Requires CONFIG_FATFS_USE_LABEL=y.

bool format_if_unformatted = {false}

Format the medium as FAT when it is handed to the application and has no filesystem. Off by default: an unformatted medium raises MscEvent::FormatRequired instead.

Warning

esp_tinyusb formats FatFs drive 0 rather than this medium’s own drive. Only enable this (or call format_msc_medium()) when no other FAT volume is mounted on the device, or it may format that volume instead.

MscOwner initial_owner = {MscOwner::App}

Owner right after initialize(). With auto_handover a host that is (or becomes) connected takes the medium when it mounts the device.

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

UsbCdc is the CDC-only subset of espp::UsbDevice; the composite USB device example below shows the CDC interface (a framed-protocol link over Web Serial) next to the vendor and MSC interfaces.

  logger.info("System:\n{}", espp::SystemInfo::to_string());

  // The OTA and core-dump engines, shared by the per-transport services below.
  // A panic core-dumps to the `coredump` partition (partitions.csv) and is
  // reported here on the next boot; the coredump console downloads / erases
  // it. OTA alternates between ota_0 / ota_1 with host-driven rollback
  // confirmation, exactly as the ota example does.
  espp::CoreDump core_dump({.log_level = espp::Logger::Verbosity::INFO});
  const std::string crash_report = core_dump.format_report();
  if (crash_report.empty())
    logger.info("Clean boot history (reset reason: {})",
                espp::CoreDump::reset_reason_name(espp::CoreDump::reset_reason()));
  else
    logger.error("Previous abnormal reset:\n{}", crash_report);
  espp::Ota ota({.reject_same_version = false, .log_level = espp::Logger::Verbosity::INFO});
  if (ota.is_pending_verify())
    logger.warn("This image is PENDING VERIFY (first boot after an OTA update): it rolls back on "
                "the next reset unless the host confirms it (MARK_VALID from the OTA console)");

  // --- the composite USB device: CDC + vendor (WebUSB) + MSC ------------------
  espp::UsbDevice::Config usb_cfg;
  usb_cfg.pid = 0x0d38; // distinct from the other espp examples so a host filter is specific
  usb_cfg.manufacturer = "espp";
  usb_cfg.product = "espp USB Device";
  usb_cfg.connect_on_initialize = false; // write the drive's README before the host can mount it
  usb_cfg.log_level = espp::Logger::Verbosity::WARN;
  espp::UsbDevice::CdcFunction cdc;
  cdc.interface_name = "espp USB Device (CDC)";
  usb_cfg.cdc = cdc;
  espp::UsbDevice::VendorFunction vendor;
  vendor.interface_name = "espp USB Device (WebUSB)";
  vendor.webusb = true; // advertise BOS / WebUSB / MS OS 2.0 descriptors
  vendor.landing_page_url = "esp-cpp.github.io/espp/apps/system_console.html";
  usb_cfg.vendor = vendor;
  // MSC: the `storage` FAT partition, owned by the application first (so it
  // can write the README), then handed to the host when it mounts the device
  // (auto_handover); ejecting the drive gives it back to the application.
  using MscOwner = espp::UsbDevice::MscOwner;
  using MscEvent = espp::UsbDevice::MscEvent;
  std::atomic<bool> drive_regained{false};
  espp::UsbDevice::MscMedium medium;
  medium.type = espp::UsbDevice::MscMedium::Type::FlashPartition;
  medium.partition_label = "storage"; // `data, fat` partition in partitions.csv
  medium.base_path = kMscBasePath;
  medium.volume_label = "ESPP USB";    // the name the host shows for the drive
  medium.format_if_unformatted = true; // the only FAT volume on this device
  medium.initial_owner = MscOwner::App;
  espp::UsbDevice::MscFunction msc;
  msc.interface_name = "espp USB Device (MSC)";
  msc.media = {medium};
  msc.auto_handover = true;
  msc.on_event = [&](size_t lun, MscEvent event, MscOwner owner) {
    if (event == MscEvent::OwnerChanged) {
      logger.info("drive {} now owned by the {}", lun, owner == MscOwner::App ? "app" : "host");
      if (owner == MscOwner::App)
        drive_regained = true;
    } else if (event == MscEvent::FormatRequired) {
      logger.warn("drive {} has no filesystem", lun);
    } else if (event == MscEvent::FormatFailed || event == MscEvent::OwnerChangeFailed) {
      logger.error("drive {}: {}", lun,
                   event == MscEvent::FormatFailed ? "format failed" : "hand-over failed");
    }
  };
  usb_cfg.msc = msc;
  espp::UsbDevice usb(usb_cfg);

  // Replies go back on the stream the request came in on: one send function
  // per transport. All services share each transport and only serialize their
  // OWN frames (a streamed monitor event and a system reply come from
  // different tasks), so every device->host write on a transport goes through
  // one application-level mutex; write_vendor / write_cdc are all-or-nothing
  // per call, so a frame is never truncated or interleaved.
  std::mutex vendor_tx_mutex, cdc_tx_mutex;
  auto vendor_send = [&](std::span<const uint8_t> frame) {
    std::lock_guard<std::mutex> lock(vendor_tx_mutex);
    usb.write_vendor(frame);
  };
  auto cdc_send = [&](std::span<const uint8_t> frame) {
    std::lock_guard<std::mutex> lock(cdc_tx_mutex);
    usb.write_cdc(frame);
  };

  // The application decides whether a reboot may happen right now: this demo
  // permits every request and logs it. A real application would refuse (or
  // defer) while, say, the host is writing to the drive.
  auto reboot_request = [&](espp::SystemService::RebootKind kind) {
    logger.warn("Host requested a {}; allowing it",
                kind == espp::SystemService::RebootKind::Bootloader ? "reboot into the bootloader"
                                                                    : "reboot");
    return true;
  };

  // One service instance per transport (they are cheap; each replies on its
  // own stream). Declared BEFORE the workers that call into them.
  espp::SystemService vendor_system({.send = vendor_send,
                                     .on_reboot_request = reboot_request,
                                     .log_level = espp::Logger::Verbosity::INFO});
  espp::SystemService cdc_system({.send = cdc_send,
                                  .on_reboot_request = reboot_request,
                                  .log_level = espp::Logger::Verbosity::INFO});
  espp::MonitorService vendor_monitor(
      {.send = vendor_send,
       .task_config = {.name = "monitor_v", .stack_size_bytes = 6 * 1024},
       .log_level = espp::Logger::Verbosity::INFO});
  espp::MonitorService cdc_monitor(
      {.send = cdc_send,
       .task_config = {.name = "monitor_c", .stack_size_bytes = 6 * 1024},
       .log_level = espp::Logger::Verbosity::INFO});
  // OTA and core dump: one service per transport over the shared engines (each
  // OtaService only touches the session IT began, so the two cannot interfere).
  espp::OtaService vendor_ota(ota,
                              {.send = vendor_send, .log_level = espp::Logger::Verbosity::INFO});
  espp::OtaService cdc_ota(ota, {.send = cdc_send, .log_level = espp::Logger::Verbosity::INFO});
  espp::CoreDumpService vendor_coredump(
      core_dump, {.send = vendor_send, .log_level = espp::Logger::Verbosity::INFO});
  espp::CoreDumpService cdc_coredump(
      core_dump, {.send = cdc_send, .log_level = espp::Logger::Verbosity::INFO});

  // One DispatcherWorker per byte stream, so the services never run on the
  // TinyUSB task. Registering a service routes its module to it and advertises
  // it for discovery; serve_discovery() answers the hub's ListModules query.
  // On an RX overflow the OTA service aborts a transfer it owned and tells the
  // host (an OTA image with dropped bytes is unusable).
  espp::DispatcherWorker vendor_link(
      {.send = vendor_send,
       .on_overflow = [&]() { vendor_ota.on_rx_overflow(); },
       .task_config = {.name = "usb_rx_vendor", .stack_size_bytes = 8192}});
  espp::DispatcherWorker cdc_link(
      {.send = cdc_send,
       .on_overflow = [&]() { cdc_ota.on_rx_overflow(); },
       .task_config = {.name = "usb_rx_cdc", .stack_size_bytes = 8192}});
  vendor_link.register_module(vendor_system);
  vendor_link.register_module(vendor_monitor);
  vendor_link.register_module(vendor_ota);
  vendor_link.register_module(vendor_coredump);
  cdc_link.register_module(cdc_system);
  cdc_link.register_module(cdc_monitor);
  cdc_link.register_module(cdc_ota);
  cdc_link.register_module(cdc_coredump);
  vendor_link.serve_discovery(usb_cfg.product);
  cdc_link.serve_discovery(usb_cfg.product);

  // RX plumbing: the TinyUSB callbacks just queue the bytes for the workers.
  usb.set_vendor_receive_callback([&](std::span<const uint8_t> data) { vendor_link.push(data); });
  usb.set_cdc_receive_callback([&](std::span<const uint8_t> data) { cdc_link.push(data); });

  std::error_code usb_ec;
  if (!usb.initialize(usb_ec)) {
    logger.error("Failed to initialize USB device: {}", usb_ec.message());
    return;
  }
  // The drive is ours until the host mounts the device: describe it, then
  // let the host in.
  if (const auto capacity = usb.msc_capacity(0))
    logger.info("drive: {} sectors x {} bytes = {} KiB", capacity->sector_count,
                capacity->sector_size, capacity->bytes() / 1024);
  if (usb.msc_owner(0) == MscOwner::App)
    write_drive_readme(logger);
  usb.connect();

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