diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6aae031fc..2a733e258 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/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index e4203cd04..3a3adc173 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 diff --git a/components/usb_host/CMakeLists.txt b/components/usb_host/CMakeLists.txt new file mode 100644 index 000000000..bbd864c1a --- /dev/null +++ b/components/usb_host/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES base_component task usb usb_host_hid +) diff --git a/components/usb_host/README.md b/components/usb_host/README.md new file mode 100644 index 000000000..cb9c781cb --- /dev/null +++ b/components/usb_host/README.md @@ -0,0 +1,119 @@ +# 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. + +## 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 +#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) -- devices() may be empty, so +// guard it (or keep the shared_ptr handed to on_device_connected and use that): +std::array payload{...}; +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 +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 000000000..717f1dbf7 --- /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 000000000..7bade98a8 --- /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 000000000..b4a0d43c8 --- /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 000000000..f2e4a6382 --- /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 000000000..46c8c8259 --- /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 000000000..86a04f7bf --- /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: "https://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 000000000..931eddc7e --- /dev/null +++ b/components/usb_host/include/usb_host.hpp @@ -0,0 +1,344 @@ +#pragma once + +#include +#include +#include +#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" +#include "task.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. + * + * **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 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 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 + */ +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`; the + * identity accessors keep returning the values captured at connect time). + */ + 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, 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. + 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 (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 (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. 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 + /// `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 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(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). + 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; 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, Info info, Params params, + std::vector report_descriptor) + : handle_(handle) + , info_(std::move(info)) + , params_(std::move(params)) + , report_descriptor_(std::move(report_descriptor)) {} + + // 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}; + }; + + /// @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_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 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}; + }; + + /// @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. + /// 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 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. Destroying + /// a UsbHost in that state aborts (see the destructor). + /// @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 *); + + // 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::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 + // 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). + 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); + static HidDevice::Params read_params(hid_host_device_handle_t handle); + + Config config_; + std::atomic initialized_{false}; + + // USB Host library task. + std::atomic lib_task_run_{false}; + 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_; + 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_; + + 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 000000000..c063add3a --- /dev/null +++ b/components/usb_host/src/usb_host.cpp @@ -0,0 +1,731 @@ +#include "usb_host.hpp" + +#include +#include +#include +#include +#include + +#include "esp_err.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "usb/usb_host.h" + +using namespace std::chrono_literals; + +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: + 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: + return std::make_error_code(std::errc::io_error); + } +} + +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) { + 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; +} + +constexpr uint8_t kHidSubclassBoot = 1; +} // namespace + +// --------------------------------------------------------------------------- +// UsbHost::HidDevice +// --------------------------------------------------------------------------- +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; + } + 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) { + std::lock_guard lk(io_mutex_); + 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) { + started_.store(false); + } + return !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; + } + // 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; +} + +bool UsbHost::HidDevice::get_report(hid_report_type_t report_type, 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; + } + size_t len = buffer.size(); + 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; +} + +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; + } + 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) { + std::lock_guard lk(io_mutex_); + 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; +} + +void UsbHost::HidDevice::deliver_input(std::span data) { + input_callback_fn cb; + { + std::lock_guard lk(cb_mutex_); + cb = on_input_; + } + if (cb) { + 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_); +} + +// --------------------------------------------------------------------------- +// UsbHost +// --------------------------------------------------------------------------- +UsbHost::UsbHost(const Config &config) + : BaseComponent("UsbHost", config.log_level) + , config_(config) {} + +UsbHost::~UsbHost() { + if (initialized_.load()) { + std::error_code ec; + if (!deinitialize(ec)) { + // 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(); + } + } +} + +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) Start the USB-host-library event task. + lib_task_run_.store(true); + 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; + } + + // 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; + } + 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 = { + .create_background_task = true, + .task_priority = config_.task_priority, + .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); + 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"); + + // 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_); + for (auto &[handle, dev] : devices_) { + (void)handle; + devices.push_back(dev); + } + devices_.clear(); + } + for (auto &dev : devices) { + dev->retire(); + if (config_.on_device_disconnected) { + config_.on_device_disconnected(dev); + } + } + + // 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) { + // 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; + } + + // 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(); + + err = usb_host_uninstall(); + if (err != ESP_OK) { + // 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.clear(); + return true; +} + +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; +} + +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; +} + +// --------------------------------------------------------------------------- +// 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(); + } + 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_) { + 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, then join it. + usb_host_lib_unblock(); + lib_task_->stop(); + lib_task_.reset(); +} + +// --------------------------------------------------------------------------- +// 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_); + // 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). + 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. + 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: 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)); + } + 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}; + 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) { + // 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(); + } + size_t len = 0; + esp_err_t err = hid_host_device_get_raw_input_report_data(handle, buf, cap, &len); + if (err != ESP_OK) { + return; + } + ev.len = 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()); + 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); + break; + } + } + return !dispatch_run_.load(); // true = stop the task +} + +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_) { + dispatch_task_->stop(); + dispatch_task_.reset(); + } + std::lock_guard lk(queue_mutex_); + queue_.clear(); + overflow_pool_.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, + 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 (on the driver task). + 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; + } + + // 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, 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) { + std::error_code sec; + if (!device->start(sec)) { + logger_.warn("hid_host_device_start failed: {}", sec.message()); + } + } +} + +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::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); + } + } + 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); + } +} + +} // namespace espp diff --git a/doc/Doxyfile b/doc/Doxyfile index f4c329a90..d21bb8297 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 65a8b9297..1ad45919f 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 000000000..faf8560a0 --- /dev/null +++ b/doc/en/buses/usb_host.rst @@ -0,0 +1,137 @@ +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`` 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 +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) -- devices() may be empty, so + // guard it (or keep the shared_ptr handed to on_device_connected and use that): + std::array payload{/* ... */}; + if (auto devs = host.devices(); !devs.empty()) + devs.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. + +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 +------- + +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 000000000..81588eddc --- /dev/null +++ b/doc/en/buses/usb_host_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/usb_host/example/README.md +```