feat(usb_device): composable native-USB device (CDC + vendor/WebUSB, esp_tinyusb) - #720
Conversation
…i example Add espp::UsbCdc, a thin idiomatic wrapper around ESP-IDF's esp_tinyusb managed component that presents a dedicated native USB CDC-ACM interface on the ESP32-S3/-S2/-P4 USB-OTG peripheral with a configurable VID/PID and manufacturer/product/serial strings, separate from the log console. The example wires espp::UsbCdc RX -> espp::OdriveAscii::process_bytes -> espp::UsbCdc::write so the device enumerates as an ODrive-like serial port while the log console stays on USB-Serial-JTAG. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…evice Generalize the usb_device component from a single hard-coded CDC-ACM transport into espp::UsbDevice, which assembles a native USB device from a set of selectable functions (CDC and/or vendor-specific) with interface numbers, endpoint addresses and string indices allocated sequentially and checked against the ESP32-S3 USB-OTG endpoint budget. - Add a vendor-specific interface (bInterfaceClass 0xFF, bulk IN + bulk OUT) carrying a raw byte stream, plus WebUSB + MS OS 2.0 descriptors (BOS, URL descriptor, MS-OS-2.0 set) so browsers/Windows bind driverlessly. - Vendor class enabled via CONFIG_TINYUSB_VENDOR_COUNT>0 (CFG_TUD_VENDOR); all tud_vendor_* paths are #if-guarded so CDC-only builds still link. - Keep espp::UsbCdc as a thin CDC-only preset over UsbDevice (back-compat). - Reserve HID/MSC extension points and document the endpoint budget table. - Update example to a composite CDC + Vendor/WebUSB device feeding one OdriveAscii; docs (README, rst, Doxyfile) updated. esp32s3 build passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
✅Static analysis result - no issues found! ✅ |
There was a problem hiding this comment.
Pull request overview
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Adds a new usb_device component providing a composable native USB device for ESP32-S3/-S2/-P4 (CDC-ACM + optional vendor/WebUSB), plus documentation, an example project, and CI wiring.
Changes:
- Introduces
espp::UsbDevicewith descriptor building, endpoint budgeting, CDC + vendor/WebUSB support, and a CDC-onlyespp::UsbCdcpreset. - Adds an ESP-IDF example showcasing composite CDC + vendor/WebUSB wired to
OdriveAscii. - Integrates component docs/Doxygen inputs and updates CI to build and publish the new component.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| doc/en/buses/usb_cdc_example.md | Includes the usb_device example README into the docs site. |
| doc/en/buses/usb_cdc.rst | Adds user-facing documentation for UsbDevice/UsbCdc usage and constraints. |
| doc/en/buses/index.rst | Adds the new USB CDC/Device page to the buses index. |
| doc/Doxyfile | Adds new headers and example source to Doxygen input paths. |
| components/usb_device/src/usb_device.cpp | Implements UsbDevice, descriptor construction, callbacks, and IO paths. |
| components/usb_device/src/usb_cdc.cpp | Implements UsbCdc as a CDC-only thin wrapper over UsbDevice. |
| components/usb_device/include/usb_device.hpp | Public API for composable USB device + function configs. |
| components/usb_device/include/usb_cdc.hpp | Public API for the CDC-only preset transport. |
| components/usb_device/idf_component.yml | Registers the new component for the IDF component manager. |
| components/usb_device/example/sdkconfig.defaults.esp32s3 | Example target-specific console configuration. |
| components/usb_device/example/sdkconfig.defaults | Enables TinyUSB CDC and vendor class in the example. |
| components/usb_device/example/main/usb_cdc_example.cpp | Example app demonstrating composite CDC + vendor/WebUSB. |
| components/usb_device/example/main/idf_component.yml | Example dependency overrides to use in-repo espp components. |
| components/usb_device/example/main/CMakeLists.txt | Registers the example “main” component and dependencies. |
| components/usb_device/example/README.md | Example documentation and usage instructions. |
| components/usb_device/example/CMakeLists.txt | Example project configuration (component selection + dirs). |
| components/usb_device/README.md | Component README (features, API, endpoint budgeting, notes). |
| components/usb_device/CMakeLists.txt | Component build registration (include/src/requirements). |
| .github/workflows/upload_components.yml | Adds usb_device to the upload/publish workflow. |
| .github/workflows/build.yml | Adds usb_device example to the CI build matrix. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.
Suppressed comments (5)
components/usb_device/src/usb_device.cpp:69
- There is a race window where TinyUSB task callbacks can still observe
s_device == thiswhile teardown is in progress (until aftertinyusb_driver_uninstall()returns). Clearings_devicebefore beginning deinit/uninstall (and ideally preventing further callback dispatch) reduces the chance of a callback calling into an object that is being destructed.
UsbDevice::~UsbDevice() {
if (initialized_) {
if (config_.cdc)
tinyusb_cdcacm_deinit(kCdcPort);
tinyusb_driver_uninstall();
if (s_device == this)
s_device = nullptr;
initialized_ = false;
}
}
components/usb_device/src/usb_device.cpp:138
- The vendor control handler responds solely based on
bRequest(and for MS OS 2.0 onlywIndex == 7). For WebUSB and MS OS 2.0, the spec-defined fields (notablywIndex/wValue) should be validated so you only respond to the exact expected requests (e.g., WebUSB GET_URL and a specific URL index). Without this, unrelated vendor requests using the same vendor code can be incorrectly answered, and hosts may observe non-compliant behavior.
switch (request->bmRequestType_bit.type) {
case TUSB_REQ_TYPE_VENDOR:
if (request->bRequest == vendor.webusb_vendor_code) {
// Return the WebUSB landing-page URL descriptor.
uint8_t len = 0;
const uint8_t *url = s_device->webusb_url_descriptor(len);
if (!url)
return false;
return tud_control_xfer(rhport, request, (void *)(uintptr_t)url, len);
}
if (request->bRequest == vendor.ms_os_vendor_code && request->wIndex == 7) {
// Return the MS OS 2.0 descriptor set.
uint16_t total_len = 0;
const uint8_t *ms = s_device->ms_os_20_descriptor(total_len);
if (!ms)
return false;
return tud_control_xfer(rhport, request, (void *)(uintptr_t)ms, total_len);
}
return false;
components/usb_device/src/usb_device.cpp:377
- The WebUSB URL descriptor length is stored into a
uint8_tand can overflow iflanding_page_url.size()is large (producing an invalid descriptor). Consider validatinglanding_page_urllength duringinitialize()and failing with a clearstd::error_code(e.g.,invalid_argument) when it can’t fit in a single descriptor.
// WebUSB URL descriptor: bLength, bDescriptorType(3), bScheme, url...
impl_->webusb_url_desc.clear();
impl_->webusb_url_desc.push_back(static_cast<uint8_t>(3 + v.landing_page_url.size()));
impl_->webusb_url_desc.push_back(3); // WEBUSB URL descriptor type
impl_->webusb_url_desc.push_back(v.url_scheme);
impl_->webusb_url_desc.insert(impl_->webusb_url_desc.end(), v.landing_page_url.begin(),
v.landing_page_url.end());
components/usb_device/src/usb_device.cpp:204
- Both RX handlers allocate a new
std::vectorbuffer on every callback invocation. Since these callbacks run in the TinyUSB device task and can be high-frequency, repeated heap allocation can add latency and fragmentation. Consider keeping a reusable buffer as a member (or per-function buffer inImpl) sized torx_chunk_size, and resizing only when the configured chunk size changes.
void UsbDevice::handle_cdc_rx() {
receive_callback_fn cb;
{
std::scoped_lock lk(cb_mutex_);
cb = on_cdc_receive_;
}
if (!cb || !config_.cdc)
return;
std::vector<uint8_t> buf(config_.cdc->rx_chunk_size);
size_t rx_size = 0;
do {
rx_size = 0;
esp_err_t err = tinyusb_cdcacm_read(kCdcPort, buf.data(), buf.size(), &rx_size);
if (err != ESP_OK) {
logger_.error("CDC read error: {}", esp_err_to_name(err));
break;
}
if (rx_size > 0)
cb(std::span<const uint8_t>(buf.data(), rx_size));
} while (rx_size == buf.size());
}
components/usb_device/include/usb_device.hpp:97
- The
url_schemedoc comment claims255 = URL includes its own scheme, but the WebUSB URL descriptor scheme field is typically restricted to the defined scheme codes (commonly 0=http, 1=https) and does not embed an arbitrary scheme string. If you intend to support nonstandard values, it would be good to document the expected host behavior; otherwise, constrain/validateurl_schemeand update the comment to match the spec.
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. */
…eview fixes Implement the HID function using TinyUSB's HID class driver: store the application-supplied report descriptor, provide the tud_hid_* weak-callback overrides, allocate the interrupt IN (+ optional OUT) endpoint and interface, append TUD_HID_DESCRIPTOR to the config descriptor, include HID in the endpoint-budget accounting, and add write_hid_report()/is_hid_ready(). All tud_hid_* usage is gated behind CFG_TUD_HID so HID-less builds still link. Exercise it in the example as a composite CDC + vendor + HID gamepad: build the report descriptor from espp::GamepadInputReport (hid-rp) and animate axes + buttons, sending input reports at ~10 Hz. Example-manifest cleanup: move esp_tinyusb into the usb_device component's idf_component.yml, delete the example main manifest, and resolve espp deps via EXTRA_COMPONENT_DIRS (no override_path). PR #720 review fixes: device descriptor uses MISC/IAD only when CDC is enabled (else 0x00/0x00/0x00); validate WebUSB URL length fits a uint8_t; clear s_device before driver teardown; allocation-free CDC/vendor RX via preallocated buffers; clarify landing_page_url / url_scheme=255 docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ath) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… as the default WebUSB landing page
Add components/usb_device/web/board_console.html: a general-purpose,
single-file browser tool for any espp/ESP board.
- Serial monitor over the Web Serial API: connect/disconnect with
selectable baud, live RX view (robust to binary), TX with CR/LF/none
line ending + command history, autoscroll/pause/clear/save-log, and
Reset / Enter-bootloader buttons (DTR/RTS control signals).
- ESP flasher using Espressif's official esptool-js: per-binary rows
(file + hex offset), erase-all + flashing-baud options, chip/MAC/flash
detection, per-file progress, and a hard reset when done.
- Theme-aware (light/dark + manual toggle), responsive, no third-party
runtime dependency: esptool-js is vendored same-origin as
esptool-bundle.js (esptool-js 0.5.7, Apache-2.0, (c) Espressif).
- README documents usage, port choice, flash offsets, and attribution.
Host it + make it the default landing page:
- build_and_publish_docs.yml copies components/usb_device/web/*.{html,js}
into docs/apps/ (nullglob), alongside the other hosted browser apps.
- usb_device.hpp: default VendorFunction::landing_page_url now points at
esp-cpp.github.io/espp/apps/board_console.html (doc comment updated).
Also bump esp_tinyusb dependency to >=2.0 (code uses the 2.x API;
resolves to 2.2.1) and drop the registry-invalid `CDC-ACM` tag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 23 changed files in this pull request and generated 2 comments.
Suppressed comments (6)
components/usb_device/src/usb_device.cpp:458
- When HID is enabled,
report_lencan be 0 (empty report descriptor) andpoll_interval_mscan be 0, both of which can produce invalid/undesirable HID descriptors at enumeration time. Recommended: duringinitialize(), reject HID configs with an emptyreport_descriptorand enforcepoll_interval_msin the valid USB range (1..255), returningstd::errc::invalid_argumenton failure.
if (config_.hid) {
const uint16_t report_len = static_cast<uint16_t>(impl_->hid_report_desc.size());
const uint8_t poll = config_.hid->poll_interval_ms;
// Interrupt endpoints are full-speed (<=64 byte packets) even on high-speed
// parts; a 64-byte endpoint buffer comfortably fits the gamepad report.
constexpr uint8_t kHidEpSize = 64;
components/usb_device/src/usb_device.cpp:16
s_deviceis accessed from TinyUSB callbacks (TinyUSB task context) and is also written frominitialize()/destructor, but it is a plain pointer with no synchronization. On dual-core targets this is a data race (undefined behavior) even if the intent is just to avoid UAF. Recommended: makes_deviceastd::atomic<espp::UsbDevice*>(or an IDF/FreeRTOS-safe atomic), use atomic loads in callbacks, and atomic stores during init/deinit.
espp::UsbDevice *s_device = nullptr;
components/usb_device/src/usb_device.cpp:71
s_deviceis accessed from TinyUSB callbacks (TinyUSB task context) and is also written frominitialize()/destructor, but it is a plain pointer with no synchronization. On dual-core targets this is a data race (undefined behavior) even if the intent is just to avoid UAF. Recommended: makes_deviceastd::atomic<espp::UsbDevice*>(or an IDF/FreeRTOS-safe atomic), use atomic loads in callbacks, and atomic stores during init/deinit.
if (s_device == this)
s_device = nullptr;
components/usb_device/src/usb_device.cpp:691
s_deviceis accessed from TinyUSB callbacks (TinyUSB task context) and is also written frominitialize()/destructor, but it is a plain pointer with no synchronization. On dual-core targets this is a data race (undefined behavior) even if the intent is just to avoid UAF. Recommended: makes_deviceastd::atomic<espp::UsbDevice*>(or an IDF/FreeRTOS-safe atomic), use atomic loads in callbacks, and atomic stores during init/deinit.
s_device = this;
components/usb_device/src/usb_device.cpp:142
- Control transfers should respect
request->wLength(host-requested length). The current implementation always useslen/total_lenas the transfer length, which can be larger thanwLengthfor some hosts and requests. Recommended: clamp the transmitted length tomin(descriptor_len, request->wLength)for both WebUSB URL and MS OS 2.0 descriptor responses. Also consider validating the URL request'swIndex(landing-page index) to avoid responding with the wrong descriptor if the host queries a different index.
if (request->bRequest == vendor.webusb_vendor_code) {
// Return the WebUSB landing-page URL descriptor.
uint8_t len = 0;
const uint8_t *url = s_device->webusb_url_descriptor(len);
if (!url)
return false;
return tud_control_xfer(rhport, request, (void *)(uintptr_t)url, len);
}
if (request->bRequest == vendor.ms_os_vendor_code && request->wIndex == 7) {
// Return the MS OS 2.0 descriptor set.
uint16_t total_len = 0;
const uint8_t *ms = s_device->ms_os_20_descriptor(total_len);
if (!ms)
return false;
return tud_control_xfer(rhport, request, (void *)(uintptr_t)ms, total_len);
}
components/usb_device/src/usb_device.cpp:437
- The configuration descriptor advertises
REMOTE_WAKEUP. If the device does not actually support/implement remote wakeup behavior, advertising this capability can be misleading to hosts. Recommended: either (a) switch the attribute to omit remote wakeup unless explicitly enabled by configuration, or (b) document/ensure the stack supports remote wakeup end-to-end for this device.
TUD_CONFIG_DESCRIPTOR(1, itf_count, 0, total_len, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100),
- tud_vendor_rx_cb: the TinyUSB zero-copy RX variant (CFG_TUD_VENDOR_RX_BUFSIZE==0) delivers bytes via the callback buffer, not the FIFO. Dispatch those directly instead of discarding them and reading an empty FIFO; the FIFO variant (the esp_tinyusb default, buffer==NULL) is unchanged. - initialize(): reject rx_chunk_size==0 for the CDC/vendor functions (invalid_argument). A zero-length RX buffer spins handle_cdc_rx()'s drain loop (while (0 == 0)) and stalls vendor reads. Addresses PR #720 review (usb_device.cpp:112, :319). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ndex, FIFO drain, retry errc - s_device is now std::atomic<UsbDevice*> and every TinyUSB callback loads it ONCE into a local (fixes the cross-thread data race and the check-then-use TOCTOU against teardown); initialized_ likewise atomic. - WebUSB URL control branch now requires wIndex == 2 (WEBUSB_REQUEST_GET_URL) so it cannot shadow the MS-OS-2.0 request if the two vendor codes are configured equal. - handle_cdc_rx/handle_vendor_rx drain (and discard) the RX FIFO even when no receive callback is attached, so clearing the callback at runtime can no longer back-pressure/stall the host. - write_hid_report distinguishes transient backpressure (resource_unavailable_try_again when mounted but a report is in flight) from a real disconnect (not_connected via tud_mounted()). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… PR branches Bring this integration branch's component copies up to the re-review fixes landed on the source PRs (strict/typed-setter parsing + case-insensitive bool in odrive_ascii; atomic instance routing, WebUSB wIndex guard, FIFO drain, retry errc, zero-copy vendor RX, board console + esp_tinyusb >=2.0 in usb_device). The usb_device example (this PR's delta) is untouched. esp32s3 integration example builds clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 23 changed files in this pull request and generated 2 comments.
Suppressed comments (13)
components/usb_device/include/usb_device.hpp:104
- The default WebUSB landing page cannot communicate with this vendor function:
board_console.htmlonly detects and callsnavigator.serialand explicitly directs users to a USB-Serial-JTAG/UART port. Thus clicking the advertised WebUSB landing page cannot open the vendor bulk endpoints as documented. Either add anavigator.usbtransport for this VID/PID/interface or point the default at an actual WebUSB console.
std::string landing_page_url{"esp-cpp.github.io/espp/apps/board_console.html"};
components/usb_device/src/usb_device.cpp:498
- An enabled HID function can reach this cast with the default empty report descriptor, or with a descriptor larger than 65535 bytes. Both produce an invalid/truncated HID descriptor while
initialize()still succeeds;poll_interval_ms == 0likewise emits an invalid interrupt endpoint. Reject these configurations before appending the descriptor.
const uint16_t report_len = static_cast<uint16_t>(impl_->hid_report_desc.size());
const uint8_t poll = config_.hid->poll_interval_ms;
components/usb_device/web/board_console.html:402
- Pausing bypasses the DOM backlog limit and appends all incoming serial data to one unbounded string. At the supported high baud rates, leaving the monitor paused can consume memory indefinitely, and repeated string concatenation becomes increasingly expensive until the tab stalls. Apply a bounded backlog (dropping the oldest data) just as the live monitor does.
if (paused && kind === "rx") { pausedBuffer += text; return; }
components/usb_device/idf_component.yml:25
- This component is documented and implemented only for ESP32-S2/S3/P4 USB-OTG targets, but the registry manifest currently advertises it for every ESP-IDF target. Add a target constraint so unsupported projects fail dependency resolution clearly rather than attempting to compile target-specific TinyUSB code.
espressif/esp_tinyusb: '>=2.0'
components/usb_device/web/README.md:69
- The vendored esptool-js bundle is Apache-2.0 code, but this repository only contains its MIT root license and the bundle contains no Apache license text; a link and attribution in this README do not include the license copy required for redistribution. Add the upstream Apache-2.0 license (and any upstream NOTICE, if present) as third-party licensing material alongside the bundle.
The flashing feature is powered by **esptool-js**
(<https://github.com/espressif/esptool-js>), © Espressif Systems, licensed under
the **Apache License 2.0**. To keep this app free of any runtime third-party CDN
dependency, esptool-js is **vendored same-origin**: the published bundled build
is committed here verbatim as `esptool-bundle.js` and imported by
doc/Doxyfile:154
- New
EXAMPLE_PATHentries must remain alphabetically ordered.usb_deviceis currently betweenodrive_asciiandpca9535; move it aftertt21100and beforevl53l.
$(PROJECT_PATH)/components/usb_device/example/main/usb_cdc_example.cpp \
doc/Doxyfile:364
- New Doxygen
INPUTentries must remain alphabetically ordered. Move this component aftertt21100and beforeutils/vl53l, and order its headers asusb_cdc.hppthenusb_device.hpp.
$(PROJECT_PATH)/components/usb_device/include/usb_device.hpp \
$(PROJECT_PATH)/components/usb_device/include/usb_cdc.hpp \
.github/workflows/build.yml:214
- The CI example matrix is maintained alphabetically. Move the
usb_deviceentry aftertt21100and beforevl53lrather than placing it betweenodrive_asciiandpca9535.
- path: 'components/usb_device/example'
target: esp32s3
components/usb_device/example/README.md:32
- The example now enables and initializes HID, but this copyable configuration block omits
CONFIG_TINYUSB_HID_COUNT=1. A user following it will build withoutCFG_TUD_HIDand the example will fail initialization withfunction_not_supported. Document all three required classes here.
The example's `sdkconfig.defaults` enables both the CDC and vendor classes:
components/usb_device/src/usb_device.cpp:854
- The public method accepts an arbitrary-size span, but this narrowing cast silently wraps lengths above 65535; TinyUSB then queues only the truncated length while this method may return success. Reject oversized reports with
value_too_largebefore callingtud_hid_report.
if (!tud_hid_report(report_id, report.data(), static_cast<uint16_t>(report.size()))) {
components/usb_device/src/usb_device.cpp:536
url_schemeis copied directly into the WebUSB descriptor even though the public contract permits only 0, 1, or 255. Any other configurable value creates a malformed URL descriptor whileinitialize()reports success. Validate the enum value before constructing the descriptor.
impl_->webusb_url_desc.push_back(static_cast<uint8_t>(3 + v.landing_page_url.size()));
impl_->webusb_url_desc.push_back(3); // WEBUSB URL descriptor type
impl_->webusb_url_desc.push_back(v.url_scheme);
components/usb_device/src/usb_device.cpp:200
has_out_endpoint=trueadvertises a HID interrupt-OUT endpoint, but every OUT report delivered here is silently discarded and there is no HID receive callback in the public API. Consumers enabling it for output reports will see successful USB transfers with no application-visible data. Add an output-report callback and dispatch to it, or remove/reject the option until supported.
void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t report_type,
uint8_t const *buffer, uint16_t bufsize) {
(void)instance;
(void)report_id;
(void)report_type;
components/usb_device/README.md:40
- The generated table of contents does not match the headings below: it omits “Enabling the HID class” and links to a nonexistent “Extending with HID / MSC” heading. Update these entries so both sections are reachable.
- [Enabling the vendor / WebUSB class](#enabling-the-vendor--webusb-class)
- [Endpoint budget (ESP32-S3 USB-OTG)](#endpoint-budget-esp32-s3-usb-otg)
- [Extending with HID / MSC](#extending-with-hid--msc)
- [Example](#example)
…set validation
- On high-speed builds (TUD_OPT_HIGH_SPEED, e.g. ESP32-P4) the single config
descriptor with 512-byte bulk endpoints was installed for BOTH speeds,
making the full-speed configuration invalid, and no device qualifier was
provided. Build a 64-byte-bulk FS descriptor and a 512-byte-bulk HS
descriptor, encode the HID bInterval as 2^(n-1) x 125us microframes for HS
(choosing the largest exponent not slower than the requested ms), and
install a device qualifier derived from the device descriptor. FS-only
targets (S3/S2) are unchanged.
- board_console: validate the WHOLE flash-offset string as hex before
parseInt ("0x10000oops" no longer flashes at 0x10000) — flashing is
destructive, so trailing garbage is now rejected with a clear error.
Addresses PR #720 review (usb_device.cpp:730, board_console.html:668).
esp32s3 example builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # .github/workflows/build_and_publish_docs.yml # .github/workflows/upload_components.yml # doc/en/buses/index.rst
…ches #720) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (7)
components/usb_device/example/README.md:32
- The example firmware also enables/uses HID (
CONFIG_TINYUSB_HID_COUNT=1insdkconfig.defaults, and the example code adds a HID gamepad function), but the README only mentions CDC+vendor. Update this section to include HID enablement (and any relevant usage note) so the documented requirements match the shipped example.
The example's `sdkconfig.defaults` enables both the CDC and vendor classes:
components/usb_device/include/usb_device.hpp:104
- The PR description and docs refer to a default "WebUSB console" landing page, but the default URL points to
board_console.html, which (per its own README) is a Web Serial tool intended for USB-Serial-JTAG/UART ports and explicitly advises against connecting to a firmware-created CDC port. Either (a) provide a WebUSB-based landing page that actually talks to the vendor/WebUSB interface, or (b) adjust the PR/docs wording and defaults so the advertised landing page behavior matches what the hosted app supports.
* 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.
*/
std::string landing_page_url{"esp-cpp.github.io/espp/apps/board_console.html"};
components/usb_device/src/usb_device.cpp:526
- When HID is enabled,
poll_interval_mscan currently be set to 0, which would generate an invalid HID interrupt endpointbIntervalat full speed and can break enumeration on some hosts. Consider validatingconfig_.hid->poll_interval_ms >= 1duringinitialize()(similar to the existingrx_chunk_sizechecks) and fail withstd::errc::invalid_argumentif it’s 0.
const uint8_t hid_poll_ms = config_.hid ? config_.hid->poll_interval_ms : 0;
// Full-speed configuration: 64-byte bulk endpoints, bInterval in ms frames.
build_config_desc(impl_->config_desc, 64, hid_poll_ms);
components/usb_device/src/usb_device.cpp:55
- The LANGID entry is typically represented as a
uint16_tarray (e.g.{0x0409}) and passed via the pointer table. Storing it asuint8_t[2]and casting throughconst char*works in some implementations but risks alignment/interpretation issues depending on how the IDF/TinyUSB string-descriptor helper reads index 0. Prefer storing LANGID asstd::array<uint16_t, 1>{0x0409}(or equivalent) to match common TinyUSB expectations and avoid potential unaligned access.
// Owning strings + the pointer table TinyUSB reads (index 0 is the LANGID).
std::array<uint8_t, 2> langid{{0x09, 0x04}};
std::vector<std::string> owned_strings;
std::vector<const char *> strings;
components/usb_device/src/usb_device.cpp:150
- For the WebUSB GET_URL request,
wValueis the URL index (commonly 1). The handler currently ignoreswValueand responds for any index as long aswIndex==2. To better match the WebUSB request semantics and avoid surprising behavior if additional URLs are added later, validaterequest->wValue(e.g., only answer index 1 andreturn falsefor others).
if (request->bRequest == vendor.webusb_vendor_code && request->wIndex == 2) {
// Return the WebUSB landing-page URL descriptor.
uint8_t len = 0;
const uint8_t *url = dev->webusb_url_descriptor(len);
if (!url)
return false;
return tud_control_xfer(rhport, request, (void *)(uintptr_t)url, len);
}
components/usb_device/idf_component.yml:5
- Using the
git://protocol is commonly blocked in CI/corporate networks and is generally deprecated in favor of HTTPS. Switching this to anhttps://github.com/...URL improves reliability of dependency fetching in restricted environments.
repository: "git://github.com/esp-cpp/espp.git"
components/usb_device/web/board_console.html:655
FileReader.readAsBinaryString()is deprecated and may be removed or behave inconsistently in future browsers. Prefer reading asArrayBuffer(and then passing aUint8Arrayifesptool-jssupports it, or converting explicitly if it requires a binary string) to keep the flasher robust long-term.
function readFileAsBinaryString(file) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = () => reject(r.error || new Error("file read failed"));
r.readAsBinaryString(file);
});
}
…fset bound - initialize() now fails with invalid_argument when the HID function is enabled with an empty report_descriptor: proceeding emitted a HID interface with wDescriptorLength == 0 and a null report callback -- an invalid interface that reported success (PR review). - board_console: flash offsets are additionally bounded to the 32-bit address range after the strict-hex validation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rict+bounded flash offsets) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The empty-report-descriptor check followed the CFG_TUD_HID==0 early return in the same block, which is unreachable when that preprocessor branch is active. Move it into the #else so each configuration contains only its own path. No behavior change; esp32s3 example builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 23 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
components/usb_device/src/usb_device.cpp:535
poll_interval_msis written directly into the HID interrupt endpointbInterval. At full-speed,bInterval=0is invalid per USB spec (and values must fit 1..255). Add validation when HID is enabled to rejectpoll_interval_ms == 0(and ideally clamp/reject values > 255) to avoid emitting an invalid configuration descriptor that can break enumeration.
const uint8_t hid_poll_ms = config_.hid ? config_.hid->poll_interval_ms : 0;
// Full-speed configuration: 64-byte bulk endpoints, bInterval in ms frames.
build_config_desc(impl_->config_desc, 64, hid_poll_ms);
components/usb_device/web/board_console.html:614
- This uses
innerHTMLwith string interpolation foroffset, which can lead to DOM injection ifoffsetever contains quotes/markup (even if currently only set by this page, it’s easy for future refactors to pass user-controlled strings). Prefer constructing the DOM nodes and assigninginput.valuevia the property (or otherwise escaping) instead of injecting HTML.
function addFileRow(offset) {
const id = "row-" + (rowSeq++);
const row = document.createElement("div");
row.className = "file-row"; row.id = id;
row.innerHTML = `
<div class="offset"><input type="text" class="mono offset-in" value="${offset || "0x0"}" placeholder="0x0" spellcheck="false"></div>
<div class="file"><input type="file" class="file-in" accept=".bin,application/octet-stream"></div>
<button class="tiny danger rm" title="Remove">×</button>
<div class="progress-wrap"><div class="progress-bar"></div></div>`;
components/usb_device/web/board_console.html:657
FileReader.readAsBinaryString()is deprecated in web standards and may eventually regress. PreferreadAsArrayBuffer()and pass aUint8Array/ArrayBufferonward (or adapt it to the formatesptool-jsexpects) to keep the flasher future-proof.
function readFileAsBinaryString(file) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = () => reject(r.error || new Error("file read failed"));
r.readAsBinaryString(file);
});
}
components/usb_device/example/README.md:32
- The example project’s
sdkconfig.defaultsalso enables HID (CONFIG_TINYUSB_HID_COUNT=1), but this snippet/docs only mention CDC + vendor. Update the README snippet/text to include HID (or clarify HID is optional) so the docs match the actual example configuration.
The example's `sdkconfig.defaults` enables both the CDC and vendor classes:
components/usb_device/include/usb_device.hpp:159
- The PR description says
Confighascdc/vendorslots and thathidis reserved, but the code/docs include a fully definedHidFunctionand HID enablement. Update the PR description (or adjust the stated scope) so it matches the implementation being introduced.
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. */
initialize() claimed s_device with a check-then-set: two threads (or two instances) could both observe nullptr and both proceed to install the TinyUSB driver. The registration is now a compare_exchange_strong just before tinyusb_driver_install() — exactly one initialize() wins, the loser backs out with device_or_resource_busy; the early null check remains as a documented fast-fail only. The destructor releases the slot with a matching compare_exchange (clears only if we still own it). PR #720 review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 23 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
components/usb_device/src/usb_device.cpp:351
- HID interrupt endpoints require a non-zero
bInterval, butpoll_interval_mscan currently be set to 0 (it’s auint8_twith no validation). That would emit an invalid interrupt endpoint descriptor at full-speed. Add validation here (or clamp) to requirepoll_interval_ms >= 1when HID is enabled.
if (config_.hid->report_descriptor.empty()) {
// A default-constructed HidFunction has no report descriptor; proceeding
// would emit a HID interface with wDescriptorLength == 0 (and a null
// report callback) -- an invalid HID interface that "succeeds" here and
// then confuses the host. Reject it as an invalid configuration.
logger_.error("HID function enabled but report_descriptor is empty; supply the HID "
"report descriptor bytes (e.g. built with the hid-rp component).");
ec = std::make_error_code(std::errc::invalid_argument);
return false;
}
components/usb_device/src/usb_device.cpp:883
- When the device is not mounted / host not connected,
tud_vendor_write()may return 0; the current code reports that asno_buffer_spaceand logs 'TX buffer full', which is misleading and makes it hard for callers to distinguish disconnect vs backpressure. Consider checkingtud_mounted()(or the appropriate vendor-connection predicate) up front and returningstd::errc::not_connectedbefore attempting to write.
if (!initialized_ || !config_.vendor) {
ec = std::make_error_code(std::errc::not_connected);
return false;
}
size_t offset = 0;
while (offset < data.size()) {
uint32_t queued = tud_vendor_write(data.data() + offset, data.size() - offset);
tud_vendor_write_flush();
if (queued == 0) {
logger_.warn_rate_limited("Vendor TX buffer full, dropping {} bytes", data.size() - offset);
ec = std::make_error_code(std::errc::no_buffer_space);
break;
}
offset += queued;
}
components/usb_device/src/usb_device.cpp:64
- Returning an
std::atomic<UsbDevice*>via implicit conversion obscures the fact that this is an atomic load (and makes it easier to miss memory-ordering intent). Preferreturn s_device.load();(optionally with an explicit memory order) for clarity/consistency with the rest of the file’s atomic usage.
UsbDevice *UsbDevice::instance() { return s_device; }
doc/en/buses/usb_cdc.rst:6
- This page is named
usb_cdc.rstand is linked asusb_cdcin the buses index, but the title/content primarily describes the broaderUsbDevicecomponent (CDC + vendor/WebUSB + HID). To reduce reader confusion, consider either: (a) renaming the page to something likeusb_device(and updatingdoc/en/buses/index.rst+ component manifest doc URL), or (b) adjusting the title/intro to clearly state it covers bothUsbDeviceand theUsbCdcpreset.
USB Device Component
====================
components/usb_device/web/board_console.html:657
FileReader.readAsBinaryString()is deprecated and can be a portability risk long-term. Consider switching toreadAsArrayBuffer()and passing aUint8Array/ArrayBuffer-compatible payload to esptool-js (or converting as required by the library) to avoid relying on deprecated browser behavior.
function readFileAsBinaryString(file) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = () => reject(r.error || new Error("file read failed"));
r.readAsBinaryString(file);
});
}
…HID descriptor The call is correct — TUD_HID_INOUT_DESCRIPTOR's signature is (..., _epout, _epin, ...) with OUT before IN, and hid_out/hid_in carry the right direction bits — but the ordering reads backwards without checking usbd.h, and a reviewer suggested 'fixing' it. Document it at the call site. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e/Fibre + HID) (#725) * feat(usb_device): native USB CDC transport (esp_tinyusb) + OdriveAscii example Add espp::UsbCdc, a thin idiomatic wrapper around ESP-IDF's esp_tinyusb managed component that presents a dedicated native USB CDC-ACM interface on the ESP32-S3/-S2/-P4 USB-OTG peripheral with a configurable VID/PID and manufacturer/product/serial strings, separate from the log console. The example wires espp::UsbCdc RX -> espp::OdriveAscii::process_bytes -> espp::UsbCdc::write so the device enumerates as an ODrive-like serial port while the log console stays on USB-Serial-JTAG. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(usb_device): vendor + WebUSB interface, composable multi-class device Generalize the usb_device component from a single hard-coded CDC-ACM transport into espp::UsbDevice, which assembles a native USB device from a set of selectable functions (CDC and/or vendor-specific) with interface numbers, endpoint addresses and string indices allocated sequentially and checked against the ESP32-S3 USB-OTG endpoint budget. - Add a vendor-specific interface (bInterfaceClass 0xFF, bulk IN + bulk OUT) carrying a raw byte stream, plus WebUSB + MS OS 2.0 descriptors (BOS, URL descriptor, MS-OS-2.0 set) so browsers/Windows bind driverlessly. - Vendor class enabled via CONFIG_TINYUSB_VENDOR_COUNT>0 (CFG_TUD_VENDOR); all tud_vendor_* paths are #if-guarded so CDC-only builds still link. - Keep espp::UsbCdc as a thin CDC-only preset over UsbDevice (back-compat). - Reserve HID/MSC extension points and document the endpoint budget table. - Update example to a composite CDC + Vendor/WebUSB device feeding one OdriveAscii; docs (README, rst, Doxyfile) updated. esp32s3 build passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(odrive_native): ODrive legacy native (Fibre endpoint) protocol server Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(odrive_native): fibre serial-loopback interop harness + stream framing Add the real-tool interop gate for components/odrive_native, mirroring how components/rtps is gated against real FastDDS/ROS 2: the genuine reference fibre client (pure-python legacy fibre from odriverobotics/ODrive @ fw-v0.5.1) connects to a host build of the odrive_native device shim over a PTY serial loopback, downloads endpoint 0, enumerates the tree, and reads/writes endpoints. - detail/odrive_native_stream.hpp: UART stream framing (odrive_crc8, stream_frame, StreamDeframer) verified byte-for-byte against the fw-v0.5.1 reference. - interop/: device shim (PTY, detail/-only, plain c++), real fibre client driver, run_interop.sh + run.sh runner, README, .gitignore for the fetched client/venv. - test/odrive_native_stream_test.cpp + pc/tests/odrive_native_golden.cpp: golden wire-format tests (CRC8/CRC16 goldens, exact frame bytes, deframe/packet round-trips); pc/CMakeLists.txt gives odrive_native_* targets the include dir. - .github/workflows/odrive_native_interop.yml: PASS/FAIL-gated CI. Fix (found by this harness): the endpoint canary json_crc is calc_crc16(json, init=PROTOCOL_VERSION=1), NOT the 0x1337 packet-CRC init -- this matches the fw-v0.5.1 firmware (endpoints_template.j2) and the reference client (discovery.py). Without it the real client's endpoint reads are all rejected. Corrected the core, host test, and PROTOCOL.md accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(usb_device): ODrive-compatible USB example — native/Fibre on vendor, ASCII on CDC Wire the usb_device composite example to present a real ODrive-style protocol split from one simulated motor state: - CDC interface -> espp::OdriveAscii (text; terminal / Web Serial console) - vendor interface (0xFF/WebUSB) -> espp::OdriveNative (the Fibre binary protocol that odrivetool / the fibre library auto-discover over USB) Previously the vendor interface carried ASCII too (a shortcut); the vendor interface is where the native protocol belongs. Adds a USB hardware probe (odrive_usb_probe.py, the reference-fibre USB-backend sibling of the serial interop client) and HARDWARE_TEST.md. Builds for esp32s3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(usb_device): pin esp32s3 target in the example sdkconfig.defaults The example uses native USB-OTG (S3/S2/P4 only); pinning the target makes a bare 'idf.py build' target esp32s3 instead of defaulting to esp32 and failing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(usb_device): auto-detect/clone the reference fibre in the USB probe The odrive-ref/ clone is a git-ignored interop-harness artifact that doesn't exist on a fresh checkout, so the hard-coded --fibre-path was dead. The probe now auto-detects known clone locations, adds --clone to fetch it, and prints a clear clone command; HARDWARE_TEST.md updated to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(usb_device): HID function + example-manifest cleanup + PR #720 review fixes Implement the HID function using TinyUSB's HID class driver: store the application-supplied report descriptor, provide the tud_hid_* weak-callback overrides, allocate the interrupt IN (+ optional OUT) endpoint and interface, append TUD_HID_DESCRIPTOR to the config descriptor, include HID in the endpoint-budget accounting, and add write_hid_report()/is_hid_ready(). All tud_hid_* usage is gated behind CFG_TUD_HID so HID-less builds still link. Exercise it in the example as a composite CDC + vendor + HID gamepad: build the report descriptor from espp::GamepadInputReport (hid-rp) and animate axes + buttons, sending input reports at ~10 Hz. Example-manifest cleanup: move esp_tinyusb into the usb_device component's idf_component.yml, delete the example main manifest, and resolve espp deps via EXTRA_COMPONENT_DIRS (no override_path). PR #720 review fixes: device descriptor uses MISC/IAD only when CDC is enabled (else 0x00/0x00/0x00); validate WebUSB URL length fits a uint8_t; clear s_device before driver teardown; allocation-free CDC/vendor RX via preallocated buffers; clarify landing_page_url / url_scheme=255 docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(usb_device): fix stale example CMakeLists comment (no override_path) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(usb_device): add HID gamepad + WebHID visualizer to the hardware test Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(usb_device): drop invalid CDC-ACM tag, require esp_tinyusb >=2.0 Sync manifest with feat/usb-cdc-transport: registry tags allow only [A-Za-z0-9_] (no hyphens), and the code uses the esp_tinyusb 2.x API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(usb_device example): sync odrive_native static-analysis fixes + correct README - odrive_native: STL insert in stream test + reject accessor-less endpoints (mirrors #721; clears the 6 cppcheck findings on this branch). - example README: describe the actual wiring — CDC->OdriveAscii, vendor->OdriveNative (Fibre), plus a HID gamepad — instead of the stale 'both interfaces -> OdriveAscii' text (Copilot review). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(odrive_native): ignore unknown endpoints even with expect-response bit Mirror #721: unknown endpoints return no response (per PROTOCOL.md) rather than an ACK-only; adds a regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(odrive_native): enforce stream_frame packet<128 guard (sync with #721) The #725 copy of the stream framer predated the guard; a packet larger than kStreamMaxPacket (127) would truncate the single-byte length field into a malformed frame. Refuse it. Addresses PR #725 review (stream.hpp:76). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(odrive_native): sync #725 copy with #721 (dedupe divergent component) The #725 branch carried a stale odrive_native snapshot that predated several #721 review fixes: missing <algorithm>/<bit>/<type_traits> includes, the std::endian little-endian check, the ep_size response cap, and the O(n) deframer read-cursor rewrite (it still had the quadratic front-erase loop). Bring the whole component to #721's committed version so the two PRs ship identical source; also adds the espp_odrive Python client. Host tests + esp32s3 usb example build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(odrive_native): sync with #721 (error hook + endpoint-id cap) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: sync odrive_ascii (#719) + usb_device (#720) copies with their PR branches Bring this integration branch's component copies up to the re-review fixes landed on the source PRs (strict/typed-setter parsing + case-insensitive bool in odrive_ascii; atomic instance routing, WebUSB wIndex guard, FIFO drain, retry errc, zero-copy vendor RX, board console + esp_tinyusb >=2.0 in usb_device). The usb_device example (this PR's delta) is untouched. esp32s3 integration example builds clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(odrive_native): sync with #721 (Linux interop fix + cppcheck style) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(odrive_native): sync with #721 (CMake layout note + unused test var) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: restore usb_device/web docs-hosting copy after main merge (matches #720) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(odrive web): address post-merge #723 review — reset-based recovery, strict codecs, XSS - control panel: WebUSB transfers are uncancelable, so the old timeout clearHalt and the 50ms drain race left an abandoned transferIn pending that could consume a later response and permanently desync the link. Replace both with recoverLink(): device.reset() aborts every pending transfer and restores clean endpoint state (fall back to disconnect if the reset itself fails). Timeout errors are tagged and trigger recovery. - control panel + webusb console: a failed selectAlternateInterface now propagates and fails the connect instead of reporting Connected with endpoints from an unselected alternate. - control panel: strict type codecs — integers must fully parse and fit the target type's range (no '1abc', no '1.9' truncation, no uint8-300 wrap; int64/uint64 BigInt range-checked), floats must fully parse and fit float32 (Math.fround overflow check). Typos now surface an error instead of commanding the motor with garbage. - hid visualizer: device productName is attacker-controlled; build the device-info rows with DOM nodes + textContent instead of interpolating into innerHTML (XSS). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(usb_device): sync with #720 (empty-HID-descriptor rejection, strict+bounded flash offsets) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(usb_device): sync with #720 (cppcheck unreachableCode fix) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(usb_device): sync with #720 (atomic singleton claim) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(usb_device): sync with #720 (HID descriptor arg-order comment) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: drop stale pc harness special-case re-added by branch history main (#721) removed the odrive_native include-dir special case from pc/CMakeLists.txt; this branch's older history re-introduced it through the merge. Match main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
New
usb_devicecomponent:espp::UsbDevice, a composable native-USB device on the ESP32-S3/-S2/-P4 USB-OTG peripheral, wrapping the managedespressif/esp_tinyusb.Confighas optionalcdc/vendorslots;hid/mscreserved) rather than hard-coding one class. Interface numbers, endpoint addresses and string indices are allocated sequentially, with an endpoint-budget check (errors viastd::error_codeif exceeded — ~5 IN / 5 OUT on the S3).espp::UsbCdckept as a thin CDC-only preset.CFG_TUD_VENDORenabled viaCONFIG_TINYUSB_VENDOR_COUNT; alltud_vendor_*gated so CDC-only builds still link.Verification
esp32s3 example (composite CDC + vendor) builds and links (
nm-verified vendor/WebUSB symbols). README + docs + CI wiring included.🤖 Generated with Claude Code