Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
889d7ea
feat(usb_device): X-Input (Xbox 360) controller emulation
finger563 Sep 10, 2026
d4b0a0e
ci(usb_device): wire xinput_example into CI + docs
finger563 Sep 10, 2026
8be0d8d
fix(usb_device): force X-Input class-driver registration + safe IN su…
finger563 Sep 10, 2026
46da78c
fix(usb_device): tie XInput readiness to the actually-opened endpoint
finger563 Sep 10, 2026
1811890
fix(usb_device): X-Input review — tinyusb floor, atomic ep state, tes…
finger563 Sep 10, 2026
9e6ec3c
debug(usb_device): log + respond to X-Input control requests
finger563 Sep 10, 2026
4601df0
debug(usb_device): log raw X-Input OUT (rumble/LED) reports
finger563 Sep 10, 2026
1345390
fix(usb_device): restore correct X-Input IN endpoint (regression)
finger563 Sep 10, 2026
c1436da
fix(usb_device): match retail Xbox 360 XID descriptor byte
finger563 Sep 10, 2026
3a15bd2
debug(usb_device): log outgoing X-Input report bytes
finger563 Sep 10, 2026
ed05588
fix(usb_device): give X-Input IN/OUT separate endpoint numbers (DWC2 …
finger563 Sep 10, 2026
e553b32
fix(usb_device): don't answer X-Input GET_CAPABILITIES with zeros
finger563 Sep 10, 2026
bc705ad
fix(usb_device): don't post OUT reads — they corrupt the X-Input IN s…
finger563 Sep 10, 2026
6bb210f
test(usb_device): don't open the X-Input OUT endpoint (isolate IN cor…
finger563 Sep 11, 2026
96c0b89
fix(usb_device): 4-byte align X-Input DMA buffers (the real IN-corrup…
finger563 Sep 11, 2026
d52c1bb
fix(usb_device): address X-Input review (control, claim/release, clea…
finger563 Sep 11, 2026
9ac59da
docs(usb_device): update README for conditional CDC + X-Input enablement
finger563 Sep 11, 2026
2798fcb
fix(usb_device): X-Input review — XID byte, manifest example, docs
finger563 Sep 11, 2026
b3c7d66
refactor(usb_device): rename update_gamepad→update_xinput_state; hide…
finger563 Sep 11, 2026
96e45e9
fix(usb_device): console on UART0, not USB-Serial-JTAG, in the examples
finger563 Sep 11, 2026
9703c9d
refactor(usb_device): drop X-Input hardware-debug logging
finger563 Sep 11, 2026
20eebc7
Clarify logging and USB port usage in comments
finger563 Sep 11, 2026
1f781be
Update documentation for X-Input function
finger563 Sep 11, 2026
ef9eb8b
docs(usb_device): update_gamepad -> update_xinput_state in usb_cdc.rst
finger563 Sep 11, 2026
394b54f
fix(usb_device): require tinyusb >= 0.21 for the 5-arg usbd_edpt_xfer
finger563 Sep 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,9 @@ jobs:
target: esp32
- path: 'components/usb_device/example'
target: esp32s3
- path: 'components/usb_device/xinput_example'
target: esp32s3
command: 'IDF_COMPONENT_MANAGER=0 idf.py build'
Comment thread
finger563 marked this conversation as resolved.
- path: 'components/vl53l/example'
target: esp32s3
- path: 'components/wifi/example'
Expand Down
10 changes: 10 additions & 0 deletions components/usb_device/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,13 @@ idf_component_register(
SRC_DIRS "src"
REQUIRES base_component esp_tinyusb
)

# X-Input registers a custom TinyUSB application class driver by overriding the
# weak usbd_app_driver_get_cb(). Because TinyUSB's usbd.c both defines that weak
# symbol AND calls it in the same translation unit, our strong override in
# usb_device.cpp is only guaranteed to win if the linker is forced to resolve the
# symbol globally. `-u` enters it as an undefined reference so the linker binds it
# to the strong definition (and patches usbd.c's call to it). Without this, the
# X-Input interface enumerates but its endpoints are never opened, so no
# controller input reaches the host.
target_link_options(${COMPONENT_LIB} INTERFACE "-u" "usbd_app_driver_get_cb")
50 changes: 48 additions & 2 deletions components/usb_device/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ Today it can enable, in any combination (subject to the endpoint budget):
- A **HID** function (one interrupt IN, optionally one interrupt OUT) carrying an
application-supplied report descriptor (e.g. a gamepad built with the espp
`hid-rp` component), with input reports sent via `write_hid_report()`.
- An **X-Input** function that presents the device as a wired **Xbox 360
controller** (served by a small custom TinyUSB application class driver built
into this component — no `CFG_TUD_*` count required). Gamepad state is sent with
`update_xinput_state()` (`include/xinput.hpp`), and rumble/LED reports arrive via an
`on_rumble` callback. Because a PC's XUSB driver only binds a recognized Xbox
360 VID/PID, and because the built-in vendor class also claims interface class
0xFF, **use X-Input as the only enabled function** (it then advertises the Xbox
identity + 0xFF/0xFF/0xFF device class so the host recognizes it). See the
[`xinput_example`](xinput_example/). *These are Microsoft's IDs, for emulation /
testing of your own device only.*

Interface numbers, endpoint addresses and string indices are allocated
*sequentially* as functions are enabled, and the result is checked against the
Expand All @@ -35,6 +45,8 @@ for back-compatibility.
- [Features](#features)
- [API](#api)
- [Enabling the vendor / WebUSB class](#enabling-the-vendor--webusb-class)
- [Enabling the HID class](#enabling-the-hid-class)
- [Enabling X-Input (Xbox 360)](#enabling-x-input-xbox-360)
- [Endpoint budget (ESP32-S3 USB-OTG)](#endpoint-budget-esp32-s3-usb-otg)
- [Extending with HID / MSC](#extending-with-hid--msc)
- [Example](#example)
Expand Down Expand Up @@ -126,8 +138,6 @@ The vendor class is gated in `esp_tinyusb` behind a Kconfig option. To use the
vendor function, set in your project's `sdkconfig.defaults`:

```
CONFIG_TINYUSB_CDC_ENABLED=y
CONFIG_TINYUSB_CDC_COUNT=1
CONFIG_TINYUSB_VENDOR_COUNT=1 # THE key enablement: compiles in the vendor class
```

Expand All @@ -139,6 +149,18 @@ weak-callback overrides (`tud_descriptor_bos_cb`, `tud_vendor_control_xfer_cb`,
`tud_vendor_rx_cb`). If the vendor function is requested but `CFG_TUD_VENDOR == 0`,
`initialize()` fails with `std::errc::function_not_supported`.

CDC support is compiled conditionally (`#if CFG_TUD_CDC > 0`), so a vendor-only,
HID-only or X-Input-only build does **not** need CDC enabled. Enable it only when
you use the CDC function:

```
CONFIG_TINYUSB_CDC_ENABLED=y
CONFIG_TINYUSB_CDC_COUNT=1
```

(Requesting a CDC function while `CFG_TUD_CDC == 0` fails `initialize()` with
`std::errc::function_not_supported`, matching the vendor/HID checks.)

## Enabling the HID class

Like the vendor class, the HID class is gated in `esp_tinyusb` behind a Kconfig
Expand All @@ -157,6 +179,29 @@ builds them with the espp `hid-rp` component), assign them to
`write_hid_report(report_id, report)`. If the HID function is requested but
`CFG_TUD_HID == 0`, `initialize()` fails with `std::errc::function_not_supported`.

## Enabling X-Input (Xbox 360)

X-Input needs **no** `CFG_TUD_*` count — it is served by a custom TinyUSB
application class driver built into this component (registered via the weak
`usbd_app_driver_get_cb`, forced into the link with `-u`). So an X-Input-only
project needs no CDC/vendor/HID class enabled at all; the
[`xinput_example`](xinput_example/) sdkconfig disables them:

```
CONFIG_TINYUSB_CDC_ENABLED=n
CONFIG_TINYUSB_CDC_COUNT=0
# vendor/HID counts default to 0 — importantly, keep CFG_TUD_VENDOR at 0 so the
# built-in bulk vendor driver does not claim the X-Input 0xFF interface.
```

Set `Config::xinput` (only — see the "only enabled function" note above), send
gamepad state with `update_xinput_state(GamepadState)`, and receive rumble/LED reports
via `XInputFunction::on_rumble`. The interface uses one interrupt-IN endpoint
(0x81, 20-byte input reports) and one interrupt-OUT endpoint (rumble/LED); the two
use **separate endpoint numbers**, and the DMA report buffers are word-aligned, as
the ESP32-S3 DWC2 requires. See `include/xinput.hpp` for the report/`GamepadState`
API and the button/axis layout.

## Endpoint budget (ESP32-S3 USB-OTG)

The ESP32-S3 / -S2 USB-OTG core is full-speed and, besides EP0, provides roughly
Expand All @@ -168,6 +213,7 @@ consumes:
| CDC-ACM | 2 (1 interrupt-IN notif + 1 bulk-IN) | 1 (bulk-OUT) |
| Vendor / WebUSB | 1 (bulk-IN) | 1 (bulk-OUT) |
| HID | 1 (interrupt-IN) | 0 or 1 (optional interrupt-OUT) |
| X-Input (Xbox 360)| 1 (interrupt-IN) | 1 (interrupt-OUT) |
| MSC (future) | 1 (bulk-IN) | 1 (bulk-OUT) |

This is why the device is **selectable** ("not all at once"). Combinations that
Expand Down
16 changes: 10 additions & 6 deletions components/usb_device/example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ state (matching how a real ODrive splits its protocols across interfaces):
- **HID** → an animated **gamepad** input device (built with the `hid-rp`
component; visualize it with the WebHID `hid_visualizer.html`).

The device enumerates with an ODrive-like VID/PID (0x1209 / 0x0d32), separate from
the log console which stays on the USB-Serial-JTAG peripheral.
The device enumerates with an ODrive-like VID/PID (0x1209 / 0x0d32) on the native
USB port. The log console is on **UART0** (with USB-Serial-JTAG as an early-boot
secondary): on the ESP32-S3 the USB-Serial-JTAG controller and USB-OTG share the
same native USB PHY, so keeping the console on it would contend with the TinyUSB
interfaces here and reboot-loop the device.

Each protocol server is transport-agnostic: the CDC RX callback feeds bytes to
`OdriveAscii::process_bytes()`, the vendor RX callback feeds bytes to
Expand Down Expand Up @@ -58,8 +61,8 @@ idf.py build

## Flash and Monitor

Flash / monitor over the USB-Serial-JTAG (or UART) console, which is kept separate
from the native USB interfaces:
Flash / monitor over the UART0 console (a USB-UART adapter), which is independent
of the native USB interfaces this example presents:

```sh
idf.py flash monitor
Expand Down Expand Up @@ -111,5 +114,6 @@ and connect.
- The HID report descriptor is built with the `hid-rp` component
(`espp::GamepadInputReport`); the main loop animates the state and pushes reports
with `write_hid_report()` when the HID interface is ready.
- The log console remains on the USB-Serial-JTAG peripheral (see
`sdkconfig.defaults.esp32s3`).
- The log console is on UART0, with USB-Serial-JTAG as an early-boot secondary
(see `sdkconfig.defaults.esp32s3`) — it cannot stay on USB-Serial-JTAG because
that shares the native USB PHY with the USB-OTG interfaces here.
15 changes: 11 additions & 4 deletions components/usb_device/example/sdkconfig.defaults.esp32s3
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
# USB-OTG is available on the ESP32-S3 (also S2 / P4).
CONFIG_IDF_TARGET="esp32s3"

# Keep the log console on the built-in USB-Serial-JTAG peripheral so it stays
# completely separate from the native USB-OTG CDC interface created by
# espp::UsbCdc. (On an ESP32-S3 devkit these are two distinct USB connectors.)
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
# Put the log console on UART0, NOT the USB-Serial-JTAG peripheral. This example
# hands the native USB-OTG port to TinyUSB (the CDC / vendor / HID interfaces), and
# on the ESP32-S3 the USB-Serial-JTAG controller and USB-OTG share the SAME
# internal USB PHY / physical port (GPIO19/20) — they are NOT separate. Running the
# console on USB-Serial-JTAG therefore contends with the TinyUSB interfaces for that
# one port and makes the device reboot-loop at boot. UART0 is an independent
# peripheral (the devkit's other, "UART", connector via its USB-UART bridge), so the
# console is unaffected once TinyUSB takes over the native USB port. USB-Serial-JTAG
# stays as the SECONDARY console for early-boot logs before TinyUSB comes up.
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
Comment thread
finger563 marked this conversation as resolved.
12 changes: 12 additions & 0 deletions components/usb_device/idf_component.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ maintainers:
documentation: "https://esp-cpp.github.io/espp/buses/usb_cdc.html"
examples:
- path: example
- path: xinput_example
tags:
- cpp
- Component
Expand All @@ -18,8 +19,19 @@ tags:
- TinyUSB
- Serial
- Transport
- HID
- XInput
- Gamepad
dependencies:
idf:
version: '>=5.0'
espp/base_component: '>=1.0'
espressif/esp_tinyusb: '>=2.0'
# The X-Input class driver uses the 5-argument usbd_edpt_xfer(..., is_isr) API
# (and the usbd_class_driver_t `xfer_isr` member). TinyUSB 0.21 provides that
# 5-argument form; earlier releases (e.g. 0.19) still have the 4-argument
# usbd_edpt_xfer, which fails to compile here. esp_tinyusb only requires tinyusb
# >= 0.17, so pin the tested 0.21 floor here.
tinyusb:
version: '>=0.21.0'
public: true
71 changes: 69 additions & 2 deletions components/usb_device/include/usb_device.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
#include <vector>

#include "base_component.hpp"
#include "tinyusb.h" // for tinyusb_event_t (esp_tinyusb is already a REQUIRES dependency)
#include "tinyusb.h" // for tinyusb_event_t (esp_tinyusb is already a REQUIRES dependency)
#include "xinput.hpp" // X-Input (Xbox 360) gamepad state + descriptor helpers

namespace espp {

Expand Down Expand Up @@ -143,6 +144,35 @@ class UsbDevice : public BaseComponent {
uint8_t poll_interval_ms{10}; /**< Interrupt IN polling interval (bInterval), ms. */
};

/**
* @brief X-Input (Xbox 360 wired controller) function.
*
* Presents a vendor-specific interface (bInterfaceClass 0xFF / SubClass 0x5D /
* Protocol 0x01) with one interrupt IN endpoint (20-byte input reports, sent
* with `UsbDevice::update_xinput_state()`) and one interrupt OUT endpoint (8-byte
* rumble / LED reports, delivered to `on_rumble`). Unlike HID it is served by a
* small custom TinyUSB application class driver built into this component (no
* `CFG_TUD_*` count is required).
*
* A PC's XUSB driver only binds a device whose VID/PID is a recognized Xbox 360
* controller, so `vid` / `pid` default to Microsoft's identifiers
* (`0x045E:0x028E`) -- for emulation / testing of your own device only. When
* the XInput function is the ONLY enabled function these identifiers (and a
* 0xFF/0xFF/0xFF device class) override the top-level Config vid/pid so the
* host recognizes it; combine XInput with other functions only if you do not
* need XUSB to bind (the built-in vendor/WebUSB class also claims class 0xFF).
*
* Consumes 1 interrupt IN + 1 interrupt OUT endpoint.
*/
struct XInputFunction {
std::string interface_name{"espp XInput"}; /**< XInput interface string descriptor. */
uint16_t vid{espp::xinput::kDefaultVid}; /**< Xbox 360 controller VID (Microsoft). */
uint16_t pid{espp::xinput::kDefaultPid}; /**< Xbox 360 controller PID. */
/** @brief Callback invoked with received rumble / LED report bytes (8-byte
* reports on the interrupt OUT endpoint). Runs in the TinyUSB device task. */
receive_callback_fn on_rumble{nullptr};
};

/**
* @brief (Future) MSC (mass storage) function extension point. Not implemented yet.
*
Expand All @@ -168,6 +198,7 @@ class UsbDevice : public BaseComponent {
std::optional<CdcFunction> cdc{}; /**< Enable a CDC-ACM function. */
std::optional<VendorFunction> vendor{}; /**< Enable a vendor-specific / WebUSB function. */
std::optional<HidFunction> hid{}; /**< Enable a HID function. */
std::optional<XInputFunction> xinput{}; /**< Enable an X-Input (Xbox 360) function. */
std::optional<MscFunction> msc{}; /**< (Future) enable an MSC function. */

espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; /**< Logger verbosity. */
Expand Down Expand Up @@ -293,6 +324,24 @@ class UsbDevice : public BaseComponent {
/// new input report (no report in flight).
bool is_hid_ready() const;

/**
* @brief Send a fresh X-Input (Xbox 360) input report from a gamepad state.
* @param state Buttons / triggers / sticks to serialize into the 20-byte report.
* @param[out] ec Set on failure (XInput not enabled / not initialized, host not
* ready / a previous report still in flight, or a transfer error).
* @return true if the report was queued for transmission, false otherwise.
* @note Single-writer: call from one task. The report bytes are held in an
* internal buffer for the duration of the (asynchronous) transfer.
*/
bool update_xinput_state(const espp::xinput::GamepadState &state, std::error_code &ec);

/// @brief Convenience overload of update_xinput_state() that ignores errors.
bool update_xinput_state(const espp::xinput::GamepadState &state);

/// @brief Whether the XInput function is enabled, mounted and ready to accept a
/// new input report (no report in flight).
bool is_xinput_ready() const;

/// @brief Set or replace the CDC receive callback (nullptr to detach).
void set_cdc_receive_callback(const receive_callback_fn &cb);

Expand Down Expand Up @@ -321,9 +370,17 @@ class UsbDevice : public BaseComponent {
/// @brief Whether the vendor function is enabled and the device is mounted.
bool is_vendor_connected() const;

/// @brief Opaque bridge letting the TinyUSB C callback trampolines reach the
/// device-task-only methods below (defined in usb_device.cpp). An
/// implementation detail: it is incomplete here, with nothing callable
/// from application code.
struct Callbacks;

protected:
Comment thread
finger563 marked this conversation as resolved.
//
// Internal: invoked from the TinyUSB device task via C trampolines / weak
// overrides. Not intended to be called by application code.
// overrides (through the Callbacks bridge, or the friended event trampoline).
// Not part of the public API; not intended to be called by application code.
//

/// @brief Internal: drain the CDC RX FIFO and dispatch to the CDC callback.
Expand Down Expand Up @@ -353,6 +410,15 @@ class UsbDevice : public BaseComponent {
/// @brief Internal: config for the vendor control-request handler.
const std::optional<VendorFunction> &vendor_config() const { return config_.vendor; }

/// @brief Internal: dispatch received X-Input rumble / LED report bytes to the
/// on_rumble callback. Called from the XInput class driver's OUT
/// transfer-complete callback (TinyUSB device task context).
void handle_xinput_out(const uint8_t *buffer, size_t bufsize);

/// @brief Internal: the allocated X-Input IN endpoint address (0 if the XInput
/// function is not enabled). Used by the write path / readiness check.
uint8_t xinput_in_endpoint() const;

/// @brief Internal: the singleton instance handling the global USB callbacks.
static UsbDevice *instance();

Expand All @@ -375,6 +441,7 @@ class UsbDevice : public BaseComponent {
std::mutex cb_mutex_;
receive_callback_fn on_cdc_receive_;
receive_callback_fn on_vendor_receive_;
receive_callback_fn on_xinput_rumble_;
event_callback_fn on_mount_;
event_callback_fn on_unmount_;

Expand Down
Loading
Loading