feat(usb_host): espp::UsbHost component (USB Host HID) - #791
Conversation
Add espp::UsbHost, the host-side counterpart to espp::UsbDevice. It drives the ESP32-S2/-S3/-P4 USB-OTG peripheral as a USB host, enumerates attached devices, and exposes the HID class devices it finds (mice, keyboards, gamepads, and vendor HID devices such as an espp WdiUsbPeripheral). A thin idiomatic wrapper over the ESP-IDF USB Host library (usb) and the usb_host_hid class driver: owns the host lifecycle (install host lib + HID driver, run the event tasks, open interfaces, teardown) and marshals the driver's C callbacks into per-device std::function callbacks. No exceptions; failures via std::error_code. - Device connect/disconnect callbacks + optional open filter. - Per-device Input report callback (device->host; report id in byte 0). - Send Output reports (host->device) + HID class Get/Set Report/Idle/Protocol. - Read the device's HID report descriptor. Direction naming (Input = device->host, Output = host->device) mirrors espp::UsbDevice so the two ends of a link line up. Only the HID class is wired up today; the design leaves room for CDC/MSC host classes later. Includes a runnable esp32s3 example (logs connected HID devices + hex-dumps their Input reports), README, Sphinx docs (buses/usb_host), Doxyfile entries, and a CI build entry. Built clean on IDF v6.1 esp32s3 (56% free). Because the vendored usb_host_hid declares its `usb` dependency only through the component manager on IDF>=6, the example builds with the component manager on (the CI default), unlike the manager-off device-side USB examples. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
There was a problem hiding this comment.
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.
Pull request overview
Adds a new espp::UsbHost component to run ESP32-S2/-S3/-P4 USB-OTG in host mode and expose HID devices, along with documentation, an ESP32-S3 example, and CI build coverage.
Changes:
- Introduces
components/usb_host(C++ wrapper over ESP-IDF USB Host +usb_host_hid) with connect/disconnect + per-device HID APIs. - Adds a runnable ESP32-S3 example and wires it into Doxygen + GitHub Actions CI.
- Adds Sphinx/MyST docs under
doc/en/buses/usb_host*and links them into the buses index.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| doc/en/buses/usb_host_example.md | Adds a MyST include wrapper to pull in the example README into the docs. |
| doc/en/buses/usb_host.rst | Adds Sphinx documentation page for the new USB host component and links the example + API include. |
| doc/en/buses/index.rst | Links the new USB host docs page in the buses section. |
| doc/Doxyfile | Registers the new usb_host example and header for Doxygen generation. |
| components/usb_host/src/usb_host.cpp | Implements espp::UsbHost lifecycle, tasks, and HID callbacks bridging. |
| components/usb_host/include/usb_host.hpp | Declares the public espp::UsbHost / HidDevice API and documentation. |
| components/usb_host/idf_component.yml | Declares the component manager manifest and dependencies. |
| components/usb_host/example/sdkconfig.defaults | Configures ESP32-S3 target + UART console requirements for host mode. |
| components/usb_host/example/main/usb_host_example.cpp | Adds a basic host example that logs connected HID devices and input reports. |
| components/usb_host/example/main/CMakeLists.txt | Registers the example’s main component. |
| components/usb_host/example/README.md | Documents how to build/use the example and hardware constraints. |
| components/usb_host/example/CMakeLists.txt | Defines the example project and component set (manager-on flow). |
| components/usb_host/README.md | Documents the new component, API, requirements, and roadmap. |
| components/usb_host/CMakeLists.txt | Registers the new usb_host component and its dependencies. |
| .github/workflows/build.yml | Adds CI build entry for the usb_host example. |
Suppressed comments (1)
doc/en/buses/usb_host_example.md:3
- The include directive block is indented by one leading space, which can cause MyST/Sphinx to treat it as a literal code block instead of executing the
{include}directive. Remove the leading space so the fence starts at column 0.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
✅Static analysis result - no issues found! ✅ |
… buffers) - Join the lib task on teardown instead of a fixed delay: stop_lib_task() sets the run flag, calls usb_host_lib_unblock() to wake the blocked usb_host_lib_handle_events(), and waits (bounded) on a done flag set by the task as it exits, before usb_host_uninstall(). Used by both the init failure path and deinitialize() (fixes the install-fail race). - deinitialize(): collect device handles under the lock, then hid_host_device_ close() them *outside* the lock to avoid lock inversion with callbacks. - HidDevice::report_descriptor() now returns a std::vector copy instead of a std::span into driver-owned memory (no dangling view on concurrent disconnect). - send_output_report() const_casts the payload for the (read-only) SET_REPORT transfer instead of allocating+copying a vector on every call. - Input-report buffer size is now Config::max_input_report_size (default 64) and documented as a truncation bound, replacing the misleading "grown as needed". - Fix an RST inline-literal pluralization in the docs. Rebuilt clean on IDF v6.1 esp32s3 (56% free). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
git:// is plaintext and can be blocked/MITM'd; use https. Addresses a review comment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
|
Addressed all review comments (commit pushed) and resolved the threads:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
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.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.
- start()/stop()/set_idle()/set_protocol() now check connected_ and fail with no_such_device once the device is gone, matching the documented "inert after disconnect" contract (send_output_report/get_report/report_descriptor already did). - Map ESP_ERR_NOT_SUPPORTED to std::errc::not_supported instead of no_such_device (the device may exist; the operation isn't supported). Addresses review comments; example rebuilt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
…t the HID task)
Self-review found a design bug: the HID class driver delivers CONNECTED /
INPUT_REPORT / DISCONNECTED on its own background task, and that same task is
what completes the driver's synchronous control transfers (the completion
callback is dispatched from usb_host_client_handle_events()). Any control
transfer issued from inside a UsbHost callback -- set_protocol() at connect,
or a user's send_output_report() from the input callback (exactly what the WDI
host does to answer keepalives) -- could therefore never complete and timed
out. The ESP-IDF HID host example avoids this with an app-side event queue;
UsbHost now does the same internally:
- The driver task only enqueues events (copying each Input report out of the
driver's transfer buffer inside the callback, where it is still valid).
- A dedicated espp::Task ("usb_host_cb") drains the queue and performs open /
set_protocol / start / close and invokes every user callback, so callbacks
may issue control transfers and never stall the USB stack. Events for a
device stay ordered; the connect callback now runs *before* start(), so the
input callback installed there sees the very first report (previously the
first reports could be dropped). Queue depth is bounded; when full, Input
reports are dropped rather than blocking the driver.
- HidDevice snapshots Info / Params / report descriptor at connect (they were
live driver reads that dereferenced freed driver memory after close, and
crashed after deinitialize()); accessors now return the cached values.
- Per-device io_mutex_ serializes every driver call through a HidDevice
against its retirement on disconnect (an app-task control transfer could
race the driver freeing the interface on DEV_GONE).
- deinitialize(): the driver only forgets a device on DEV_GONE and refuses to
uninstall while it tracks one, so the old teardown could "succeed" while the
driver still referenced this object (use-after-free on the next plug). Now:
stop the dispatch task, retire our devices (firing their disconnect
callbacks), power down the root port to force DEV_GONE, wait bounded for
hid_host_uninstall() to succeed, and on failure stay initialized and return
an error rather than tearing down under a live driver.
- The USB-host-library event loop is an espp::Task too (no hand-rolled
FreeRTOS task + join loop); stop = flag + usb_host_lib_unblock() + join.
- set_protocol(report) is only sent to boot-subclass interfaces (the only ones
required to support it); ESP_ERR_NOT_SUPPORTED maps to not_supported.
- Config: separate lib / HID / dispatch task stack sizes; max_queued_events.
Public API is unchanged apart from the identity accessors returning const
references. Example rebuilt clean on IDF v6.1 esp32s3 (55% free).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
7f46c98 to
0583272
Compare
… safety) Describe that the driver task only enqueues events while a dedicated dispatch task runs device open/start/close and every user callback, so control transfers from callbacks complete; event ordering; connect-before-start; the bounded queue; per-device serialization; and that info()/params()/ report_descriptor() are connect-time snapshots. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
|
Self-review (design / idiom / bugs / races) — findings and fixes, all pushed I did a deep pass of
Threading model documented in the README/rst. Public API unchanged except identity accessors return const refs. Example rebuilt clean (55% free). 🤖 Generated with Claude Code |
There was a problem hiding this comment.
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.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 8 comments.
…bound, typing)
- ~UsbHost: if deinitialize() fails the driver still holds a pointer to this
object; returning would free it and the next device event would be a
use-after-free. Log and abort() instead of silently continuing.
- deinitialize(): only clear initialized_ after usb_host_uninstall() succeeds;
on failure stay initialized and return the error (the library is still
installed).
- wchars_to_utf8(): real UTF-16 (incl. surrogate pairs) -> UTF-8 conversion
instead of replacing non-ASCII with '?', so the "UTF-8" documentation on
HidDevice::Info is true.
- get_report(): report_type is hid_report_type_t rather than a raw uint8_t.
- Event queue is now a hard bound: a lifecycle event that arrives when the
queue is full evicts the oldest queued Input report rather than growing the
queue (lifecycle events are bounded by the number of attached devices), and
dropped-input logging is rate-limited (first drop, then every 100) with a
running count.
- docs: RST pluralization ("std::function objects").
Example rebuilt clean on IDF v6.1 esp32s3.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
There was a problem hiding this comment.
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.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 5 comments.
…d, teardown gate, no per-report alloc) - send_output_report(): copy into a stack buffer (heap only for an oversized report) instead of const_cast-ing the caller's bytes for the driver's non-const SET_REPORT signature -- safe even if the caller's data lives in read-only memory. - Event queue bound is now precise: when full, an Input is dropped; a lifecycle event evicts the oldest queued Input, and if none is queued a NewDevice event is dropped (the device stays unopened while overloaded) while a Disconnected is always kept (it can only follow an opened device). Worst-case length is max_queued_events + open devices; documented. - enqueue() is gated on an `accepting_` flag that is set once the dispatch task runs and cleared before it stops, so driver callbacks that race teardown no longer grow a consumer-less queue. - Input reports are stored inline in the Event (64 bytes, the full-speed HID interrupt maximum) so the driver task performs no heap allocation per report; the heap is used only when a larger max_input_report_size is configured. Example rebuilt clean on IDF v6.1 esp32s3. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
The upload workflow enumerates components explicitly; add the new one so it is validated in PR dry-runs and published on release. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
There was a problem hiding this comment.
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.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.
…reuse, doc snippets) - enqueue(): re-check accepting_ after taking queue_mutex_. stop_dispatch_task() clears accepting_ and then clears the queue under the same mutex, so an enqueue that passed the unlocked check can no longer push a stale event after the clear (which a later re-initialize would otherwise dispatch). - Large-report configurations (max_input_report_size > 64) no longer allocate per Input report: Event::overflow buffers are recycled through a small pool (guarded by queue_mutex_, bounded by max_queued_events), so the driver task's resize() reuses capacity instead of hitting the heap. - README / rst usage snippets: guard devices() before front() (or use the shared_ptr from on_device_connected) instead of calling front() on a possibly-empty vector. Example rebuilt clean on IDF v6.1 esp32s3. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
What
Adds
espp::UsbHost, the host-side counterpart toespp::UsbDevice. It drives the ESP32-S2/-S3/-P4 USB-OTG peripheral as a USB host, enumerates attached devices, and exposes the HID class devices it finds — mice, keyboards, gamepads, and vendor-specific HID devices (for example another ESP runningespp::UsbDeviceas a HID device, such as anespp::WdiUsbPeripheral).It is a thin, idiomatic wrapper over the ESP-IDF USB Host library (
usb) and theusb_host_hidclass driver: it owns the whole host lifecycle (install host lib + HID driver, run the event tasks, open interfaces, teardown) and marshals the driver's C callbacks into per-devicestd::functions. No exceptions; failures viastd::error_code.API
should_openfilter predicate (open only the devices you care about, by VID/PID/interface).HidDevice:set_input_callback(fn(span))— raw Input reports (device→host; report id in byte 0, symmetric withUsbDevice's HID receive callback)send_output_report(report_id, span, ec)— Output reports (host→device)get_report()/set_idle()/set_protocol()— HID class control requestsreport_descriptor(),info()(VID/PID + strings),params()(addr/iface/subclass/proto)initialize()/deinitialize()/devices().Direction naming (Input = device→host, Output = host→device) mirrors
espp::UsbDeviceexactly, so the two ends of a link (e.g. the device and host roles of thewdicomponent) line up.Threading model
The HID class driver delivers its events on its own background task, and that same task is the only one that completes the driver's synchronous control transfers — so a control transfer issued from inside a driver callback can never complete.
UsbHosttherefore runs an internal dispatch task (like the ESP-IDF HID host example's app queue): the driver task only enqueues events (copying each Input report inside the callback, where the driver's buffer is still valid), and the dispatch task performs open/set_protocol/start/close and invokes every user callback. Consequences:send_output_report()etc. are safe from callbacks, events stay ordered, the connect callback runs beforestart()(no missed first reports),info()/params()/report_descriptor()are connect-time snapshots, and each device serializes its driver calls so app-task I/O can't race a disconnect. Teardown powers the root port down to force DEV_GONE and refuses to complete (staying initialized) if the driver can't release a device.Design notes
wdihost role needs). The component is structured so CDC/MSC host classes can be layered in later without changing the host-lifecycle model — the same wayUsbDevicecomposes CDC/Vendor/HID on the device side.UsbHostmay exist at a time (global stack); the board must source VBUS; console must run on UART0 (the native port is the host).usb+usb_host_hidcomponents come from the ESP Component Registry. On IDF ≥ 6.0usb_host_hiddeclares itsusbdependency only through the component manager, so this example builds with the component manager on (the CI default), unlike the manager-off device-side USB examples. This is documented in the README/docs and reflected in the CI entry.Included
Component (
include/,src/), a runnable esp32s3 example (logs connected HID devices + hex-dumps their Input reports), README, Sphinx docs (buses/usb_host), Doxyfile entries, and abuild.ymlCI entry.Verification
Example builds clean on ESP-IDF v6.1 / esp32s3 (
usb_host_example.bin, 56% free).Follow-up
This unblocks the WDI host role (the wheelchair side): a
WdiUsbHostonUsbHost+ aWdiBleCentral, with USB-host and BLE-central examples — a separate PR on top of this and #788.🤖 Generated with Claude Code
https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU