From 222a11ccc53ac3b4b4a07fac3ac8c6e8ceedfa83 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 22:34:29 -0500 Subject: [PATCH 01/10] feat(usb_host): espp::UsbHost (USB Host HID) component + example 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 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .github/workflows/build.yml | 2 + components/usb_host/CMakeLists.txt | 5 + components/usb_host/README.md | 90 ++++ components/usb_host/example/CMakeLists.txt | 33 ++ components/usb_host/example/README.md | 45 ++ .../usb_host/example/main/CMakeLists.txt | 5 + .../example/main/usb_host_example.cpp | 60 +++ .../usb_host/example/sdkconfig.defaults | 7 + components/usb_host/idf_component.yml | 26 ++ components/usb_host/include/usb_host.hpp | 245 ++++++++++ components/usb_host/src/usb_host.cpp | 433 ++++++++++++++++++ doc/Doxyfile | 2 + doc/en/buses/index.rst | 1 + doc/en/buses/usb_host.rst | 108 +++++ doc/en/buses/usb_host_example.md | 2 + 15 files changed, 1064 insertions(+) create mode 100644 components/usb_host/CMakeLists.txt create mode 100644 components/usb_host/README.md create mode 100644 components/usb_host/example/CMakeLists.txt create mode 100644 components/usb_host/example/README.md create mode 100644 components/usb_host/example/main/CMakeLists.txt create mode 100644 components/usb_host/example/main/usb_host_example.cpp create mode 100644 components/usb_host/example/sdkconfig.defaults create mode 100644 components/usb_host/idf_component.yml create mode 100644 components/usb_host/include/usb_host.hpp create mode 100644 components/usb_host/src/usb_host.cpp create mode 100644 doc/en/buses/usb_host.rst create mode 100644 doc/en/buses/usb_host_example.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6aae031fc5..2a733e258d 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -355,6 +355,8 @@ jobs: - path: 'components/usb_device/xinput_example' target: esp32s3 command: 'IDF_COMPONENT_MANAGER=0 idf.py build' + - path: 'components/usb_host/example' + target: esp32s3 - path: 'components/vl53l/example' target: esp32s3 - path: 'components/wifi/example' diff --git a/components/usb_host/CMakeLists.txt b/components/usb_host/CMakeLists.txt new file mode 100644 index 0000000000..f7f1e11bc3 --- /dev/null +++ b/components/usb_host/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES base_component usb usb_host_hid +) diff --git a/components/usb_host/README.md b/components/usb_host/README.md new file mode 100644 index 0000000000..7d5b1f4aac --- /dev/null +++ b/components/usb_host/README.md @@ -0,0 +1,90 @@ +# USB Host Component + +[![Badge](https://components.espressif.com/components/espp/usb_host/badge.svg)](https://components.espressif.com/components/espp/usb_host) + +`espp::UsbHost` is 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 espp wrapper over the ESP-IDF USB Host library (`usb`) +and the `usb_host_hid` class driver: it owns the whole host lifecycle (install +the host library + HID driver, run the event tasks, open interfaces, teardown) +and marshals the driver's C callbacks into per-device `std::function`s. Like the +rest of espp it does not throw and reports failures via `std::error_code`. + +## Features + +- Installs / uninstalls the USB Host library and the HID class driver and runs + their event-handling tasks. +- Device **connect / disconnect** callbacks, with an optional filter predicate so + you only open the devices you care about (by VID/PID, interface, etc.). +- Per-device **Input report** callback (device→host), delivering the raw report + bytes (report ID in byte 0 for report-ID'd descriptors — symmetric with how + `espp::UsbDevice`'s HID receive callback delivers OUT reports). +- Send **Output reports** (host→device) and issue the HID class control requests + (Get/Set Report, Get/Set Idle, Set Protocol). +- Read a connected device's **HID report descriptor**. + +Report directions are named from the connected **device's** point of view, as in +the USB HID spec: an *Input* report is device→host, an *Output* report is +host→device. This mirrors `espp::UsbDevice` exactly, so the two ends of a link +(e.g. the device and host roles of the `wdi` component) line up. + +## Requirements / caveats + +- USB-OTG **host** mode is only available on the **ESP32-S2, -S3 and -P4**. +- Only **one** `espp::UsbHost` may exist at a time (the USB Host library and HID + class driver are global singletons). It cannot coexist with `espp::UsbDevice` + (they both claim the USB-OTG peripheral). +- The board must be able to source **VBUS** to the attached device — a board with + a USB-A host port / VBUS switch, or a self-powered hub. `UsbHost` does not + manage board power. +- On the ESP32-S3 the USB-Serial-JTAG shares the USB-OTG PHY, so when the host + role is active the **console must run on UART0** (see the example's + `sdkconfig.defaults`). +- The USB Host library (`usb`) and `usb_host_hid` come from the ESP Component + Registry via the IDF component manager. On ESP-IDF ≥ 6.0 `usb_host_hid` + declares its `usb` dependency only through the manager, so build the example + with the component manager **on** (the default) rather than the manager-off + flow used by the device-side USB examples. + +## Example + +```cpp +#include "usb_host.hpp" + +espp::UsbHost host({ + .on_device_connected = + [](const std::shared_ptr &dev) { + auto info = dev->info(); + printf("connected: %s %s %04x:%04x\n", info.manufacturer.c_str(), + info.product.c_str(), info.vid, info.pid); + dev->set_input_callback([](std::span report) { + // handle a device->host Input report (report[0] is the report id) + }); + }, + .on_device_disconnected = [](const auto &) { /* ... */ }, + // optional: only open the devices you want + // .should_open = [](const auto &info, const auto &) { return info.vid == 0x1209; }, +}); + +std::error_code ec; +if (!host.initialize(ec)) { /* handle ec */ } + +// later, send an Output report (host->device): +std::array payload{...}; +host.devices().front()->send_output_report(/*report_id*/ 0x02, payload, ec); +``` + +See `example/` for a full runnable example (esp32s3) that logs every connected +HID device and hex-dumps its Input reports. + +## Roadmap + +Only the **HID** class driver is wired up today (it covers mice, keyboards, +gamepads and vendor HID devices, and is what the `wdi` host role needs). The +component is structured so other class drivers (CDC-ACM, MSC) can be layered in +later without changing the host-lifecycle model — the same way `espp::UsbDevice` +composes CDC / Vendor / HID functions on the device side. diff --git a/components/usb_host/example/CMakeLists.txt b/components/usb_host/example/CMakeLists.txt new file mode 100644 index 0000000000..717f1dbf7d --- /dev/null +++ b/components/usb_host/example/CMakeLists.txt @@ -0,0 +1,33 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.20) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add only the component directories that we want to use +set(EXTRA_COMPONENT_DIRS + "../../../components/base_component" + "../../../components/format" + "../../../components/logger" + "../../../components/task" + "../../../components/timer" + "../../../components/usb_host" +) + +# The USB Host library (`usb`) and the HID class driver (`usb_host_hid`) are +# fetched from the ESP Component Registry by the IDF component manager (which is +# enabled by default). On ESP-IDF >= 6.0 the `usb_host_hid` component declares +# its `usb` dependency only through the manager, so unlike the device-side USB +# examples this one is built with the component manager **on** (the espp/* +# components are still resolved locally via EXTRA_COMPONENT_DIRS above). + +set( + COMPONENTS + "main esptool_py base_component format logger task timer usb_host usb usb_host_hid" + CACHE STRING + "List of components to include" + ) + +project(usb_host_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/usb_host/example/README.md b/components/usb_host/example/README.md new file mode 100644 index 0000000000..7bade98a86 --- /dev/null +++ b/components/usb_host/example/README.md @@ -0,0 +1,45 @@ +# USB Host Example + +This example uses `espp::UsbHost` to drive the ESP32-S3 USB-OTG peripheral as a +**USB host**. It enumerates attached USB **HID** devices (mice, keyboards, +gamepads, or vendor HID devices such as an espp `WdiUsbPeripheral`), logs each +connected device's identity and report-descriptor length, and hex-dumps every +Input report the device sends. + +## How it works + +- Constructs an `espp::UsbHost` with `on_device_connected` / + `on_device_disconnected` callbacks. +- On connect, reads the device `info()` (VID/PID + strings) and `params()` + (interface / protocol), fetches the HID `report_descriptor()`, and installs a + per-device input callback that logs each report. +- `initialize()` installs the USB Host library + HID class driver and starts the + event tasks; the device callbacks then fire as devices are plugged / unplugged. + +## Hardware / build notes + +- USB-OTG **host** mode requires an **ESP32-S2 / -S3 / -P4**, and the board must + be able to source **VBUS** to the attached device (a board with a USB-A host + port / VBUS switch, or a self-powered hub). +- The native USB-OTG port is used for the host role, so the console runs on + **UART0** (USB-Serial-JTAG shares the PHY on the ESP32-S3). Monitor over a UART + adapter. +- The USB Host library (`usb`) and `usb_host_hid` come from the ESP Component + Registry, so build this example with the **component manager enabled** (the + default `idf.py build`), not the manager-off flow used by the device-side USB + examples. + +## Use it with the WDI USB device + +Flash the `wdi` component's `usb_example` onto a second ESP32-S3 (it enumerates +as a WDI HID device) and connect it to the host running this example — you will +see the WDI Control / keepalive Input reports arrive in the log. This is the +basis for the forthcoming WDI **host** (wheelchair) role. + +## Build and flash + +``` +idf.py set-target esp32s3 +idf.py build +idf.py -p PORT flash monitor +``` diff --git a/components/usb_host/example/main/CMakeLists.txt b/components/usb_host/example/main/CMakeLists.txt new file mode 100644 index 0000000000..b4a0d43c8c --- /dev/null +++ b/components/usb_host/example/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." + REQUIRES usb_host +) diff --git a/components/usb_host/example/main/usb_host_example.cpp b/components/usb_host/example/main/usb_host_example.cpp new file mode 100644 index 0000000000..f2e4a63828 --- /dev/null +++ b/components/usb_host/example/main/usb_host_example.cpp @@ -0,0 +1,60 @@ +#include +#include + +#include "logger.hpp" +#include "usb_host.hpp" + +using namespace std::chrono_literals; + +// USB Host (HID) example: act as a USB host, enumerate attached HID devices +// (mice, keyboards, gamepads, or vendor HID devices such as an espp WDI +// peripheral) and log each device plus a hex dump of every Input report it +// sends. Plug the ESP32-S3 (in host mode) into a USB HID device and watch the +// monitor. +// +// NOTE: the board must be able to source VBUS to the attached device (a board +// with a USB-A host port / VBUS switch, or a self-powered hub). The console +// runs on UART0 because the native USB-OTG port is used for the host role. + +//! [usb_host_example] +extern "C" void app_main(void) { + espp::Logger logger({.tag = "USB Host", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting USB HID host example"); + + espp::UsbHost host({ + .on_device_connected = + [&](const std::shared_ptr &device) { + auto info = device->info(); + auto params = device->params(); + logger.info("connected: '{}' '{}' VID={:#06x} PID={:#06x} iface={} proto={}", + info.manufacturer, info.product, info.vid, info.pid, + params.interface_number, params.protocol); + auto desc = device->report_descriptor(); + logger.info(" report descriptor: {} bytes", desc.size()); + + // Log every Input report this device sends (device -> host). + device->set_input_callback([&logger](std::span data) { + logger.info("input report ({} bytes): {::#04x}", data.size(), data); + }); + }, + .on_device_disconnected = + [&](const std::shared_ptr &device) { + logger.info("disconnected: PID={:#06x}", device->info().pid); + }, + // .should_open = [](const auto &info, const auto &) { return info.vid == 0x1209; }, + .log_level = espp::Logger::Verbosity::INFO, + }); + + std::error_code ec; + if (!host.initialize(ec)) { + logger.error("Failed to initialize USB host: {}", ec.message()); + return; + } + logger.info("USB host ready; plug in a USB HID device."); + + while (true) { + logger.debug("connected HID devices: {}", host.devices().size()); + std::this_thread::sleep_for(2s); + } +} +//! [usb_host_example] diff --git a/components/usb_host/example/sdkconfig.defaults b/components/usb_host/example/sdkconfig.defaults new file mode 100644 index 0000000000..46c8c82593 --- /dev/null +++ b/components/usb_host/example/sdkconfig.defaults @@ -0,0 +1,7 @@ +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 +# The native USB-OTG port is used for the USB **host** role. On the ESP32-S3 the +# USB-Serial-JTAG shares that PHY, so the console runs on UART0 (with +# USB-Serial-JTAG as an early-boot secondary). Use a UART adapter to monitor. +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y diff --git a/components/usb_host/idf_component.yml b/components/usb_host/idf_component.yml new file mode 100644 index 0000000000..083b705e74 --- /dev/null +++ b/components/usb_host/idf_component.yml @@ -0,0 +1,26 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "Native USB host (ESP-IDF USB Host library + usb_host_hid): enumerate and talk to USB HID devices (mice, keyboards, gamepads, vendor HID) from an ESP32-S2/-S3/-P4" +url: "https://github.com/esp-cpp/espp/tree/main/components/usb_host" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/usb_host/usb_host.html" +examples: + - path: example +tags: + - cpp + - Component + - USB + - Host + - HID + - Transport + - Gamepad +dependencies: + idf: + version: '>=5.0' + espp/base_component: '>=1.0' + # The USB Host library (`usb`) and the HID class driver come from esp-usb. For + # IDF >= 6.0 the `usb` component is provided by esp-usb (usb_host_hid pulls it + # in). Pin the tested floors. + espressif/usb_host_hid: '>=1.0.0' diff --git a/components/usb_host/include/usb_host.hpp b/components/usb_host/include/usb_host.hpp new file mode 100644 index 0000000000..f076e5d67b --- /dev/null +++ b/components/usb_host/include/usb_host.hpp @@ -0,0 +1,245 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "usb/hid_host.h" // usb_host_hid managed component (pulls in the usb host library) + +#include "base_component.hpp" + +namespace espp { + +// Forward-declare the extern "C" trampolines (defined in usb_host.cpp inside +// `namespace espp`) so the in-class friend declarations below refer to these +// existing C-linkage declarations rather than introducing conflicting +// C++-linkage symbols. The USB HID host driver invokes them from its background +// task with a `void *arg` set to the owning UsbHost. +extern "C" void espp_usb_host_driver_event_cb(hid_host_device_handle_t handle, + const hid_host_driver_event_t event, void *arg); +extern "C" void espp_usb_host_interface_event_cb(hid_host_device_handle_t handle, + const hid_host_interface_event_t event, void *arg); + +/** + * @brief Native-USB **host** built on the ESP-IDF USB Host library (`usb`) and + * the `usb_host_hid` class driver, for the ESP32-S2 / -S3 / -P4 USB-OTG + * peripheral acting as a host. + * + * @details `espp::UsbHost` is the counterpart to `espp::UsbDevice`: instead of + * enumerating *as* a USB device, it drives the bus as a **host**, enumerates + * attached devices, and exposes the **HID** class devices it finds (mice, + * keyboards, gamepads, and vendor-specific HID devices such as an + * `espp::WdiUsbPeripheral`). It owns the whole host-side lifecycle: + * + * - installs the USB Host library and runs its event-handling task, + * - installs the HID class driver (with its own background task), + * - on device attach, opens each HID interface and (optionally) starts receiving + * its **Input** reports, delivering them to a per-device callback, + * - lets the application send **Output** reports (and issue the HID class + * Get/Set Report / Idle / Protocol control requests) back to a device, + * - and cleans everything up on teardown. + * + * Report directions are named from the connected **device's** point of view (as + * in the USB HID spec): an *Input* report is device→host (delivered to + * `HidDevice`'s input callback), an *Output* report is host→device (sent with + * `HidDevice::send_output_report()`). This is deliberately symmetric with + * `espp::UsbDevice`'s HID function, so the two sides of a link (e.g. the two + * roles of the `wdi` component) mirror each other. + * + * The class is idiomatic espp: it does not throw, reports failures via + * `std::error_code`, and marshals the USB-host driver's C callbacks (which run + * in the HID driver's background task) into per-device `std::function`s. + * + * @note Only one `espp::UsbHost` may exist at a time: the USB Host library and + * the HID class driver are global singletons. USB-OTG **host** mode is + * only available on the ESP32-S2, ESP32-S3 and ESP32-P4, and the board + * must be able to source VBUS to the attached device (a self-powered hub + * or a board with a VBUS switch); the host does not manage board power. + * + * @note Device-connected / disconnected / input-report callbacks are invoked + * from the HID driver's background task. Keep them short and non-blocking; + * it is safe to call `HidDevice::send_output_report()` and the other + * device methods from within them. + * + * \section usb_host_ex1 UsbHost (generic HID host) Example + * \snippet usb_host_example.cpp usb_host_example + */ +class UsbHost : public BaseComponent { +public: + /** + * @brief A HID interface on a device connected to the host. + * + * Created by `UsbHost` when a HID device is attached; handed to the + * application (as a `std::shared_ptr`) through the connect / disconnect + * callbacks and `UsbHost::devices()`. Owns nothing itself -- the underlying + * driver handle is owned by `UsbHost` -- and becomes inert once the device is + * disconnected (methods then fail with `std::errc::no_such_device`). + */ + class HidDevice { + public: + /// @brief Callback invoked with a raw HID Input report from the device. + /// @param data The report bytes. For a device whose report descriptor uses + /// report IDs, byte 0 is the report ID (matching how + /// `espp::UsbDevice`'s HID receive callback delivers OUT reports). + using input_callback_fn = std::function data)>; + + /// @brief Device descriptor identity (VID/PID + string descriptors). + struct Info { + uint16_t vid{0}; ///< idVendor + uint16_t pid{0}; ///< idProduct + std::string manufacturer{}; ///< iManufacturer string (UTF-8) + std::string product{}; ///< iProduct string (UTF-8) + std::string serial_number{}; ///< iSerialNumber string (UTF-8) + }; + + /// @brief HID interface parameters. + struct Params { + uint8_t address{0}; ///< USB device address + uint8_t interface_number{0}; ///< bInterfaceNumber of this HID interface + uint8_t sub_class{0}; ///< bInterfaceSubClass (1 = boot interface) + uint8_t protocol{0}; ///< bInterfaceProtocol (1 = keyboard, 2 = mouse, 0 = none) + }; + + /// @brief The identity of the connected device. + Info info() const; + /// @brief The parameters of this HID interface. + Params params() const; + + /// @brief The device's HID report descriptor. + /// @return The raw report-descriptor bytes (empty if unavailable). The + /// underlying storage is owned by the driver and is valid only while + /// the device is connected. + std::span report_descriptor() const; + + /// @brief Install the callback invoked with each Input report. + void set_input_callback(input_callback_fn cb); + + /// @brief Start receiving Input reports (called automatically on open when + /// `Config::auto_start` is set). + bool start(std::error_code &ec); + /// @brief Stop receiving Input reports. + bool stop(std::error_code &ec); + + /// @brief Send a HID **Output** report to the device (host→device). + /// @param report_id The report ID (0 if the descriptor is not report-ID'd). + /// @param data The report payload (without the report-ID byte). + /// @param ec Set on failure. + /// @return true on success. + bool send_output_report(uint8_t report_id, std::span data, std::error_code &ec); + + /// @brief Request a report from the device (HID class Get_Report). + /// @param report_type One of HID_REPORT_TYPE_INPUT / _OUTPUT / _FEATURE. + /// @param report_id The report ID. + /// @param buffer Buffer that receives the report. + /// @param out_length Number of bytes written into @p buffer. + /// @param ec Set on failure. + bool get_report(uint8_t report_type, uint8_t report_id, std::span buffer, + size_t &out_length, std::error_code &ec); + + /// @brief Set the device's idle rate (HID class Set_Idle). + bool set_idle(uint8_t duration, uint8_t report_id, std::error_code &ec); + /// @brief Set the device's HID protocol (boot vs report; HID class Set_Protocol). + bool set_protocol(hid_report_protocol_t protocol, std::error_code &ec); + + /// @brief Whether the device is still connected/usable. + bool is_connected() const { return connected_.load(); } + + /// @brief The underlying driver handle (for advanced use). + hid_host_device_handle_t handle() const { return handle_; } + + private: + friend class UsbHost; + explicit HidDevice(hid_host_device_handle_t handle) + : handle_(handle) {} + + // Called by UsbHost (in the driver task) when the interface reports input. + void deliver_input(); + void mark_disconnected() { connected_.store(false); } + + hid_host_device_handle_t handle_{nullptr}; + std::atomic connected_{true}; + std::atomic started_{false}; + mutable std::mutex cb_mutex_; + input_callback_fn on_input_{nullptr}; + std::vector rx_buffer_ = std::vector(64); // grown as needed + }; + + /// @brief Callback invoked when a HID device is connected / disconnected. + using device_callback_fn = std::function &device)>; + + /// @brief Predicate deciding whether to open a newly attached HID interface. + /// Return false to ignore it (no callbacks, not listed in devices()). + using open_filter_fn = + std::function; + + /// @brief Configuration for the USB host. + struct Config { + device_callback_fn on_device_connected{nullptr}; ///< a HID device attached and opened + device_callback_fn on_device_disconnected{nullptr}; ///< a HID device detached + open_filter_fn should_open{nullptr}; ///< optional filter (default: open every HID interface) + bool auto_start{true}; ///< start receiving Input reports as soon as a device opens + size_t task_stack_size{4096}; ///< stack for the USB-host-library event task + size_t task_priority{5}; ///< priority of the USB-host-library event task + int task_core_id{-1}; ///< core for the host tasks (-1 = no affinity) + Logger::Verbosity log_level{Logger::Verbosity::WARN}; + }; + + /// @brief Construct a USB host. Call initialize() to actually install the stack. + explicit UsbHost(const Config &config); + + /// @brief Uninstall the stack (if still installed). + ~UsbHost(); + + UsbHost(const UsbHost &) = delete; + UsbHost &operator=(const UsbHost &) = delete; + + /// @brief Install the USB Host library + HID class driver and start the tasks. + /// @param ec Set on failure. + /// @return true on success. + bool initialize(std::error_code &ec); + + /// @brief Uninstall the HID class driver + USB Host library and stop the tasks. + /// @param ec Set on failure. + /// @return true on success. + bool deinitialize(std::error_code &ec); + + /// @brief Whether the host stack is installed. + bool is_initialized() const { return initialized_.load(); } + + /// @brief Snapshot of the currently connected (opened) HID devices. + std::vector> devices() const; + +private: + friend void espp_usb_host_driver_event_cb(hid_host_device_handle_t, const hid_host_driver_event_t, + void *); + friend void espp_usb_host_interface_event_cb(hid_host_device_handle_t, + const hid_host_interface_event_t, void *); + + // Trampoline targets (run in the HID driver's background task). + void on_driver_event(hid_host_device_handle_t handle, hid_host_driver_event_t event); + void on_interface_event(hid_host_device_handle_t handle, hid_host_interface_event_t event); + + // The USB Host library event-handling loop (own task). + static void lib_task_trampoline(void *arg); + void lib_task(); + + static HidDevice::Info read_info(hid_host_device_handle_t handle); + static HidDevice::Params read_params(hid_host_device_handle_t handle); + + Config config_; + std::atomic initialized_{false}; + std::atomic lib_task_run_{false}; + void *lib_task_handle_{nullptr}; // TaskHandle_t (kept type-erased to avoid a public FreeRTOS dep) + + mutable std::mutex devices_mutex_; + std::map> devices_; +}; + +} // namespace espp diff --git a/components/usb_host/src/usb_host.cpp b/components/usb_host/src/usb_host.cpp new file mode 100644 index 0000000000..acee2560a6 --- /dev/null +++ b/components/usb_host/src/usb_host.cpp @@ -0,0 +1,433 @@ +#include "usb_host.hpp" + +#include + +#include "esp_err.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "usb/usb_host.h" + +namespace espp { + +// --------------------------------------------------------------------------- +// C-linkage trampolines the HID host driver calls (from its background task). +// `arg` is the owning UsbHost. +// --------------------------------------------------------------------------- +extern "C" void espp_usb_host_driver_event_cb(hid_host_device_handle_t handle, + const hid_host_driver_event_t event, void *arg) { + if (arg) { + static_cast(arg)->on_driver_event(handle, event); + } +} + +extern "C" void espp_usb_host_interface_event_cb(hid_host_device_handle_t handle, + const hid_host_interface_event_t event, + void *arg) { + if (arg) { + static_cast(arg)->on_interface_event(handle, event); + } +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- +namespace { +// Map an esp_err_t to a std::error_code (the generic category is close enough +// for the intent: callers branch on "did it work", and the log carries detail). +std::error_code make_ec(esp_err_t err) { + switch (err) { + case ESP_OK: + return {}; + case ESP_ERR_INVALID_ARG: + return std::make_error_code(std::errc::invalid_argument); + case ESP_ERR_INVALID_STATE: + return std::make_error_code(std::errc::operation_not_permitted); + case ESP_ERR_TIMEOUT: + return std::make_error_code(std::errc::timed_out); + case ESP_ERR_NOT_FOUND: + case ESP_ERR_NOT_SUPPORTED: + return std::make_error_code(std::errc::no_such_device); + case ESP_ERR_NO_MEM: + return std::make_error_code(std::errc::not_enough_memory); + default: + return std::make_error_code(std::errc::io_error); + } +} + +std::string wchars_to_utf8(const wchar_t *ws) { + std::string out; + if (!ws) { + return out; + } + for (; *ws; ++ws) { + // The HID host driver stores string descriptors as UCS-2; keep ASCII and + // approximate the rest (device identity strings are informational). + wchar_t c = *ws; + out.push_back(c < 0x80 ? static_cast(c) : '?'); + } + return out; +} +} // namespace + +// --------------------------------------------------------------------------- +// UsbHost::HidDevice +// --------------------------------------------------------------------------- +UsbHost::HidDevice::Info UsbHost::HidDevice::info() const { return UsbHost::read_info(handle_); } + +UsbHost::HidDevice::Params UsbHost::HidDevice::params() const { + return UsbHost::read_params(handle_); +} + +std::span UsbHost::HidDevice::report_descriptor() const { + if (!connected_.load()) { + return {}; + } + size_t len = 0; + uint8_t *desc = hid_host_get_report_descriptor(handle_, &len); + if (!desc || len == 0) { + return {}; + } + return {desc, len}; +} + +void UsbHost::HidDevice::set_input_callback(input_callback_fn cb) { + std::lock_guard lk(cb_mutex_); + on_input_ = std::move(cb); +} + +bool UsbHost::HidDevice::start(std::error_code &ec) { + esp_err_t err = hid_host_device_start(handle_); + ec = make_ec(err); + if (!ec) { + started_.store(true); + } + return !ec; +} + +bool UsbHost::HidDevice::stop(std::error_code &ec) { + esp_err_t err = hid_host_device_stop(handle_); + ec = make_ec(err); + if (!ec) { + started_.store(false); + } + return !ec; +} + +bool UsbHost::HidDevice::send_output_report(uint8_t report_id, std::span data, + std::error_code &ec) { + if (!connected_.load()) { + ec = std::make_error_code(std::errc::no_such_device); + return false; + } + // hid_class_request_set_report takes a non-const buffer; copy the payload. + std::vector buf(data.begin(), data.end()); + esp_err_t err = hid_class_request_set_report(handle_, HID_REPORT_TYPE_OUTPUT, report_id, + buf.data(), buf.size()); + ec = make_ec(err); + return !ec; +} + +bool UsbHost::HidDevice::get_report(uint8_t report_type, uint8_t report_id, + std::span buffer, size_t &out_length, + std::error_code &ec) { + if (!connected_.load()) { + ec = std::make_error_code(std::errc::no_such_device); + return false; + } + size_t len = buffer.size(); + esp_err_t err = + hid_class_request_get_report(handle_, report_type, report_id, buffer.data(), &len); + ec = make_ec(err); + out_length = ec ? 0 : len; + return !ec; +} + +bool UsbHost::HidDevice::set_idle(uint8_t duration, uint8_t report_id, std::error_code &ec) { + esp_err_t err = hid_class_request_set_idle(handle_, duration, report_id); + ec = make_ec(err); + return !ec; +} + +bool UsbHost::HidDevice::set_protocol(hid_report_protocol_t protocol, std::error_code &ec) { + esp_err_t err = hid_class_request_set_protocol(handle_, protocol); + ec = make_ec(err); + return !ec; +} + +void UsbHost::HidDevice::deliver_input() { + // Copy the raw report into our buffer, then invoke the user callback. + size_t len = 0; + esp_err_t err = hid_host_device_get_raw_input_report_data(handle_, rx_buffer_.data(), + rx_buffer_.size(), &len); + if (err != ESP_OK) { + return; + } + input_callback_fn cb; + { + std::lock_guard lk(cb_mutex_); + cb = on_input_; + } + if (cb) { + cb(std::span(rx_buffer_.data(), len)); + } +} + +// --------------------------------------------------------------------------- +// UsbHost +// --------------------------------------------------------------------------- +UsbHost::UsbHost(const Config &config) + : BaseComponent("UsbHost", config.log_level) + , config_(config) {} + +UsbHost::~UsbHost() { + if (initialized_.load()) { + std::error_code ec; + deinitialize(ec); + } +} + +UsbHost::HidDevice::Info UsbHost::read_info(hid_host_device_handle_t handle) { + HidDevice::Info info; + hid_host_dev_info_t dev_info{}; + if (hid_host_get_device_info(handle, &dev_info) == ESP_OK) { + info.vid = dev_info.VID; + info.pid = dev_info.PID; + info.manufacturer = wchars_to_utf8(dev_info.iManufacturer); + info.product = wchars_to_utf8(dev_info.iProduct); + info.serial_number = wchars_to_utf8(dev_info.iSerialNumber); + } + return info; +} + +UsbHost::HidDevice::Params UsbHost::read_params(hid_host_device_handle_t handle) { + HidDevice::Params params; + hid_host_dev_params_t dev_params{}; + if (hid_host_device_get_params(handle, &dev_params) == ESP_OK) { + params.address = dev_params.addr; + params.interface_number = dev_params.iface_num; + params.sub_class = dev_params.sub_class; + params.protocol = dev_params.proto; + } + return params; +} + +bool UsbHost::initialize(std::error_code &ec) { + if (initialized_.load()) { + logger_.warn("already initialized"); + ec = std::make_error_code(std::errc::operation_in_progress); + return false; + } + + // 1) Install the USB Host library. + usb_host_config_t host_config = {}; + host_config.skip_phy_setup = false; + host_config.intr_flags = ESP_INTR_FLAG_LEVEL1; + esp_err_t err = usb_host_install(&host_config); + if (err != ESP_OK) { + logger_.error("usb_host_install failed: {}", esp_err_to_name(err)); + ec = make_ec(err); + return false; + } + + // 2) Spawn the USB-host-library event task. + lib_task_run_.store(true); + BaseType_t core = config_.task_core_id < 0 ? tskNO_AFFINITY : config_.task_core_id; + TaskHandle_t task = nullptr; + BaseType_t created = + xTaskCreatePinnedToCore(&UsbHost::lib_task_trampoline, "usb_host_lib", + config_.task_stack_size, this, config_.task_priority, &task, core); + if (created != pdPASS) { + logger_.error("failed to create usb host lib task"); + lib_task_run_.store(false); + usb_host_uninstall(); + ec = std::make_error_code(std::errc::not_enough_memory); + return false; + } + lib_task_handle_ = task; + + // 3) Install the HID class driver (with its own background task). + const hid_host_driver_config_t hid_config = { + .create_background_task = true, + .task_priority = config_.task_priority, + .stack_size = config_.task_stack_size, + .core_id = core, + .callback = &espp_usb_host_driver_event_cb, + .callback_arg = this, + }; + err = hid_host_install(&hid_config); + if (err != ESP_OK) { + logger_.error("hid_host_install failed: {}", esp_err_to_name(err)); + lib_task_run_.store(false); + usb_host_uninstall(); + ec = make_ec(err); + return false; + } + + initialized_.store(true); + logger_.info("USB host installed"); + ec.clear(); + return true; +} + +bool UsbHost::deinitialize(std::error_code &ec) { + if (!initialized_.load()) { + ec.clear(); + return true; + } + logger_.info("uninstalling USB host"); + + // Close + drop all devices. + { + std::lock_guard lk(devices_mutex_); + for (auto &[handle, dev] : devices_) { + dev->mark_disconnected(); + hid_host_device_close(handle); + } + devices_.clear(); + } + + // Uninstall the HID class driver (stops its background task). + esp_err_t err = hid_host_uninstall(); + if (err != ESP_OK) { + logger_.warn("hid_host_uninstall: {}", esp_err_to_name(err)); + } + + // Stop the lib task and free devices so uninstall can complete. + lib_task_run_.store(false); + usb_host_device_free_all(); + // Give the lib task a chance to observe ALL_FREE and exit. + vTaskDelay(pdMS_TO_TICKS(100)); + lib_task_handle_ = nullptr; + + err = usb_host_uninstall(); + if (err != ESP_OK) { + logger_.warn("usb_host_uninstall: {}", esp_err_to_name(err)); + } + + initialized_.store(false); + ec = make_ec(err); + return !ec; +} + +std::vector> UsbHost::devices() const { + std::vector> out; + std::lock_guard lk(devices_mutex_); + out.reserve(devices_.size()); + for (const auto &[handle, dev] : devices_) { + (void)handle; + out.push_back(dev); + } + return out; +} + +void UsbHost::lib_task_trampoline(void *arg) { static_cast(arg)->lib_task(); } + +void UsbHost::lib_task() { + while (lib_task_run_.load()) { + uint32_t event_flags = 0; + usb_host_lib_handle_events(portMAX_DELAY, &event_flags); + if (event_flags & USB_HOST_LIB_EVENT_FLAGS_NO_CLIENTS) { + // No registered clients: it is safe to release the devices. + usb_host_device_free_all(); + } + if (event_flags & USB_HOST_LIB_EVENT_FLAGS_ALL_FREE) { + logger_.debug("all USB devices freed"); + if (!lib_task_run_.load()) { + break; + } + } + } + vTaskDelete(nullptr); +} + +void UsbHost::on_driver_event(hid_host_device_handle_t handle, hid_host_driver_event_t event) { + if (event != HID_HOST_DRIVER_EVENT_CONNECTED) { + return; + } + HidDevice::Info info = read_info(handle); + HidDevice::Params params = read_params(handle); + logger_.info("HID device connected: VID={:#06x} PID={:#06x} iface={} proto={}", info.vid, + info.pid, params.interface_number, params.protocol); + + if (config_.should_open && !config_.should_open(info, params)) { + logger_.debug("filter rejected device; not opening"); + return; + } + + // Open the HID interface, routing its events back to us. + const hid_host_device_config_t dev_config = { + .callback = &espp_usb_host_interface_event_cb, + .callback_arg = this, + }; + esp_err_t err = hid_host_device_open(handle, &dev_config); + if (err != ESP_OK) { + logger_.error("hid_host_device_open failed: {}", esp_err_to_name(err)); + return; + } + + // Some devices report a boot protocol; force report protocol so we always get + // the full report-descriptor'd reports (ignore errors -- not all devices + // support the request). + hid_class_request_set_protocol(handle, HID_REPORT_PROTOCOL_REPORT); + + auto device = std::shared_ptr(new HidDevice(handle)); + { + std::lock_guard lk(devices_mutex_); + devices_[handle] = device; + } + + if (config_.auto_start) { + esp_err_t serr = hid_host_device_start(handle); + if (serr != ESP_OK) { + logger_.warn("hid_host_device_start failed: {}", esp_err_to_name(serr)); + } else { + device->started_.store(true); + } + } + + if (config_.on_device_connected) { + config_.on_device_connected(device); + } +} + +void UsbHost::on_interface_event(hid_host_device_handle_t handle, + hid_host_interface_event_t event) { + std::shared_ptr device; + { + std::lock_guard lk(devices_mutex_); + auto it = devices_.find(handle); + if (it != devices_.end()) { + device = it->second; + } + } + + switch (event) { + case HID_HOST_INTERFACE_EVENT_INPUT_REPORT: + if (device) { + device->deliver_input(); + } + break; + case HID_HOST_INTERFACE_EVENT_DISCONNECTED: + logger_.info("HID device disconnected"); + if (device) { + device->mark_disconnected(); + } + hid_host_device_close(handle); + { + std::lock_guard lk(devices_mutex_); + devices_.erase(handle); + } + if (device && config_.on_device_disconnected) { + config_.on_device_disconnected(device); + } + break; + case HID_HOST_INTERFACE_EVENT_TRANSFER_ERROR: + logger_.warn("HID transfer error"); + break; + default: + break; + } +} + +} // namespace espp diff --git a/doc/Doxyfile b/doc/Doxyfile index f4c329a909..d21bb82974 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -202,6 +202,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/tt21100/example/main/tt21100_example.cpp \ $(PROJECT_PATH)/components/usb_device/example/main/usb_cdc_example.cpp \ $(PROJECT_PATH)/components/usb_device/xinput_example/main/xinput_example.cpp \ + $(PROJECT_PATH)/components/usb_host/example/main/usb_host_example.cpp \ $(PROJECT_PATH)/components/vl53l/example/main/vl53l_example.cpp \ $(PROJECT_PATH)/components/wifi/example/main/wifi_example.cpp \ $(PROJECT_PATH)/components/wrover-kit/example/main/wrover_kit_example.cpp \ @@ -471,6 +472,7 @@ INPUT = \ $(PROJECT_PATH)/components/usb_device/include/usb_device.hpp \ $(PROJECT_PATH)/components/usb_device/include/usb_cdc.hpp \ $(PROJECT_PATH)/components/usb_device/include/xinput.hpp \ + $(PROJECT_PATH)/components/usb_host/include/usb_host.hpp \ $(PROJECT_PATH)/components/vl53l/include/vl53l.hpp \ $(PROJECT_PATH)/components/utils/include/bitmask_operators.hpp \ $(PROJECT_PATH)/components/wifi/include/wifi.hpp \ diff --git a/doc/en/buses/index.rst b/doc/en/buses/index.rst index 65a8b92972..1ad45919f1 100644 --- a/doc/en/buses/index.rst +++ b/doc/en/buses/index.rst @@ -13,3 +13,4 @@ external chips. twai canopen usb_cdc + usb_host diff --git a/doc/en/buses/usb_host.rst b/doc/en/buses/usb_host.rst new file mode 100644 index 0000000000..3ddb1cf825 --- /dev/null +++ b/doc/en/buses/usb_host.rst @@ -0,0 +1,108 @@ +USB Host Component +================== + +Overview +-------- + +``espp::UsbHost`` is 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 — installing +the host library and HID driver, running their event tasks, opening interfaces, +and teardown — and marshals the driver's C callbacks into per-device +``std::function`` s. Like the rest of espp it does not throw and reports failures +via ``std::error_code``. + +Report directions are named from the connected **device's** point of view, as in +the USB HID spec: an *Input* report is device→host (delivered to a ``HidDevice`` +input callback), an *Output* report is host→device (sent with +``HidDevice::send_output_report()``). This mirrors ``espp::UsbDevice`` exactly, +so the two ends of a link (for example the device and host roles of the ``wdi`` +component) line up. + +Features +-------- + +- Installs / uninstalls the USB Host library and the HID class driver and runs + their event-handling tasks. +- Device **connect / disconnect** callbacks, with an optional filter predicate so + only the devices you care about are opened (by VID/PID, interface, etc.). +- Per-device **Input report** callback (device→host) delivering the raw report + bytes (report id in byte 0 for report-ID'd descriptors). +- Send **Output reports** (host→device) and issue the HID class control requests + (Get/Set Report, Get/Set Idle, Set Protocol). +- Read a connected device's **HID report descriptor**. +- No exceptions; ``initialize()`` reports failures via ``std::error_code``. + +Basic Usage +----------- + +.. code-block:: cpp + + espp::UsbHost host({ + .on_device_connected = + [](const std::shared_ptr &dev) { + auto info = dev->info(); + printf("connected: %s %s %04x:%04x\n", info.manufacturer.c_str(), + info.product.c_str(), info.vid, info.pid); + dev->set_input_callback([](std::span report) { + // handle a device->host Input report (report[0] is the report id) + }); + }, + .on_device_disconnected = [](const auto &) { /* ... */ }, + // optional: only open the devices you want + // .should_open = [](const auto &info, const auto &) { return info.vid == 0x1209; }, + }); + + std::error_code ec; + if (!host.initialize(ec)) { /* handle ec */ } + + // later, send an Output report (host->device): + std::array payload{/* ... */}; + host.devices().front()->send_output_report(/*report_id*/ 0x02, payload, ec); + +Requirements and caveats +------------------------ + +- USB-OTG **host** mode is only available on the **ESP32-S2, -S3 and -P4**. +- Only one ``espp::UsbHost`` may exist at a time (the USB Host library and HID + class driver are global singletons). It cannot coexist with ``espp::UsbDevice`` + (they both claim the USB-OTG peripheral). +- The board must be able to source **VBUS** to the attached device — a board with + a USB-A host port / VBUS switch, or a self-powered hub. ``UsbHost`` does not + manage board power. +- On the ESP32-S3 the USB-Serial-JTAG shares the USB-OTG PHY, so when the host + role is active the **console must run on UART0** (see the example's + ``sdkconfig.defaults``). +- The ``usb`` and ``usb_host_hid`` components come from the ESP Component + Registry via the IDF component manager. On ESP-IDF ≥ 6.0 ``usb_host_hid`` + declares its ``usb`` dependency only through the manager, so build with the + component manager **on** (the default) rather than the manager-off flow used by + the device-side USB examples. + +Roadmap +------- + +Only the **HID** class driver is wired up today (it covers mice, keyboards, +gamepads and vendor HID devices, and is what the ``wdi`` host role needs). The +component is structured so other class drivers (CDC-ACM, MSC) can be layered in +later without changing the host-lifecycle model — the same way +``espp::UsbDevice`` composes CDC / Vendor / HID functions on the device side. + +.. ------------------------------- Example ------------------------------------- + +.. toctree:: + + usb_host_example.md + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/usb_host.inc diff --git a/doc/en/buses/usb_host_example.md b/doc/en/buses/usb_host_example.md new file mode 100644 index 0000000000..81588eddce --- /dev/null +++ b/doc/en/buses/usb_host_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/usb_host/example/README.md +``` From 3386006dc770c489d9dc3e446d55fd09cd1ffdd7 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 23:02:43 -0500 Subject: [PATCH 02/10] fix(usb_host): address review feedback (teardown race, dangling span, 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 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/include/usb_host.hpp | 29 ++++++++---- components/usb_host/src/usb_host.cpp | 60 ++++++++++++++++++------ doc/en/buses/usb_host.rst | 2 +- 3 files changed, 68 insertions(+), 23 deletions(-) diff --git a/components/usb_host/include/usb_host.hpp b/components/usb_host/include/usb_host.hpp index f076e5d67b..803e9f8737 100644 --- a/components/usb_host/include/usb_host.hpp +++ b/components/usb_host/include/usb_host.hpp @@ -112,11 +112,11 @@ class UsbHost : public BaseComponent { /// @brief The parameters of this HID interface. Params params() const; - /// @brief The device's HID report descriptor. - /// @return The raw report-descriptor bytes (empty if unavailable). The - /// underlying storage is owned by the driver and is valid only while - /// the device is connected. - std::span report_descriptor() const; + /// @brief The device's HID report descriptor (a copy). + /// @return The raw report-descriptor bytes (empty if unavailable). A copy is + /// returned rather than a view into driver-owned memory, so it stays + /// valid even if the device disconnects concurrently. + std::vector report_descriptor() const; /// @brief Install the callback invoked with each Input report. void set_input_callback(input_callback_fn cb); @@ -156,8 +156,9 @@ class UsbHost : public BaseComponent { private: friend class UsbHost; - explicit HidDevice(hid_host_device_handle_t handle) - : handle_(handle) {} + HidDevice(hid_host_device_handle_t handle, size_t rx_buffer_size) + : handle_(handle) + , rx_buffer_(rx_buffer_size) {} // Called by UsbHost (in the driver task) when the interface reports input. void deliver_input(); @@ -168,7 +169,11 @@ class UsbHost : public BaseComponent { std::atomic started_{false}; mutable std::mutex cb_mutex_; input_callback_fn on_input_{nullptr}; - std::vector rx_buffer_ = std::vector(64); // grown as needed + // Fixed-size scratch for the current Input report. Sized from + // Config::max_input_report_size; a report longer than this is truncated (the + // driver copies at most this many bytes), so raise it if your device sends + // larger reports. + std::vector rx_buffer_; }; /// @brief Callback invoked when a HID device is connected / disconnected. @@ -188,6 +193,10 @@ class UsbHost : public BaseComponent { size_t task_stack_size{4096}; ///< stack for the USB-host-library event task size_t task_priority{5}; ///< priority of the USB-host-library event task int task_core_id{-1}; ///< core for the host tasks (-1 = no affinity) + /// @brief Per-device Input-report buffer size. A report larger than this is + /// truncated (the driver copies at most this many bytes); raise it if + /// your device sends larger reports. 64 covers full-speed HID. + size_t max_input_report_size{64}; Logger::Verbosity log_level{Logger::Verbosity::WARN}; }; @@ -229,6 +238,9 @@ class UsbHost : public BaseComponent { // The USB Host library event-handling loop (own task). static void lib_task_trampoline(void *arg); void lib_task(); + // Stop + join the lib task: signal it, unblock its event wait, and wait + // (bounded) for it to actually exit before the library is uninstalled. + void stop_lib_task(); static HidDevice::Info read_info(hid_host_device_handle_t handle); static HidDevice::Params read_params(hid_host_device_handle_t handle); @@ -236,6 +248,7 @@ class UsbHost : public BaseComponent { Config config_; std::atomic initialized_{false}; std::atomic lib_task_run_{false}; + std::atomic lib_task_done_{false}; // set by the lib task as it exits (join signal) void *lib_task_handle_{nullptr}; // TaskHandle_t (kept type-erased to avoid a public FreeRTOS dep) mutable std::mutex devices_mutex_; diff --git a/components/usb_host/src/usb_host.cpp b/components/usb_host/src/usb_host.cpp index acee2560a6..2b9866a373 100644 --- a/components/usb_host/src/usb_host.cpp +++ b/components/usb_host/src/usb_host.cpp @@ -79,16 +79,19 @@ UsbHost::HidDevice::Params UsbHost::HidDevice::params() const { return UsbHost::read_params(handle_); } -std::span UsbHost::HidDevice::report_descriptor() const { +std::vector UsbHost::HidDevice::report_descriptor() const { if (!connected_.load()) { return {}; } size_t len = 0; + // The driver returns a pointer into memory it owns, valid only while the + // device is connected. Copy it out so the caller can't be left with a dangling + // reference if the device disconnects concurrently. uint8_t *desc = hid_host_get_report_descriptor(handle_, &len); if (!desc || len == 0) { return {}; } - return {desc, len}; + return std::vector(desc, desc + len); } void UsbHost::HidDevice::set_input_callback(input_callback_fn cb) { @@ -120,10 +123,12 @@ bool UsbHost::HidDevice::send_output_report(uint8_t report_id, std::span buf(data.begin(), data.end()); + // hid_class_request_set_report's signature is non-const, but a SET_REPORT is a + // host->device transfer: the driver only reads the buffer, it does not write + // it. const_cast avoids an allocation + copy on every output report (hot path + // for e.g. WDI feedback). esp_err_t err = hid_class_request_set_report(handle_, HID_REPORT_TYPE_OUTPUT, report_id, - buf.data(), buf.size()); + const_cast(data.data()), data.size()); ec = make_ec(err); return !ec; } @@ -232,6 +237,7 @@ bool UsbHost::initialize(std::error_code &ec) { // 2) Spawn the USB-host-library event task. lib_task_run_.store(true); + lib_task_done_.store(false); BaseType_t core = config_.task_core_id < 0 ? tskNO_AFFINITY : config_.task_core_id; TaskHandle_t task = nullptr; BaseType_t created = @@ -258,7 +264,7 @@ bool UsbHost::initialize(std::error_code &ec) { err = hid_host_install(&hid_config); if (err != ESP_OK) { logger_.error("hid_host_install failed: {}", esp_err_to_name(err)); - lib_task_run_.store(false); + stop_lib_task(); // join the lib task before uninstalling the library usb_host_uninstall(); ec = make_ec(err); return false; @@ -277,15 +283,22 @@ bool UsbHost::deinitialize(std::error_code &ec) { } logger_.info("uninstalling USB host"); - // Close + drop all devices. + // Collect the device handles under the lock, then close them *outside* it: the + // driver's close path can run callbacks that also take devices_mutex_, so + // closing while holding it risks lock inversion. + std::vector handles; { std::lock_guard lk(devices_mutex_); + handles.reserve(devices_.size()); for (auto &[handle, dev] : devices_) { dev->mark_disconnected(); - hid_host_device_close(handle); + handles.push_back(handle); } devices_.clear(); } + for (auto handle : handles) { + hid_host_device_close(handle); + } // Uninstall the HID class driver (stops its background task). esp_err_t err = hid_host_uninstall(); @@ -293,12 +306,11 @@ bool UsbHost::deinitialize(std::error_code &ec) { logger_.warn("hid_host_uninstall: {}", esp_err_to_name(err)); } - // Stop the lib task and free devices so uninstall can complete. - lib_task_run_.store(false); + // Free any remaining devices so the library can be uninstalled, then stop + + // join the lib task (unblocking it so it observes the stop flag promptly + // rather than relying on a fixed delay). usb_host_device_free_all(); - // Give the lib task a chance to observe ALL_FREE and exit. - vTaskDelay(pdMS_TO_TICKS(100)); - lib_task_handle_ = nullptr; + stop_lib_task(); err = usb_host_uninstall(); if (err != ESP_OK) { @@ -338,9 +350,29 @@ void UsbHost::lib_task() { } } } + lib_task_done_.store(true); // signal stop_lib_task() that we have exited vTaskDelete(nullptr); } +void UsbHost::stop_lib_task() { + if (lib_task_handle_ == nullptr) { + return; + } + lib_task_run_.store(false); + // The task blocks in usb_host_lib_handle_events(portMAX_DELAY); unblock it so + // it observes the stop flag and returns instead of waiting for an event. + usb_host_lib_unblock(); + // Join: wait (bounded) for the task to actually exit before the caller + // uninstalls the library out from under it. + for (int i = 0; i < 100 && !lib_task_done_.load(); ++i) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + if (!lib_task_done_.load()) { + logger_.warn("usb host lib task did not exit in time"); + } + lib_task_handle_ = nullptr; +} + void UsbHost::on_driver_event(hid_host_device_handle_t handle, hid_host_driver_event_t event) { if (event != HID_HOST_DRIVER_EVENT_CONNECTED) { return; @@ -371,7 +403,7 @@ void UsbHost::on_driver_event(hid_host_device_handle_t handle, hid_host_driver_e // support the request). hid_class_request_set_protocol(handle, HID_REPORT_PROTOCOL_REPORT); - auto device = std::shared_ptr(new HidDevice(handle)); + auto device = std::shared_ptr(new HidDevice(handle, config_.max_input_report_size)); { std::lock_guard lk(devices_mutex_); devices_[handle] = device; diff --git a/doc/en/buses/usb_host.rst b/doc/en/buses/usb_host.rst index 3ddb1cf825..99a1ce2574 100644 --- a/doc/en/buses/usb_host.rst +++ b/doc/en/buses/usb_host.rst @@ -15,7 +15,7 @@ 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 — installing the host library and HID driver, running their event tasks, opening interfaces, and teardown — and marshals the driver's C callbacks into per-device -``std::function`` s. Like the rest of espp it does not throw and reports failures +``std::function``\ s. Like the rest of espp it does not throw and reports failures via ``std::error_code``. Report directions are named from the connected **device's** point of view, as in From a0ab3c0f961ad771420df17cc1fba600a68d1a87 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 23:41:54 -0500 Subject: [PATCH 03/10] fix(usb_host): use https for the repository URL in the manifest git:// is plaintext and can be blocked/MITM'd; use https. Addresses a review comment. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/idf_component.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/usb_host/idf_component.yml b/components/usb_host/idf_component.yml index 083b705e74..86a04f7bf1 100644 --- a/components/usb_host/idf_component.yml +++ b/components/usb_host/idf_component.yml @@ -2,7 +2,7 @@ license: "MIT" description: "Native USB host (ESP-IDF USB Host library + usb_host_hid): enumerate and talk to USB HID devices (mice, keyboards, gamepads, vendor HID) from an ESP32-S2/-S3/-P4" url: "https://github.com/esp-cpp/espp/tree/main/components/usb_host" -repository: "git://github.com/esp-cpp/espp.git" +repository: "https://github.com/esp-cpp/espp.git" maintainers: - William Emfinger documentation: "https://esp-cpp.github.io/espp/usb_host/usb_host.html" From 2c202905d402860e1c7c692382d34315dedaa95e Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 01:04:44 -0500 Subject: [PATCH 04/10] fix(usb_host): guard HidDevice ops on disconnect; refine error mapping - 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 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/src/usb_host.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/components/usb_host/src/usb_host.cpp b/components/usb_host/src/usb_host.cpp index 2b9866a373..bdd14cab24 100644 --- a/components/usb_host/src/usb_host.cpp +++ b/components/usb_host/src/usb_host.cpp @@ -46,8 +46,9 @@ std::error_code make_ec(esp_err_t err) { case ESP_ERR_TIMEOUT: return std::make_error_code(std::errc::timed_out); case ESP_ERR_NOT_FOUND: - case ESP_ERR_NOT_SUPPORTED: return std::make_error_code(std::errc::no_such_device); + case ESP_ERR_NOT_SUPPORTED: + return std::make_error_code(std::errc::not_supported); case ESP_ERR_NO_MEM: return std::make_error_code(std::errc::not_enough_memory); default: @@ -100,6 +101,10 @@ void UsbHost::HidDevice::set_input_callback(input_callback_fn cb) { } bool UsbHost::HidDevice::start(std::error_code &ec) { + if (!connected_.load()) { + ec = std::make_error_code(std::errc::no_such_device); + return false; + } esp_err_t err = hid_host_device_start(handle_); ec = make_ec(err); if (!ec) { @@ -109,6 +114,10 @@ bool UsbHost::HidDevice::start(std::error_code &ec) { } bool UsbHost::HidDevice::stop(std::error_code &ec) { + if (!connected_.load()) { + ec = std::make_error_code(std::errc::no_such_device); + return false; + } esp_err_t err = hid_host_device_stop(handle_); ec = make_ec(err); if (!ec) { @@ -149,12 +158,20 @@ bool UsbHost::HidDevice::get_report(uint8_t report_type, uint8_t report_id, } bool UsbHost::HidDevice::set_idle(uint8_t duration, uint8_t report_id, std::error_code &ec) { + if (!connected_.load()) { + ec = std::make_error_code(std::errc::no_such_device); + return false; + } esp_err_t err = hid_class_request_set_idle(handle_, duration, report_id); ec = make_ec(err); return !ec; } bool UsbHost::HidDevice::set_protocol(hid_report_protocol_t protocol, std::error_code &ec) { + if (!connected_.load()) { + ec = std::make_error_code(std::errc::no_such_device); + return false; + } esp_err_t err = hid_class_request_set_protocol(handle_, protocol); ec = make_ec(err); return !ec; From 0583272b7d529272dbee1a22111be31964536fc2 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 01:04:44 -0500 Subject: [PATCH 05/10] fix(usb_host): run driver ops + user callbacks on a dispatch task (not 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 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/CMakeLists.txt | 2 +- components/usb_host/include/usb_host.hpp | 141 +++++--- components/usb_host/src/usb_host.cpp | 405 ++++++++++++++++------- 3 files changed, 379 insertions(+), 169 deletions(-) diff --git a/components/usb_host/CMakeLists.txt b/components/usb_host/CMakeLists.txt index f7f1e11bc3..bbd864c1aa 100644 --- a/components/usb_host/CMakeLists.txt +++ b/components/usb_host/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( INCLUDE_DIRS "include" SRC_DIRS "src" - REQUIRES base_component usb usb_host_hid + REQUIRES base_component task usb usb_host_hid ) diff --git a/components/usb_host/include/usb_host.hpp b/components/usb_host/include/usb_host.hpp index 803e9f8737..5e65c7705c 100644 --- a/components/usb_host/include/usb_host.hpp +++ b/components/usb_host/include/usb_host.hpp @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include #include #include @@ -14,6 +16,7 @@ #include "usb/hid_host.h" // usb_host_hid managed component (pulls in the usb host library) #include "base_component.hpp" +#include "task.hpp" namespace espp { @@ -53,9 +56,23 @@ extern "C" void espp_usb_host_interface_event_cb(hid_host_device_handle_t handle * `espp::UsbDevice`'s HID function, so the two sides of a link (e.g. the two * roles of the `wdi` component) mirror each other. * + * **Threading model.** The HID class driver delivers its events on its own + * background task, and that same task is the one that completes the driver's + * synchronous control transfers (Set/Get Report, Set Protocol, ...). A control + * transfer issued *from* that task can therefore never complete. `UsbHost` + * handles this the way the ESP-IDF HID host example does: the driver task only + * *enqueues* events (copying each Input report out of the driver's buffer, which + * must happen inside the callback), and a dedicated **dispatch task** owned by + * `UsbHost` opens/starts/closes devices and invokes every user callback. So it + * is safe to call `HidDevice::send_output_report()` (and the other device + * methods) from within the callbacks, and callbacks never stall the USB stack. + * Events for a device are delivered in order (connected → inputs → disconnected). + * Device methods may also be called from any application task; each device + * serializes its driver calls internally. + * * The class is idiomatic espp: it does not throw, reports failures via - * `std::error_code`, and marshals the USB-host driver's C callbacks (which run - * in the HID driver's background task) into per-device `std::function`s. + * `std::error_code`, and marshals the USB-host driver's C callbacks into + * per-device `std::function`s. * * @note Only one `espp::UsbHost` may exist at a time: the USB Host library and * the HID class driver are global singletons. USB-OTG **host** mode is @@ -63,10 +80,8 @@ extern "C" void espp_usb_host_interface_event_cb(hid_host_device_handle_t handle * must be able to source VBUS to the attached device (a self-powered hub * or a board with a VBUS switch); the host does not manage board power. * - * @note Device-connected / disconnected / input-report callbacks are invoked - * from the HID driver's background task. Keep them short and non-blocking; - * it is safe to call `HidDevice::send_output_report()` and the other - * device methods from within them. + * @note Callbacks run on the dispatch task. Keep them reasonably short: a + * callback that blocks delays every later event (and `deinitialize()`). * * \section usb_host_ex1 UsbHost (generic HID host) Example * \snippet usb_host_example.cpp usb_host_example @@ -80,7 +95,8 @@ class UsbHost : public BaseComponent { * application (as a `std::shared_ptr`) through the connect / disconnect * callbacks and `UsbHost::devices()`. Owns nothing itself -- the underlying * driver handle is owned by `UsbHost` -- and becomes inert once the device is - * disconnected (methods then fail with `std::errc::no_such_device`). + * disconnected (methods then fail with `std::errc::no_such_device`; the + * identity accessors keep returning the values captured at connect time). */ class HidDevice { public: @@ -107,18 +123,19 @@ class UsbHost : public BaseComponent { uint8_t protocol{0}; ///< bInterfaceProtocol (1 = keyboard, 2 = mouse, 0 = none) }; - /// @brief The identity of the connected device. - Info info() const; - /// @brief The parameters of this HID interface. - Params params() const; + /// @brief The identity of the connected device (captured at connect time, + /// so it stays valid after a disconnect). + const Info &info() const { return info_; } + /// @brief The parameters of this HID interface (captured at connect time). + const Params ¶ms() const { return params_; } - /// @brief The device's HID report descriptor (a copy). - /// @return The raw report-descriptor bytes (empty if unavailable). A copy is - /// returned rather than a view into driver-owned memory, so it stays - /// valid even if the device disconnects concurrently. - std::vector report_descriptor() const; + /// @brief The device's HID report descriptor (captured at connect time; a + /// copy owned by this object, not a view into driver memory). + const std::vector &report_descriptor() const { return report_descriptor_; } - /// @brief Install the callback invoked with each Input report. + /// @brief Install the callback invoked with each Input report. Install it + /// from the connect callback: `UsbHost` invokes that *before* it + /// starts the device, so no report is missed. void set_input_callback(input_callback_fn cb); /// @brief Start receiving Input reports (called automatically on open when @@ -151,29 +168,37 @@ class UsbHost : public BaseComponent { /// @brief Whether the device is still connected/usable. bool is_connected() const { return connected_.load(); } - /// @brief The underlying driver handle (for advanced use). + /// @brief The underlying driver handle (for advanced use; only valid while + /// is_connected()). hid_host_device_handle_t handle() const { return handle_; } private: friend class UsbHost; - HidDevice(hid_host_device_handle_t handle, size_t rx_buffer_size) + HidDevice(hid_host_device_handle_t handle, Info info, Params params, + std::vector report_descriptor) : handle_(handle) - , rx_buffer_(rx_buffer_size) {} + , info_(std::move(info)) + , params_(std::move(params)) + , report_descriptor_(std::move(report_descriptor)) {} - // Called by UsbHost (in the driver task) when the interface reports input. - void deliver_input(); - void mark_disconnected() { connected_.store(false); } + // Called by UsbHost (on the dispatch task) with a copy of an Input report. + void deliver_input(std::span data); + // Called by UsbHost to retire the device: marks it inert and closes the + // driver handle, serialized against any in-flight driver call. + void retire(); hid_host_device_handle_t handle_{nullptr}; + const Info info_; + const Params params_; + const std::vector report_descriptor_; std::atomic connected_{true}; std::atomic started_{false}; + // Serializes every driver call made through this object against the close + // performed on disconnect, so a control transfer in flight on an app task + // can't race the driver freeing the interface. + mutable std::mutex io_mutex_; mutable std::mutex cb_mutex_; input_callback_fn on_input_{nullptr}; - // Fixed-size scratch for the current Input report. Sized from - // Config::max_input_report_size; a report longer than this is truncated (the - // driver copies at most this many bytes), so raise it if your device sends - // larger reports. - std::vector rx_buffer_; }; /// @brief Callback invoked when a HID device is connected / disconnected. @@ -189,14 +214,21 @@ class UsbHost : public BaseComponent { device_callback_fn on_device_connected{nullptr}; ///< a HID device attached and opened device_callback_fn on_device_disconnected{nullptr}; ///< a HID device detached open_filter_fn should_open{nullptr}; ///< optional filter (default: open every HID interface) - bool auto_start{true}; ///< start receiving Input reports as soon as a device opens - size_t task_stack_size{4096}; ///< stack for the USB-host-library event task - size_t task_priority{5}; ///< priority of the USB-host-library event task - int task_core_id{-1}; ///< core for the host tasks (-1 = no affinity) - /// @brief Per-device Input-report buffer size. A report larger than this is + bool auto_start{true}; ///< start receiving Input reports as soon as a device opens + size_t task_priority{5}; ///< priority of the internal tasks + int task_core_id{-1}; ///< core for the internal tasks (-1 = no affinity) + size_t lib_task_stack_size{4096}; ///< stack for the USB-host-library event task + size_t hid_task_stack_size{4096}; ///< stack for the HID class driver's task (it only enqueues) + /// @brief Stack for the dispatch task that runs the user callbacks (size it + /// for what your callbacks do -- logging with fmt, protocol work, ...). + size_t dispatch_task_stack_size{6 * 1024}; + /// @brief Per-device Input-report copy size. A report larger than this is /// truncated (the driver copies at most this many bytes); raise it if /// your device sends larger reports. 64 covers full-speed HID. size_t max_input_report_size{64}; + /// @brief Bound on queued-but-undispatched events; when full, further Input + /// reports are dropped (logged) rather than blocking the USB stack. + size_t max_queued_events{32}; Logger::Verbosity log_level{Logger::Verbosity::WARN}; }; @@ -215,7 +247,12 @@ class UsbHost : public BaseComponent { bool initialize(std::error_code &ec); /// @brief Uninstall the HID class driver + USB Host library and stop the tasks. - /// @param ec Set on failure. + /// Attached devices are closed (their disconnect callbacks fire, on the + /// calling task) and the root port is powered down so the driver can + /// release them. Must not be called from within a `UsbHost` callback. + /// @param ec Set on failure. If the driver cannot release a device the host + /// stays initialized (is_initialized() remains true) and false is + /// returned, rather than tearing down under a live driver. /// @return true on success. bool deinitialize(std::error_code &ec); @@ -231,15 +268,29 @@ class UsbHost : public BaseComponent { friend void espp_usb_host_interface_event_cb(hid_host_device_handle_t, const hid_host_interface_event_t, void *); - // Trampoline targets (run in the HID driver's background task). + // An event queued by the HID driver task for the dispatch task. + struct Event { + enum class Type { NewDevice, Input, Disconnected } type; + hid_host_device_handle_t handle{nullptr}; + std::vector data{}; // Input: the report bytes (copied on the driver task) + }; + + // Trampoline targets: run on the HID driver's background task. They only + // enqueue (plus the Input-report copy that must happen inside the callback). void on_driver_event(hid_host_device_handle_t handle, hid_host_driver_event_t event); void on_interface_event(hid_host_device_handle_t handle, hid_host_interface_event_t event); + void enqueue(Event &&ev); + + // The dispatch task: drains the queue and does the real work / user callbacks. + bool dispatch_task_fn(std::mutex &m, std::condition_variable &cv); + void handle_new_device(hid_host_device_handle_t handle); + void handle_input(hid_host_device_handle_t handle, std::span data); + void handle_disconnected(hid_host_device_handle_t handle); + std::shared_ptr find_device(hid_host_device_handle_t handle) const; + void stop_dispatch_task(); // The USB Host library event-handling loop (own task). - static void lib_task_trampoline(void *arg); - void lib_task(); - // Stop + join the lib task: signal it, unblock its event wait, and wait - // (bounded) for it to actually exit before the library is uninstalled. + bool lib_task_fn(std::mutex &m, std::condition_variable &cv); void stop_lib_task(); static HidDevice::Info read_info(hid_host_device_handle_t handle); @@ -247,9 +298,17 @@ class UsbHost : public BaseComponent { Config config_; std::atomic initialized_{false}; + + // USB Host library task. std::atomic lib_task_run_{false}; - std::atomic lib_task_done_{false}; // set by the lib task as it exits (join signal) - void *lib_task_handle_{nullptr}; // TaskHandle_t (kept type-erased to avoid a public FreeRTOS dep) + std::unique_ptr lib_task_; + + // Event queue (driver task -> dispatch task) + dispatch task. + std::mutex queue_mutex_; + std::condition_variable queue_cv_; + std::deque queue_; + std::atomic dispatch_run_{false}; + std::unique_ptr dispatch_task_; mutable std::mutex devices_mutex_; std::map> devices_; diff --git a/components/usb_host/src/usb_host.cpp b/components/usb_host/src/usb_host.cpp index bdd14cab24..f7a3fedf0d 100644 --- a/components/usb_host/src/usb_host.cpp +++ b/components/usb_host/src/usb_host.cpp @@ -1,6 +1,8 @@ #include "usb_host.hpp" +#include #include +#include #include "esp_err.h" #include "freertos/FreeRTOS.h" @@ -8,6 +10,8 @@ #include "usb/usb_host.h" +using namespace std::chrono_literals; + namespace espp { // --------------------------------------------------------------------------- @@ -69,38 +73,20 @@ std::string wchars_to_utf8(const wchar_t *ws) { } return out; } + +constexpr uint8_t kHidSubclassBoot = 1; } // namespace // --------------------------------------------------------------------------- // UsbHost::HidDevice // --------------------------------------------------------------------------- -UsbHost::HidDevice::Info UsbHost::HidDevice::info() const { return UsbHost::read_info(handle_); } - -UsbHost::HidDevice::Params UsbHost::HidDevice::params() const { - return UsbHost::read_params(handle_); -} - -std::vector UsbHost::HidDevice::report_descriptor() const { - if (!connected_.load()) { - return {}; - } - size_t len = 0; - // The driver returns a pointer into memory it owns, valid only while the - // device is connected. Copy it out so the caller can't be left with a dangling - // reference if the device disconnects concurrently. - uint8_t *desc = hid_host_get_report_descriptor(handle_, &len); - if (!desc || len == 0) { - return {}; - } - return std::vector(desc, desc + len); -} - void UsbHost::HidDevice::set_input_callback(input_callback_fn cb) { std::lock_guard lk(cb_mutex_); on_input_ = std::move(cb); } bool UsbHost::HidDevice::start(std::error_code &ec) { + std::lock_guard lk(io_mutex_); if (!connected_.load()) { ec = std::make_error_code(std::errc::no_such_device); return false; @@ -114,6 +100,7 @@ bool UsbHost::HidDevice::start(std::error_code &ec) { } bool UsbHost::HidDevice::stop(std::error_code &ec) { + std::lock_guard lk(io_mutex_); if (!connected_.load()) { ec = std::make_error_code(std::errc::no_such_device); return false; @@ -128,6 +115,7 @@ bool UsbHost::HidDevice::stop(std::error_code &ec) { bool UsbHost::HidDevice::send_output_report(uint8_t report_id, std::span data, std::error_code &ec) { + std::lock_guard lk(io_mutex_); if (!connected_.load()) { ec = std::make_error_code(std::errc::no_such_device); return false; @@ -145,6 +133,7 @@ bool UsbHost::HidDevice::send_output_report(uint8_t report_id, std::span buffer, size_t &out_length, std::error_code &ec) { + std::lock_guard lk(io_mutex_); if (!connected_.load()) { ec = std::make_error_code(std::errc::no_such_device); return false; @@ -158,6 +147,7 @@ bool UsbHost::HidDevice::get_report(uint8_t report_type, uint8_t report_id, } bool UsbHost::HidDevice::set_idle(uint8_t duration, uint8_t report_id, std::error_code &ec) { + std::lock_guard lk(io_mutex_); if (!connected_.load()) { ec = std::make_error_code(std::errc::no_such_device); return false; @@ -168,6 +158,7 @@ bool UsbHost::HidDevice::set_idle(uint8_t duration, uint8_t report_id, std::erro } bool UsbHost::HidDevice::set_protocol(hid_report_protocol_t protocol, std::error_code &ec) { + std::lock_guard lk(io_mutex_); if (!connected_.load()) { ec = std::make_error_code(std::errc::no_such_device); return false; @@ -177,22 +168,25 @@ bool UsbHost::HidDevice::set_protocol(hid_report_protocol_t protocol, std::error return !ec; } -void UsbHost::HidDevice::deliver_input() { - // Copy the raw report into our buffer, then invoke the user callback. - size_t len = 0; - esp_err_t err = hid_host_device_get_raw_input_report_data(handle_, rx_buffer_.data(), - rx_buffer_.size(), &len); - if (err != ESP_OK) { - return; - } +void UsbHost::HidDevice::deliver_input(std::span data) { input_callback_fn cb; { std::lock_guard lk(cb_mutex_); cb = on_input_; } if (cb) { - cb(std::span(rx_buffer_.data(), len)); + cb(data); + } +} + +void UsbHost::HidDevice::retire() { + // Taking io_mutex_ here waits for any driver call in flight on another task + // to finish before the interface is closed (and its resources freed). + std::lock_guard lk(io_mutex_); + if (!connected_.exchange(false)) { + return; // already retired } + hid_host_device_close(handle_); } // --------------------------------------------------------------------------- @@ -205,7 +199,12 @@ UsbHost::UsbHost(const Config &config) UsbHost::~UsbHost() { if (initialized_.load()) { std::error_code ec; - deinitialize(ec); + if (!deinitialize(ec)) { + // The driver still references this object; there is no safe way to + // continue. Make the failure impossible to miss. + logger_.error("destroying UsbHost while the USB host stack could not be released ({})", + ec.message()); + } } } @@ -252,35 +251,66 @@ bool UsbHost::initialize(std::error_code &ec) { return false; } - // 2) Spawn the USB-host-library event task. + // 2) Start the USB-host-library event task. lib_task_run_.store(true); - lib_task_done_.store(false); - BaseType_t core = config_.task_core_id < 0 ? tskNO_AFFINITY : config_.task_core_id; - TaskHandle_t task = nullptr; - BaseType_t created = - xTaskCreatePinnedToCore(&UsbHost::lib_task_trampoline, "usb_host_lib", - config_.task_stack_size, this, config_.task_priority, &task, core); - if (created != pdPASS) { - logger_.error("failed to create usb host lib task"); + lib_task_ = espp::Task::make_unique({ + .callback = [this](std::mutex &m, std::condition_variable &cv) { return lib_task_fn(m, cv); }, + .task_config = + { + .name = "usb_host_lib", + .stack_size_bytes = config_.lib_task_stack_size, + .priority = config_.task_priority, + .core_id = config_.task_core_id, + }, + .log_level = Logger::Verbosity::WARN, + }); + if (!lib_task_->start()) { + logger_.error("failed to start usb host lib task"); lib_task_run_.store(false); + lib_task_.reset(); usb_host_uninstall(); ec = std::make_error_code(std::errc::not_enough_memory); return false; } - lib_task_handle_ = task; - // 3) Install the HID class driver (with its own background task). + // 3) Start the dispatch task (runs device open/close + all user callbacks off + // the driver task, so callbacks may issue control transfers). + dispatch_run_.store(true); + dispatch_task_ = espp::Task::make_unique({ + .callback = [this](std::mutex &m, + std::condition_variable &cv) { return dispatch_task_fn(m, cv); }, + .task_config = + { + .name = "usb_host_cb", + .stack_size_bytes = config_.dispatch_task_stack_size, + .priority = config_.task_priority, + .core_id = config_.task_core_id, + }, + .log_level = Logger::Verbosity::WARN, + }); + if (!dispatch_task_->start()) { + logger_.error("failed to start usb host dispatch task"); + dispatch_run_.store(false); + dispatch_task_.reset(); + stop_lib_task(); + usb_host_uninstall(); + ec = std::make_error_code(std::errc::not_enough_memory); + return false; + } + + // 4) Install the HID class driver (with its own background task). const hid_host_driver_config_t hid_config = { .create_background_task = true, .task_priority = config_.task_priority, - .stack_size = config_.task_stack_size, - .core_id = core, + .stack_size = config_.hid_task_stack_size, + .core_id = config_.task_core_id < 0 ? tskNO_AFFINITY : config_.task_core_id, .callback = &espp_usb_host_driver_event_cb, .callback_arg = this, }; err = hid_host_install(&hid_config); if (err != ESP_OK) { logger_.error("hid_host_install failed: {}", esp_err_to_name(err)); + stop_dispatch_task(); stop_lib_task(); // join the lib task before uninstalling the library usb_host_uninstall(); ec = make_ec(err); @@ -300,32 +330,53 @@ bool UsbHost::deinitialize(std::error_code &ec) { } logger_.info("uninstalling USB host"); - // Collect the device handles under the lock, then close them *outside* it: the - // driver's close path can run callbacks that also take devices_mutex_, so - // closing while holding it risks lock inversion. - std::vector handles; + // 1) Stop the dispatch task first, so no further driver operations or user + // callbacks are issued from it (a callback in flight finishes; the HID + // driver is still installed, so an in-flight control transfer completes). + stop_dispatch_task(); + + // 2) Retire every device we opened (serialized against app-task I/O) and let + // the application know, since the dispatch task is no longer around to. + std::vector> devices; { std::lock_guard lk(devices_mutex_); - handles.reserve(devices_.size()); for (auto &[handle, dev] : devices_) { - dev->mark_disconnected(); - handles.push_back(handle); + (void)handle; + devices.push_back(dev); } devices_.clear(); } - for (auto handle : handles) { - hid_host_device_close(handle); + for (auto &dev : devices) { + dev->retire(); + if (config_.on_device_disconnected) { + config_.on_device_disconnected(dev); + } } - // Uninstall the HID class driver (stops its background task). - esp_err_t err = hid_host_uninstall(); + // 3) The HID driver only forgets a device when the USB stack reports it gone, + // and it refuses to uninstall while it still tracks one. Power down the + // root port so any attached device is reported gone, then wait (bounded) + // for the driver to release it and uninstall to succeed. + usb_host_lib_set_root_port_power(false); + esp_err_t err = ESP_FAIL; + for (int i = 0; i < 200; ++i) { // up to ~2 s + err = hid_host_uninstall(); + if (err == ESP_OK) { + break; + } + std::this_thread::sleep_for(10ms); + } if (err != ESP_OK) { - logger_.warn("hid_host_uninstall: {}", esp_err_to_name(err)); + // Tearing down under a driver that still references us would be a + // use-after-free waiting to happen; stay initialized and report it. + logger_.error("hid_host_uninstall failed: {} (a device could not be released)", + esp_err_to_name(err)); + ec = make_ec(err); + return false; } - // Free any remaining devices so the library can be uninstalled, then stop + - // join the lib task (unblocking it so it observes the stop flag promptly - // rather than relying on a fixed delay). + // 4) Free any remaining devices so the library can be uninstalled, then stop + // + join the lib task and uninstall. usb_host_device_free_all(); stop_lib_task(); @@ -350,50 +401,146 @@ std::vector> UsbHost::devices() const { return out; } -void UsbHost::lib_task_trampoline(void *arg) { static_cast(arg)->lib_task(); } +std::shared_ptr UsbHost::find_device(hid_host_device_handle_t handle) const { + std::lock_guard lk(devices_mutex_); + auto it = devices_.find(handle); + return it == devices_.end() ? nullptr : it->second; +} -void UsbHost::lib_task() { - while (lib_task_run_.load()) { - uint32_t event_flags = 0; - usb_host_lib_handle_events(portMAX_DELAY, &event_flags); - if (event_flags & USB_HOST_LIB_EVENT_FLAGS_NO_CLIENTS) { - // No registered clients: it is safe to release the devices. - usb_host_device_free_all(); - } - if (event_flags & USB_HOST_LIB_EVENT_FLAGS_ALL_FREE) { - logger_.debug("all USB devices freed"); - if (!lib_task_run_.load()) { - break; - } - } +// --------------------------------------------------------------------------- +// USB Host library task +// --------------------------------------------------------------------------- +bool UsbHost::lib_task_fn(std::mutex & /*m*/, std::condition_variable & /*cv*/) { + uint32_t event_flags = 0; + usb_host_lib_handle_events(portMAX_DELAY, &event_flags); + if (event_flags & USB_HOST_LIB_EVENT_FLAGS_NO_CLIENTS) { + // No registered clients: it is safe to release the devices. + usb_host_device_free_all(); } - lib_task_done_.store(true); // signal stop_lib_task() that we have exited - vTaskDelete(nullptr); + if (event_flags & USB_HOST_LIB_EVENT_FLAGS_ALL_FREE) { + logger_.debug("all USB devices freed"); + } + return !lib_task_run_.load(); // true = stop the task } void UsbHost::stop_lib_task() { - if (lib_task_handle_ == nullptr) { + if (!lib_task_) { return; } lib_task_run_.store(false); // The task blocks in usb_host_lib_handle_events(portMAX_DELAY); unblock it so - // it observes the stop flag and returns instead of waiting for an event. + // it observes the stop flag and returns, then join it. usb_host_lib_unblock(); - // Join: wait (bounded) for the task to actually exit before the caller - // uninstalls the library out from under it. - for (int i = 0; i < 100 && !lib_task_done_.load(); ++i) { - vTaskDelay(pdMS_TO_TICKS(10)); - } - if (!lib_task_done_.load()) { - logger_.warn("usb host lib task did not exit in time"); + lib_task_->stop(); + lib_task_.reset(); +} + +// --------------------------------------------------------------------------- +// HID driver task side: only enqueue +// --------------------------------------------------------------------------- +void UsbHost::enqueue(Event &&ev) { + { + std::lock_guard lk(queue_mutex_); + if (queue_.size() >= config_.max_queued_events) { + // Never block the USB driver task. Drop Input reports when the consumer is + // behind; keep lifecycle events (they are rare and must not be lost). + if (ev.type == Event::Type::Input) { + logger_.debug("event queue full; dropping input report"); + return; + } + } + queue_.push_back(std::move(ev)); } - lib_task_handle_ = nullptr; + queue_cv_.notify_one(); } void UsbHost::on_driver_event(hid_host_device_handle_t handle, hid_host_driver_event_t event) { if (event != HID_HOST_DRIVER_EVENT_CONNECTED) { return; } + // Everything else (filter, open, set-protocol, start, user callback) needs + // the dispatch task: opening / configuring a device involves control + // transfers that this task is responsible for completing. + enqueue(Event{.type = Event::Type::NewDevice, .handle = handle}); +} + +void UsbHost::on_interface_event(hid_host_device_handle_t handle, + hid_host_interface_event_t event) { + switch (event) { + case HID_HOST_INTERFACE_EVENT_INPUT_REPORT: { + // The report lives in the driver's transfer buffer, which is reused as soon + // as this callback returns -- so copy it out here, then hand the copy to + // the dispatch task. + Event ev{.type = Event::Type::Input, .handle = handle}; + ev.data.resize(config_.max_input_report_size); + size_t len = 0; + esp_err_t err = + hid_host_device_get_raw_input_report_data(handle, ev.data.data(), ev.data.size(), &len); + if (err != ESP_OK) { + return; + } + ev.data.resize(len); + enqueue(std::move(ev)); + break; + } + case HID_HOST_INTERFACE_EVENT_DISCONNECTED: + // The dispatch task retires the device (in order, after any queued inputs). + enqueue(Event{.type = Event::Type::Disconnected, .handle = handle}); + break; + case HID_HOST_INTERFACE_EVENT_TRANSFER_ERROR: + logger_.warn("HID transfer error"); + break; + default: + break; + } +} + +// --------------------------------------------------------------------------- +// Dispatch task side: the real work + user callbacks +// --------------------------------------------------------------------------- +bool UsbHost::dispatch_task_fn(std::mutex & /*m*/, std::condition_variable & /*cv*/) { + std::deque batch; + { + std::unique_lock lk(queue_mutex_); + // Bounded wait so Task::stop() is never held up for long; the stop path + // also notifies queue_cv_ directly. + queue_cv_.wait_for(lk, 100ms, [this] { return !queue_.empty() || !dispatch_run_.load(); }); + batch.swap(queue_); + } + for (auto &ev : batch) { + if (!dispatch_run_.load()) { + break; + } + switch (ev.type) { + case Event::Type::NewDevice: + handle_new_device(ev.handle); + break; + case Event::Type::Input: + handle_input(ev.handle, ev.data); + break; + case Event::Type::Disconnected: + handle_disconnected(ev.handle); + break; + } + } + return !dispatch_run_.load(); // true = stop the task +} + +void UsbHost::stop_dispatch_task() { + dispatch_run_.store(false); + queue_cv_.notify_all(); + if (dispatch_task_) { + dispatch_task_->stop(); + dispatch_task_.reset(); + } + std::lock_guard lk(queue_mutex_); + queue_.clear(); +} + +void UsbHost::handle_new_device(hid_host_device_handle_t handle) { + // Identity + interface parameters are readable before the interface is + // opened (the driver has already enumerated the device); snapshot them now, + // they are static for the life of the connection. HidDevice::Info info = read_info(handle); HidDevice::Params params = read_params(handle); logger_.info("HID device connected: VID={:#06x} PID={:#06x} iface={} proto={}", info.vid, @@ -404,7 +551,7 @@ void UsbHost::on_driver_event(hid_host_device_handle_t handle, hid_host_driver_e return; } - // Open the HID interface, routing its events back to us. + // Open the HID interface, routing its events back to us (on the driver task). const hid_host_device_config_t dev_config = { .callback = &espp_usb_host_interface_event_cb, .callback_arg = this, @@ -415,67 +562,71 @@ void UsbHost::on_driver_event(hid_host_device_handle_t handle, hid_host_driver_e return; } - // Some devices report a boot protocol; force report protocol so we always get - // the full report-descriptor'd reports (ignore errors -- not all devices - // support the request). - hid_class_request_set_protocol(handle, HID_REPORT_PROTOCOL_REPORT); + // Boot-subclass interfaces may come up in boot protocol; ask those for report + // protocol so we always get the full report-descriptor'd reports (only such + // interfaces are required to support the request). Safe here: we are on the + // dispatch task, not the driver task. + if (params.sub_class == kHidSubclassBoot) { + esp_err_t perr = hid_class_request_set_protocol(handle, HID_REPORT_PROTOCOL_REPORT); + if (perr != ESP_OK) { + logger_.debug("set_protocol(report) not honored: {}", esp_err_to_name(perr)); + } + } + + // Snapshot the report descriptor (driver-owned memory, valid only while the + // interface is open) into the device object. + std::vector descriptor; + { + size_t len = 0; + uint8_t *desc = hid_host_get_report_descriptor(handle, &len); + if (desc && len > 0) { + descriptor.assign(desc, desc + len); + } + } - auto device = std::shared_ptr(new HidDevice(handle, config_.max_input_report_size)); + auto device = std::shared_ptr( + new HidDevice(handle, std::move(info), std::move(params), std::move(descriptor))); { std::lock_guard lk(devices_mutex_); devices_[handle] = device; } + // Let the application install its input callback *before* reports flow. + if (config_.on_device_connected) { + config_.on_device_connected(device); + } + if (config_.auto_start) { - esp_err_t serr = hid_host_device_start(handle); - if (serr != ESP_OK) { - logger_.warn("hid_host_device_start failed: {}", esp_err_to_name(serr)); - } else { - device->started_.store(true); + std::error_code sec; + if (!device->start(sec)) { + logger_.warn("hid_host_device_start failed: {}", sec.message()); } } +} - if (config_.on_device_connected) { - config_.on_device_connected(device); +void UsbHost::handle_input(hid_host_device_handle_t handle, std::span data) { + if (auto dev = find_device(handle)) { + dev->deliver_input(data); } } -void UsbHost::on_interface_event(hid_host_device_handle_t handle, - hid_host_interface_event_t event) { +void UsbHost::handle_disconnected(hid_host_device_handle_t handle) { std::shared_ptr device; { std::lock_guard lk(devices_mutex_); auto it = devices_.find(handle); if (it != devices_.end()) { device = it->second; + devices_.erase(it); } } - - switch (event) { - case HID_HOST_INTERFACE_EVENT_INPUT_REPORT: - if (device) { - device->deliver_input(); - } - break; - case HID_HOST_INTERFACE_EVENT_DISCONNECTED: - logger_.info("HID device disconnected"); - if (device) { - device->mark_disconnected(); - } - hid_host_device_close(handle); - { - std::lock_guard lk(devices_mutex_); - devices_.erase(handle); - } - if (device && config_.on_device_disconnected) { - config_.on_device_disconnected(device); - } - break; - case HID_HOST_INTERFACE_EVENT_TRANSFER_ERROR: - logger_.warn("HID transfer error"); - break; - default: - break; + if (!device) { + return; // not one we opened (or already torn down) -- nothing to close + } + logger_.info("HID device disconnected"); + device->retire(); + if (config_.on_device_disconnected) { + config_.on_device_disconnected(device); } } From b74ab0b4323acbd918bfae3ee57546a56fbc30da Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 09:15:22 -0500 Subject: [PATCH 06/10] docs(usb_host): document the threading model (dispatch task, callback 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 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/README.md | 27 +++++++++++++++++++++++++++ doc/en/buses/usb_host.rst | 27 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/components/usb_host/README.md b/components/usb_host/README.md index 7d5b1f4aac..8826933e6d 100644 --- a/components/usb_host/README.md +++ b/components/usb_host/README.md @@ -50,6 +50,33 @@ host→device. This mirrors `espp::UsbDevice` exactly, so the two ends of a link with the component manager **on** (the default) rather than the manager-off flow used by the device-side USB examples. +## Threading model + +The HID class driver delivers its events on its own background task — and that +same task is what completes the driver's *synchronous* control transfers +(Set/Get Report, Set Protocol, …). A control transfer issued *from* that task can +therefore never complete (it times out). `UsbHost` handles this the way the +ESP-IDF HID host example does, but internally: the driver task only **enqueues** +events (copying each Input report out of the driver's buffer, which must happen +inside the callback), and a dedicated **dispatch task** owned by `UsbHost` +opens/starts/closes devices and invokes every user callback. Consequences: + +- It is safe to call `send_output_report()` and the other `HidDevice` methods + from inside `on_device_connected` / the input callback (e.g. to answer a + request/response HID protocol) — they complete normally. +- Events for a device are delivered in order (connected → inputs → + disconnected), and the connect callback runs *before* the device is started, + so an input callback installed there sees the very first report. +- `HidDevice` methods may also be called from any application task; each device + serializes its driver calls internally, so an app-task control transfer can't + race the device being retired on disconnect. +- Keep callbacks reasonably short: one that blocks delays every later event. +- The queue is bounded (`Config::max_queued_events`); if the consumer falls + behind, Input reports are dropped (logged) rather than blocking the USB stack. + +`HidDevice::info()` / `params()` / `report_descriptor()` are snapshots taken at +connect time, so they remain valid after the device disconnects. + ## Example ```cpp diff --git a/doc/en/buses/usb_host.rst b/doc/en/buses/usb_host.rst index 99a1ce2574..9dc8bfb0fd 100644 --- a/doc/en/buses/usb_host.rst +++ b/doc/en/buses/usb_host.rst @@ -85,6 +85,33 @@ Requirements and caveats component manager **on** (the default) rather than the manager-off flow used by the device-side USB examples. +Threading model +--------------- + +The HID class driver delivers its events on its own background task, and that +same task is what completes the driver's *synchronous* control transfers +(Set/Get Report, Set Protocol, ...). A control transfer issued *from* that task +can therefore never complete. ``espp::UsbHost`` handles this the way the ESP-IDF +HID host example does, but internally: the driver task only **enqueues** events +(copying each Input report out of the driver's buffer, which must happen inside +the callback), and a dedicated **dispatch task** owned by ``UsbHost`` +opens/starts/closes devices and invokes every user callback. + +- It is safe to call ``send_output_report()`` and the other ``HidDevice`` + methods from inside ``on_device_connected`` / the input callback (e.g. to + answer a request/response HID protocol). +- Events for a device are delivered in order (connected → inputs → + disconnected), and the connect callback runs *before* the device is started, + so an input callback installed there sees the very first report. +- ``HidDevice`` methods may also be called from any application task; each + device serializes its driver calls internally. +- Keep callbacks reasonably short: one that blocks delays every later event. + The event queue is bounded (``Config::max_queued_events``); when the consumer + falls behind, Input reports are dropped (logged) rather than blocking the USB + stack. +- ``info()`` / ``params()`` / ``report_descriptor()`` are snapshots taken at + connect time and remain valid after the device disconnects. + Roadmap ------- From 5a63dfdc20588f1e9e7573af5e609560ac045877 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 12:10:54 -0500 Subject: [PATCH 07/10] fix(usb_host): address review round 3 (teardown safety, UTF-8, queue 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 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/include/usb_host.hpp | 30 +++++---- components/usb_host/src/usb_host.cpp | 84 +++++++++++++++++++----- doc/en/buses/usb_host.rst | 2 +- 3 files changed, 88 insertions(+), 28 deletions(-) diff --git a/components/usb_host/include/usb_host.hpp b/components/usb_host/include/usb_host.hpp index 5e65c7705c..c86d7e6dfb 100644 --- a/components/usb_host/include/usb_host.hpp +++ b/components/usb_host/include/usb_host.hpp @@ -108,11 +108,13 @@ class UsbHost : public BaseComponent { /// @brief Device descriptor identity (VID/PID + string descriptors). struct Info { - uint16_t vid{0}; ///< idVendor - uint16_t pid{0}; ///< idProduct - std::string manufacturer{}; ///< iManufacturer string (UTF-8) - std::string product{}; ///< iProduct string (UTF-8) - std::string serial_number{}; ///< iSerialNumber string (UTF-8) + uint16_t vid{0}; ///< idVendor + uint16_t pid{0}; ///< idProduct + std::string + manufacturer{}; ///< iManufacturer string (UTF-8, converted from the device's UTF-16) + std::string product{}; ///< iProduct string (UTF-8, converted from the device's UTF-16) + std::string + serial_number{}; ///< iSerialNumber string (UTF-8, converted from the device's UTF-16) }; /// @brief HID interface parameters. @@ -152,12 +154,12 @@ class UsbHost : public BaseComponent { bool send_output_report(uint8_t report_id, std::span data, std::error_code &ec); /// @brief Request a report from the device (HID class Get_Report). - /// @param report_type One of HID_REPORT_TYPE_INPUT / _OUTPUT / _FEATURE. + /// @param report_type The HID report type (HID_REPORT_TYPE_INPUT / _OUTPUT / _FEATURE). /// @param report_id The report ID. /// @param buffer Buffer that receives the report. /// @param out_length Number of bytes written into @p buffer. /// @param ec Set on failure. - bool get_report(uint8_t report_type, uint8_t report_id, std::span buffer, + bool get_report(hid_report_type_t report_type, uint8_t report_id, std::span buffer, size_t &out_length, std::error_code &ec); /// @brief Set the device's idle rate (HID class Set_Idle). @@ -226,8 +228,11 @@ class UsbHost : public BaseComponent { /// truncated (the driver copies at most this many bytes); raise it if /// your device sends larger reports. 64 covers full-speed HID. size_t max_input_report_size{64}; - /// @brief Bound on queued-but-undispatched events; when full, further Input - /// reports are dropped (logged) rather than blocking the USB stack. + /// @brief Hard bound on queued-but-undispatched events. When full, a new Input + /// report is dropped, and a lifecycle event evicts the oldest queued Input + /// report to make room, so the queue never blocks the USB stack and + /// lifecycle events are never lost. Drops are counted and logged at a + /// rate-limited cadence. size_t max_queued_events{32}; Logger::Verbosity log_level{Logger::Verbosity::WARN}; }; @@ -250,9 +255,11 @@ class UsbHost : public BaseComponent { /// Attached devices are closed (their disconnect callbacks fire, on the /// calling task) and the root port is powered down so the driver can /// release them. Must not be called from within a `UsbHost` callback. - /// @param ec Set on failure. If the driver cannot release a device the host + /// @param ec Set on failure. If any step of the teardown fails (a device the + /// driver cannot release, or the library refusing to uninstall) the host /// stays initialized (is_initialized() remains true) and false is - /// returned, rather than tearing down under a live driver. + /// returned, rather than tearing down under a live driver. Destroying + /// a UsbHost in that state aborts (see the destructor). /// @return true on success. bool deinitialize(std::error_code &ec); @@ -307,6 +314,7 @@ class UsbHost : public BaseComponent { std::mutex queue_mutex_; std::condition_variable queue_cv_; std::deque queue_; + uint32_t dropped_inputs_{0}; // guarded by queue_mutex_; rate-limits the drop log std::atomic dispatch_run_{false}; std::unique_ptr dispatch_task_; diff --git a/components/usb_host/src/usb_host.cpp b/components/usb_host/src/usb_host.cpp index f7a3fedf0d..beab264c85 100644 --- a/components/usb_host/src/usb_host.cpp +++ b/components/usb_host/src/usb_host.cpp @@ -1,6 +1,8 @@ #include "usb_host.hpp" +#include #include +#include #include #include @@ -61,15 +63,45 @@ std::error_code make_ec(esp_err_t err) { } std::string wchars_to_utf8(const wchar_t *ws) { + // The HID host driver stores string descriptors as wchar_t code units carrying + // the device's UTF-16 (USB string descriptors are UTF-16LE). Encode to UTF-8, + // combining surrogate pairs; a lone/invalid surrogate becomes U+FFFD. std::string out; if (!ws) { return out; } + auto put = [&out](uint32_t cp) { + if (cp < 0x80) { + out.push_back(static_cast(cp)); + } else if (cp < 0x800) { + out.push_back(static_cast(0xC0 | (cp >> 6))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } else if (cp < 0x10000) { + out.push_back(static_cast(0xE0 | (cp >> 12))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } else { + out.push_back(static_cast(0xF0 | (cp >> 18))); + out.push_back(static_cast(0x80 | ((cp >> 12) & 0x3F))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } + }; for (; *ws; ++ws) { - // The HID host driver stores string descriptors as UCS-2; keep ASCII and - // approximate the rest (device identity strings are informational). - wchar_t c = *ws; - out.push_back(c < 0x80 ? static_cast(c) : '?'); + uint32_t cu = static_cast(*ws) & 0xFFFF; + if (cu >= 0xD800 && cu <= 0xDBFF) { // high surrogate: needs a low surrogate next + uint32_t lo = static_cast(ws[1]) & 0xFFFF; + if (lo >= 0xDC00 && lo <= 0xDFFF) { + put(0x10000 + (((cu - 0xD800) << 10) | (lo - 0xDC00))); + ++ws; + } else { + put(0xFFFD); + } + } else if (cu >= 0xDC00 && cu <= 0xDFFF) { // stray low surrogate + put(0xFFFD); + } else { + put(cu); + } } return out; } @@ -130,7 +162,7 @@ bool UsbHost::HidDevice::send_output_report(uint8_t report_id, std::span buffer, size_t &out_length, std::error_code &ec) { std::lock_guard lk(io_mutex_); @@ -139,8 +171,8 @@ bool UsbHost::HidDevice::get_report(uint8_t report_type, uint8_t report_id, return false; } size_t len = buffer.size(); - esp_err_t err = - hid_class_request_get_report(handle_, report_type, report_id, buffer.data(), &len); + esp_err_t err = hid_class_request_get_report(handle_, static_cast(report_type), + report_id, buffer.data(), &len); ec = make_ec(err); out_length = ec ? 0 : len; return !ec; @@ -200,10 +232,13 @@ UsbHost::~UsbHost() { if (initialized_.load()) { std::error_code ec; if (!deinitialize(ec)) { - // The driver still references this object; there is no safe way to - // continue. Make the failure impossible to miss. - logger_.error("destroying UsbHost while the USB host stack could not be released ({})", + // The USB host driver still holds a pointer to this object and would call + // into freed memory on the next device event. Freeing it anyway would be a + // silent use-after-free; failing loudly is the only safe option. + logger_.error("USB host stack could not be released ({}); aborting rather than freeing an " + "object the driver still references", ec.message()); + abort(); } } } @@ -382,12 +417,16 @@ bool UsbHost::deinitialize(std::error_code &ec) { err = usb_host_uninstall(); if (err != ESP_OK) { - logger_.warn("usb_host_uninstall: {}", esp_err_to_name(err)); + // The library is still installed: stay initialized so the object is never + // freed under a live stack (and a retry of deinitialize() is possible). + logger_.error("usb_host_uninstall failed: {}", esp_err_to_name(err)); + ec = make_ec(err); + return false; } initialized_.store(false); - ec = make_ec(err); - return !ec; + ec.clear(); + return true; } std::vector> UsbHost::devices() const { @@ -442,12 +481,25 @@ void UsbHost::enqueue(Event &&ev) { { std::lock_guard lk(queue_mutex_); if (queue_.size() >= config_.max_queued_events) { - // Never block the USB driver task. Drop Input reports when the consumer is - // behind; keep lifecycle events (they are rare and must not be lost). + // Never block the USB driver task, and keep the queue a hard bound. if (ev.type == Event::Type::Input) { - logger_.debug("event queue full; dropping input report"); + // The consumer is behind: drop this report. Rate-limit the log so a + // sustained backlog doesn't spend the driver task's time logging. + if (++dropped_inputs_ == 1 || dropped_inputs_ % 100 == 0) { + logger_.warn("event queue full; {} input report(s) dropped so far", dropped_inputs_); + } return; } + // A lifecycle event must not be lost: make room by evicting the oldest + // queued Input report (those are droppable). If there is none to evict the + // queue holds only lifecycle events, whose count is bounded by the number + // of attached devices (at most a connect + a disconnect each), so pushing + // past the cap here cannot grow without bound. + auto victim = std::find_if(queue_.begin(), queue_.end(), + [](const Event &e) { return e.type == Event::Type::Input; }); + if (victim != queue_.end()) { + queue_.erase(victim); + } } queue_.push_back(std::move(ev)); } diff --git a/doc/en/buses/usb_host.rst b/doc/en/buses/usb_host.rst index 9dc8bfb0fd..ee453c65fc 100644 --- a/doc/en/buses/usb_host.rst +++ b/doc/en/buses/usb_host.rst @@ -15,7 +15,7 @@ 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 — installing the host library and HID driver, running their event tasks, opening interfaces, and teardown — and marshals the driver's C callbacks into per-device -``std::function``\ s. Like the rest of espp it does not throw and reports failures +``std::function`` objects. Like the rest of espp it does not throw and reports failures via ``std::error_code``. Report directions are named from the connected **device's** point of view, as in From c57fb550f852e2d018ea8f6f05aa307cd14b56d0 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 12:45:43 -0500 Subject: [PATCH 08/10] fix(usb_host): address review round 4 (no const_cast, hard queue bound, 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 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/include/usb_host.hpp | 28 +++++++++--- components/usb_host/src/usb_host.cpp | 57 +++++++++++++++++------- 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/components/usb_host/include/usb_host.hpp b/components/usb_host/include/usb_host.hpp index c86d7e6dfb..3dae5bbbeb 100644 --- a/components/usb_host/include/usb_host.hpp +++ b/components/usb_host/include/usb_host.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -228,10 +229,15 @@ class UsbHost : public BaseComponent { /// truncated (the driver copies at most this many bytes); raise it if /// your device sends larger reports. 64 covers full-speed HID. size_t max_input_report_size{64}; - /// @brief Hard bound on queued-but-undispatched events. When full, a new Input - /// report is dropped, and a lifecycle event evicts the oldest queued Input - /// report to make room, so the queue never blocks the USB stack and - /// lifecycle events are never lost. Drops are counted and logged at a + /// @brief Bound on queued-but-undispatched events. When the queue is full: a + /// new Input report is dropped; a lifecycle event first evicts the + /// oldest queued Input report, and if there is none a *new-device* + /// event is dropped (that device simply stays unopened while the + /// consumer is overloaded) while a *disconnect* is always kept (it is + /// needed to release the device, and can only follow a device that was + /// opened). The queue length is therefore never more than + /// max_queued_events + the number of currently open devices, and the + /// USB stack is never blocked. Drops are counted and logged at a /// rate-limited cadence. size_t max_queued_events{32}; Logger::Verbosity log_level{Logger::Verbosity::WARN}; @@ -275,11 +281,20 @@ class UsbHost : public BaseComponent { friend void espp_usb_host_interface_event_cb(hid_host_device_handle_t, const hid_host_interface_event_t, void *); - // An event queued by the HID driver task for the dispatch task. + // An event queued by the HID driver task for the dispatch task. Input + // reports are stored inline (no heap traffic on the driver task) unless a + // larger Config::max_input_report_size was requested. struct Event { + static constexpr size_t kInlineBytes = 64; // full-speed HID interrupt max packet enum class Type { NewDevice, Input, Disconnected } type; hid_host_device_handle_t handle{nullptr}; - std::vector data{}; // Input: the report bytes (copied on the driver task) + std::array inline_data{}; + std::vector overflow{}; // used only when max_input_report_size > kInlineBytes + size_t len{0}; + std::span data() const { + return overflow.empty() ? std::span(inline_data.data(), len) + : std::span(overflow.data(), len); + } }; // Trampoline targets: run on the HID driver's background task. They only @@ -316,6 +331,7 @@ class UsbHost : public BaseComponent { std::deque queue_; uint32_t dropped_inputs_{0}; // guarded by queue_mutex_; rate-limits the drop log std::atomic dispatch_run_{false}; + std::atomic accepting_{false}; // enqueue() is a no-op unless set (cleared before teardown) std::unique_ptr dispatch_task_; mutable std::mutex devices_mutex_; diff --git a/components/usb_host/src/usb_host.cpp b/components/usb_host/src/usb_host.cpp index beab264c85..2b93fa2450 100644 --- a/components/usb_host/src/usb_host.cpp +++ b/components/usb_host/src/usb_host.cpp @@ -152,12 +152,21 @@ bool UsbHost::HidDevice::send_output_report(uint8_t report_id, std::spandevice transfer: the driver only reads the buffer, it does not write - // it. const_cast avoids an allocation + copy on every output report (hot path - // for e.g. WDI feedback). - esp_err_t err = hid_class_request_set_report(handle_, HID_REPORT_TYPE_OUTPUT, report_id, - const_cast(data.data()), data.size()); + // hid_class_request_set_report() takes a non-const buffer. Rather than cast + // away const (the caller's bytes may live in read-only memory), copy into a + // stack buffer -- Output reports are small -- and only fall back to the heap + // for an unusually large one. + uint8_t stack_buf[Event::kInlineBytes]; + std::vector heap_buf; + uint8_t *buf = stack_buf; + if (data.size() <= sizeof(stack_buf)) { + std::memcpy(stack_buf, data.data(), data.size()); + } else { + heap_buf.assign(data.begin(), data.end()); + buf = heap_buf.data(); + } + esp_err_t err = + hid_class_request_set_report(handle_, HID_REPORT_TYPE_OUTPUT, report_id, buf, data.size()); ec = make_ec(err); return !ec; } @@ -332,6 +341,7 @@ bool UsbHost::initialize(std::error_code &ec) { ec = std::make_error_code(std::errc::not_enough_memory); return false; } + accepting_.store(true); // driver callbacks may now enqueue // 4) Install the HID class driver (with its own background task). const hid_host_driver_config_t hid_config = { @@ -478,10 +488,14 @@ void UsbHost::stop_lib_task() { // HID driver task side: only enqueue // --------------------------------------------------------------------------- void UsbHost::enqueue(Event &&ev) { + if (!accepting_.load()) { + return; // tearing down (or not yet up): there is no consumer, so keep nothing + } { std::lock_guard lk(queue_mutex_); if (queue_.size() >= config_.max_queued_events) { - // Never block the USB driver task, and keep the queue a hard bound. + // Never block the USB driver task, and keep the queue bounded (see the + // Config::max_queued_events doc for the exact bound). if (ev.type == Event::Type::Input) { // The consumer is behind: drop this report. Rate-limit the log so a // sustained backlog doesn't spend the driver task's time logging. @@ -490,15 +504,18 @@ void UsbHost::enqueue(Event &&ev) { } return; } - // A lifecycle event must not be lost: make room by evicting the oldest - // queued Input report (those are droppable). If there is none to evict the - // queue holds only lifecycle events, whose count is bounded by the number - // of attached devices (at most a connect + a disconnect each), so pushing - // past the cap here cannot grow without bound. + // A lifecycle event: make room by evicting the oldest queued Input report. auto victim = std::find_if(queue_.begin(), queue_.end(), [](const Event &e) { return e.type == Event::Type::Input; }); if (victim != queue_.end()) { queue_.erase(victim); + } else if (ev.type == Event::Type::NewDevice) { + // Only lifecycle events are queued and the consumer is overloaded: leave + // this device unopened rather than grow without bound. A Disconnected + // event is always kept -- it can only follow an opened device, so those + // are bounded by the open-device count. + logger_.warn("event queue full; not opening newly attached HID device"); + return; } } queue_.push_back(std::move(ev)); @@ -524,14 +541,19 @@ void UsbHost::on_interface_event(hid_host_device_handle_t handle, // as this callback returns -- so copy it out here, then hand the copy to // the dispatch task. Event ev{.type = Event::Type::Input, .handle = handle}; - ev.data.resize(config_.max_input_report_size); + uint8_t *buf = ev.inline_data.data(); + size_t cap = std::min(config_.max_input_report_size, Event::kInlineBytes); + if (config_.max_input_report_size > Event::kInlineBytes) { + ev.overflow.resize(config_.max_input_report_size); // opt-in larger reports only + buf = ev.overflow.data(); + cap = ev.overflow.size(); + } size_t len = 0; - esp_err_t err = - hid_host_device_get_raw_input_report_data(handle, ev.data.data(), ev.data.size(), &len); + esp_err_t err = hid_host_device_get_raw_input_report_data(handle, buf, cap, &len); if (err != ESP_OK) { return; } - ev.data.resize(len); + ev.len = len; enqueue(std::move(ev)); break; } @@ -568,7 +590,7 @@ bool UsbHost::dispatch_task_fn(std::mutex & /*m*/, std::condition_variable & /*c handle_new_device(ev.handle); break; case Event::Type::Input: - handle_input(ev.handle, ev.data); + handle_input(ev.handle, ev.data()); break; case Event::Type::Disconnected: handle_disconnected(ev.handle); @@ -579,6 +601,7 @@ bool UsbHost::dispatch_task_fn(std::mutex & /*m*/, std::condition_variable & /*c } void UsbHost::stop_dispatch_task() { + accepting_.store(false); // driver callbacks that race teardown enqueue nothing dispatch_run_.store(false); queue_cv_.notify_all(); if (dispatch_task_) { From 2dba38ad3394c570a67f50737cce4d41a2e67f5d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 14:31:56 -0500 Subject: [PATCH 09/10] ci(usb_host): publish the usb_host component to the component registry 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 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .github/workflows/upload_components.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index e4203cd041..3a3adc173a 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -180,6 +180,7 @@ jobs: components/tt21100 components/twai components/usb_device + components/usb_host components/utils components/vl53l components/wifi From d833ddf15a8e7a4baadae61142c49a64b7de589a Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Sat, 12 Sep 2026 14:36:43 -0500 Subject: [PATCH 10/10] fix(usb_host): address review round 5 (enqueue/teardown race, buffer 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 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_host/README.md | 6 ++++-- components/usb_host/include/usb_host.hpp | 3 +++ components/usb_host/src/usb_host.cpp | 25 +++++++++++++++++++++++- doc/en/buses/usb_host.rst | 6 ++++-- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/components/usb_host/README.md b/components/usb_host/README.md index 8826933e6d..cb9c781cb3 100644 --- a/components/usb_host/README.md +++ b/components/usb_host/README.md @@ -100,9 +100,11 @@ espp::UsbHost host({ std::error_code ec; if (!host.initialize(ec)) { /* handle ec */ } -// later, send an Output report (host->device): +// later, send an Output report (host->device) -- devices() may be empty, so +// guard it (or keep the shared_ptr handed to on_device_connected and use that): std::array payload{...}; -host.devices().front()->send_output_report(/*report_id*/ 0x02, payload, ec); +if (auto devs = host.devices(); !devs.empty()) + devs.front()->send_output_report(/*report_id*/ 0x02, payload, ec); ``` See `example/` for a full runnable example (esp32s3) that logs every connected diff --git a/components/usb_host/include/usb_host.hpp b/components/usb_host/include/usb_host.hpp index 3dae5bbbeb..931eddc7e6 100644 --- a/components/usb_host/include/usb_host.hpp +++ b/components/usb_host/include/usb_host.hpp @@ -330,6 +330,9 @@ class UsbHost : public BaseComponent { std::condition_variable queue_cv_; std::deque queue_; uint32_t dropped_inputs_{0}; // guarded by queue_mutex_; rate-limits the drop log + // Recycled Event::overflow buffers (only used when max_input_report_size > + // Event::kInlineBytes) so large reports don't allocate per report either. + std::vector> overflow_pool_; // guarded by queue_mutex_ std::atomic dispatch_run_{false}; std::atomic accepting_{false}; // enqueue() is a no-op unless set (cleared before teardown) std::unique_ptr dispatch_task_; diff --git a/components/usb_host/src/usb_host.cpp b/components/usb_host/src/usb_host.cpp index 2b93fa2450..c063add3a3 100644 --- a/components/usb_host/src/usb_host.cpp +++ b/components/usb_host/src/usb_host.cpp @@ -493,6 +493,12 @@ void UsbHost::enqueue(Event &&ev) { } { std::lock_guard lk(queue_mutex_); + // Re-check under the lock: stop_dispatch_task() clears accepting_ and then + // clears the queue under this same mutex, so an enqueue that passed the + // unlocked check can't slip a stale event in after the clear. + if (!accepting_.load()) { + return; + } if (queue_.size() >= config_.max_queued_events) { // Never block the USB driver task, and keep the queue bounded (see the // Config::max_queued_events doc for the exact bound). @@ -544,7 +550,17 @@ void UsbHost::on_interface_event(hid_host_device_handle_t handle, uint8_t *buf = ev.inline_data.data(); size_t cap = std::min(config_.max_input_report_size, Event::kInlineBytes); if (config_.max_input_report_size > Event::kInlineBytes) { - ev.overflow.resize(config_.max_input_report_size); // opt-in larger reports only + // Opt-in larger reports: reuse a recycled buffer (its capacity already + // covers max_input_report_size, so resize() does not reallocate) rather + // than allocating on every report. + { + std::lock_guard lk(queue_mutex_); + if (!overflow_pool_.empty()) { + ev.overflow = std::move(overflow_pool_.back()); + overflow_pool_.pop_back(); + } + } + ev.overflow.resize(config_.max_input_report_size); buf = ev.overflow.data(); cap = ev.overflow.size(); } @@ -591,6 +607,12 @@ bool UsbHost::dispatch_task_fn(std::mutex & /*m*/, std::condition_variable & /*c break; case Event::Type::Input: handle_input(ev.handle, ev.data()); + if (!ev.overflow.empty()) { // recycle the large-report buffer + std::lock_guard lk(queue_mutex_); + if (overflow_pool_.size() < config_.max_queued_events) { + overflow_pool_.push_back(std::move(ev.overflow)); + } + } break; case Event::Type::Disconnected: handle_disconnected(ev.handle); @@ -610,6 +632,7 @@ void UsbHost::stop_dispatch_task() { } std::lock_guard lk(queue_mutex_); queue_.clear(); + overflow_pool_.clear(); } void UsbHost::handle_new_device(hid_host_device_handle_t handle) { diff --git a/doc/en/buses/usb_host.rst b/doc/en/buses/usb_host.rst index ee453c65fc..faf8560a01 100644 --- a/doc/en/buses/usb_host.rst +++ b/doc/en/buses/usb_host.rst @@ -62,9 +62,11 @@ Basic Usage std::error_code ec; if (!host.initialize(ec)) { /* handle ec */ } - // later, send an Output report (host->device): + // later, send an Output report (host->device) -- devices() may be empty, so + // guard it (or keep the shared_ptr handed to on_device_connected and use that): std::array payload{/* ... */}; - host.devices().front()->send_output_report(/*report_id*/ 0x02, payload, ec); + if (auto devs = host.devices(); !devs.empty()) + devs.front()->send_output_report(/*report_id*/ 0x02, payload, ec); Requirements and caveats ------------------------