Skip to content

feat(usb_host): espp::UsbHost component (USB Host HID) - #791

Merged
finger563 merged 10 commits into
mainfrom
feat/usb-host
Sep 12, 2026
Merged

feat(usb_host): espp::UsbHost component (USB Host HID)#791
finger563 merged 10 commits into
mainfrom
feat/usb-host

Conversation

@finger563

@finger563 finger563 commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

What

Adds 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-specific HID devices (for example another ESP running espp::UsbDevice as a HID device, such as an espp::WdiUsbPeripheral).

It is a thin, idiomatic wrapper over the ESP-IDF USB Host library (usb) and the usb_host_hid class 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-device std::functions. No exceptions; failures via std::error_code.

API

  • Device connect / disconnect callbacks, with an optional should_open filter predicate (open only the devices you care about, by VID/PID/interface).
  • Per-device HidDevice:
    • set_input_callback(fn(span)) — raw Input reports (device→host; report id in byte 0, symmetric with UsbDevice's HID receive callback)
    • send_output_report(report_id, span, ec) — Output reports (host→device)
    • get_report() / set_idle() / set_protocol() — HID class control requests
    • report_descriptor(), info() (VID/PID + strings), params() (addr/iface/subclass/proto)
  • initialize() / deinitialize() / devices().

Direction naming (Input = device→host, Output = host→device) mirrors espp::UsbDevice exactly, so the two ends of a link (e.g. the device and host roles of the wdi component) 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. UsbHost therefore 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 before start() (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

  • Only the HID class driver is wired up today (covers mice/keyboards/gamepads/vendor HID, and is what the wdi host role needs). The component is structured so CDC/MSC host classes can be layered in later without changing the host-lifecycle model — the same way UsbDevice composes CDC/Vendor/HID on the device side.
  • USB-OTG host mode is only on the S2/-S3/-P4; only one UsbHost may exist at a time (global stack); the board must source VBUS; console must run on UART0 (the native port is the host).
  • The usb + usb_host_hid components come from the ESP Component Registry. On IDF ≥ 6.0 usb_host_hid declares its usb dependency 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 a build.yml CI 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 WdiUsbHost on UsbHost + a WdiBleCentral, 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

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
Copilot AI lite review requested due to automatic review settings September 12, 2026 03:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread components/usb_host/src/usb_host.cpp Outdated
Comment thread components/usb_host/src/usb_host.cpp Outdated
Comment thread components/usb_host/include/usb_host.hpp Outdated
Comment thread components/usb_host/src/usb_host.cpp Outdated
Comment thread components/usb_host/src/usb_host.cpp
Comment thread components/usb_host/src/usb_host.cpp Outdated
Comment thread doc/en/buses/usb_host.rst Outdated
@github-actions

Copy link
Copy Markdown

✅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
@finger563

Copy link
Copy Markdown
Contributor Author

Addressed all review comments (commit pushed) and resolved the threads:

  • Teardown race / fixed-delay teardownstop_lib_task() sets the run flag, calls usb_host_lib_unblock() to wake the blocked usb_host_lib_handle_events(), and joins on a done-flag before usb_host_uninstall(); used by both the init-failure path and deinitialize().
  • Dangling report_descriptor() span → returns a std::vector copy.
  • hid_host_device_close() under the mutex → handles are collected under the lock and closed outside it.
  • Per-call allocation in send_output_report()const_cast for the read-only SET_REPORT transfer.
  • RST literal typo → fixed.
  • RX buffer "grown as needed" → the misleading comment is gone; the buffer size is now Config::max_input_report_size (default 64) and documented as a truncation bound. I kept it a fixed, configurable size rather than growing: the HID host driver copies at most the buffer size for a given event and offers no "required size" query, so sizing up-front (raise it for devices with larger reports) is the pragmatic contract.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread components/usb_host/src/usb_host.cpp
Comment thread components/usb_host/src/usb_host.cpp
Comment thread components/usb_host/src/usb_host.cpp
Comment thread components/usb_host/src/usb_host.cpp
finger563 and others added 2 commits September 12, 2026 01:04
- 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
… 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
@finger563

Copy link
Copy Markdown
Contributor Author

Self-review (design / idiom / bugs / races) — findings and fixes, all pushed

I did a deep pass of UsbHost against the usb_host_hid driver source and had an independent adversarial review run too. One critical design bug and several real ones:

  • 🔴 Control transfers from callbacks could never complete. The driver delivers CONNECTED/INPUT/DISCONNECTED on its background task, and that same task is the only one that dispatches control-transfer completions (hid_control_transfer blocks on a semaphore given from usb_host_client_handle_events). So set_protocol() at connect, and any send_output_report() from the input callback (the WDI host's keepalive replies), timed out after 5 s and stalled the driver. Fix: an internal dispatch task (usb_host_cb), mirroring the IDF example's app queue — the driver task only enqueues (copying each Input report inside the callback, where the driver's buffer is still valid); the dispatch task does open/set_protocol/start/close and every user callback. Bounded queue (drops Input when the consumer lags, never blocks the driver). Ordering preserved; connect callback now runs before start(), so the first reports are no longer dropped.
  • 🔴 deinitialize() could "succeed" with the driver still holding this (UAF on the next plug): the driver only forgets a device on DEV_GONE and refuses to uninstall while it tracks one. Now: stop dispatch, retire devices (firing their disconnect callbacks), usb_host_lib_set_root_port_power(false) to force DEV_GONE, retry hid_host_uninstall() bounded, and on failure stay initialized and return an error.
  • 🟠 info()/params() were live driver reads — freed memory after close, crash after uninstall. Now connect-time snapshots (also report_descriptor()).
  • 🟠 App-task control transfer could race DEV_GONE freeing the interface: per-device io_mutex_ around every driver call, also held while retiring.
  • Both internal tasks are espp::Task now (no hand-rolled FreeRTOS task + join loop); set_protocol(report) only for boot-subclass interfaces; separate stack sizes; ESP_ERR_NOT_SUPPORTEDnot_supported.

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

https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread components/usb_host/src/usb_host.cpp
Comment thread components/usb_host/src/usb_host.cpp Outdated
Comment thread components/usb_host/include/usb_host.hpp Outdated
Comment thread components/usb_host/include/usb_host.hpp Outdated
Comment thread components/usb_host/src/usb_host.cpp Outdated
Comment thread components/usb_host/src/usb_host.cpp Outdated
Comment thread components/usb_host/src/usb_host.cpp
Comment thread doc/en/buses/usb_host.rst Outdated
…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
@finger563
finger563 requested a balanced review from Copilot September 12, 2026 17:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread components/usb_host/src/usb_host.cpp Outdated
Comment thread components/usb_host/src/usb_host.cpp
Comment thread components/usb_host/src/usb_host.cpp
Comment thread components/usb_host/src/usb_host.cpp
Comment thread components/usb_host/src/usb_host.cpp
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread components/usb_host/src/usb_host.cpp
Comment thread components/usb_host/README.md Outdated
Comment thread components/usb_host/src/usb_host.cpp
Comment thread doc/en/buses/usb_host.rst Outdated
…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
@finger563
finger563 merged commit e01567f into main Sep 12, 2026
160 checks passed
@finger563
finger563 deleted the feat/usb-host branch September 12, 2026 20:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants