Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 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
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 on lines +350 to +352
- 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_gamepad()` (`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_gamepad(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
10 changes: 10 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,17 @@ 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 usbd_class_driver_t `xfer_isr` member and
# the 5-argument usbd_edpt_xfer(..., is_isr) API, both TinyUSB >= 0.19. esp_tinyusb
# only requires tinyusb >= 0.17, so pin the newer floor here.
tinyusb:
version: '>=0.19.0'
public: true
64 changes: 63 additions & 1 deletion 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_gamepad()`) 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_gamepad(const espp::xinput::GamepadState &state, std::error_code &ec);

/// @brief Convenience overload of update_gamepad() that ignores errors.
bool update_gamepad(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 @@ -353,6 +402,18 @@ 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: whether the XInput function is enabled (for diagnostics).
bool xinput_active() const { return config_.xinput.has_value(); }

/// @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 +436,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
145 changes: 145 additions & 0 deletions components/usb_device/include/xinput.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
#pragma once

// Xbox 360 wired controller (X-Input) protocol helpers.
//
// This header is dependency-free and host-testable (no ESP-IDF / TinyUSB
// headers): it defines the X-Input wire constants, a gamepad-state model that
// packs the 20-byte input report, and a builder for the USB interface + XID +
// interrupt-endpoint descriptor bytes. `espp::UsbDevice`'s XInput function
// (usb_device.hpp) consumes these on-device; a host test exercises `report()`.
//
// X-Input is Microsoft's proprietary protocol for the Xbox 360 controller. The
// device presents a vendor-specific interface (bInterfaceClass 0xFF /
// bInterfaceSubClass 0x5D / bInterfaceProtocol 0x01) with one interrupt IN
// endpoint (20-byte input reports) and one interrupt OUT endpoint (8-byte
// rumble / LED reports). A PC's XUSB driver only binds a device whose VID/PID is
// a recognized Xbox 360 controller, so the defaults below are Microsoft's
// (0x045E:0x028E) -- use them only for emulation / testing of your own device.

#include <array>
#include <cstddef>
#include <cstdint>
#include <vector>

namespace espp {
namespace xinput {

/// Default identity of a wired Xbox 360 controller. These are Microsoft's IDs;
/// a host's XUSB driver only recognizes an X-Input controller by a known VID/PID,
/// so emulation requires advertising them (overridable via XInputFunction).
inline constexpr uint16_t kDefaultVid = 0x045E; ///< Microsoft
inline constexpr uint16_t kDefaultPid = 0x028E; ///< Xbox 360 Controller (wired)
inline constexpr uint16_t kDefaultBcdDevice = 0x0114; ///< reported device release

/// Vendor-specific interface class triple that identifies X-Input.
inline constexpr uint8_t kInterfaceClass = 0xFF;
inline constexpr uint8_t kInterfaceSubClass = 0x5D;
inline constexpr uint8_t kInterfaceProtocol = 0x01;

inline constexpr std::size_t kReportInSize = 20; ///< input report (device -> host)
inline constexpr std::size_t kReportOutSize = 8; ///< rumble / LED report (host -> device)
inline constexpr uint8_t kEpSize = 32; ///< interrupt endpoint wMaxPacketSize
inline constexpr uint8_t kInInterval = 4; ///< IN endpoint bInterval (ms, full speed)
inline constexpr uint8_t kOutInterval = 8; ///< OUT endpoint bInterval (ms, full speed)

/// Full byte length of the interface + XID + two endpoint descriptors emitted by
/// interface_descriptor(): 9 (interface) + 17 (XID) + 7 (IN ep) + 7 (OUT ep).
inline constexpr std::size_t kInterfaceDescriptorLen = 9 + 17 + 7 + 7; // 40

/// Button bit positions within GamepadState::buttons (little-endian on the wire:
/// the low byte is report byte 2, the high byte is report byte 3).
/// byte 2 (low): bit0 dpad-up, 1 down, 2 left, 3 right, 4 start, 5 back,
/// 6 left-stick (L3), 7 right-stick (R3)
/// byte 3 (high): bit0 LB, 1 RB, 2 Guide, (3 unused), 4 A, 5 B, 6 X, 7 Y
enum class Button : uint16_t {
DpadUp = 1u << 0,
DpadDown = 1u << 1,
DpadLeft = 1u << 2,
DpadRight = 1u << 3,
Start = 1u << 4,
Back = 1u << 5,
LeftStick = 1u << 6, ///< L3 (left stick click)
RightStick = 1u << 7, ///< R3 (right stick click)
LeftBumper = 1u << 8,
RightBumper = 1u << 9,
Guide = 1u << 10, ///< the center "Xbox" button
// bit 11 is unused / reserved
A = 1u << 12,
B = 1u << 13,
X = 1u << 14,
Y = 1u << 15,
};

/// The full gamepad state, packed into the 20-byte X-Input input report.
struct GamepadState {
uint16_t buttons{0}; ///< OR of Button values
uint8_t left_trigger{0}; ///< LT analog, 0..255
uint8_t right_trigger{0}; ///< RT analog, 0..255
int16_t lx{0}; ///< left stick X, -32768..32767 (right positive)
int16_t ly{0}; ///< left stick Y, -32768..32767 (up positive)
int16_t rx{0}; ///< right stick X
int16_t ry{0}; ///< right stick Y

/// Set or clear a button.
void set(Button b, bool on) {
if (on)
buttons |= static_cast<uint16_t>(b);
else
buttons &= static_cast<uint16_t>(~static_cast<uint16_t>(b));
}
bool get(Button b) const { return (buttons & static_cast<uint16_t>(b)) != 0; }

/// Serialize the 20-byte X-Input input report (little-endian axes).
std::array<uint8_t, kReportInSize> report() const {
std::array<uint8_t, kReportInSize> r{};
r[0] = 0x00; // message type (input report)
r[1] = 0x14; // message length (20)
r[2] = static_cast<uint8_t>(buttons & 0xFF);
r[3] = static_cast<uint8_t>((buttons >> 8) & 0xFF);
r[4] = left_trigger;
r[5] = right_trigger;
auto put16 = [&](std::size_t i, int16_t v) {
const uint16_t u = static_cast<uint16_t>(v);
r[i] = static_cast<uint8_t>(u & 0xFF);
r[i + 1] = static_cast<uint8_t>((u >> 8) & 0xFF);
};
put16(6, lx);
put16(8, ly);
put16(10, rx);
put16(12, ry);
// bytes 14..19 are reserved (already zero)
return r;
}
};

/// Build the interface + XID + two interrupt-endpoint descriptor bytes for an
/// X-Input interface. @p ep_in and @p ep_out are the full endpoint ADDRESSES
/// (e.g. 0x81 IN, 0x02 OUT). The retail controller shares endpoint number 1 for
/// both directions, but the ESP32-S3 DWC2 corrupts the IN stream when the number
/// is shared, so a separate OUT endpoint number is used; the XID blob's [6]/[13]
/// endpoint fields are set to match.
inline std::vector<uint8_t> interface_descriptor(uint8_t itf_num, uint8_t str_idx, uint8_t ep_in,
uint8_t ep_out, uint8_t in_interval = kInInterval,
uint8_t out_interval = kOutInterval) {
return {
// clang-format off
// Interface descriptor (9 bytes): vendor-specific 0xFF/0x5D/0x01, 2 endpoints.
0x09, 0x04 /* INTERFACE */, itf_num, 0x00 /* alt */, 0x02 /* num endpoints */,
kInterfaceClass, kInterfaceSubClass, kInterfaceProtocol, str_idx,
// XID "unknown" vendor descriptor (17 bytes), matching the Microsoft wired
// Xbox 360 controller (bLength 0x11, bDescriptorType 0x21). Byte [2] is 0x00
// on the retail controller and established XInput implementations. [6] = IN
// endpoint address, [7] = IN report size (0x14 = 20), [13] = OUT endpoint
// address, [14] = OUT report size (0x08 = 8).
0x11, 0x21, 0x00, 0x01, 0x01, 0x25,
ep_in, 0x14, 0x00, 0x00, 0x00, 0x00, 0x13, ep_out, 0x08, 0x00, 0x00,
// Endpoint IN (7 bytes): interrupt, wMaxPacketSize 32, bInterval.
0x07, 0x05 /* ENDPOINT */, ep_in, 0x03 /* interrupt */, kEpSize, 0x00, in_interval,
// Endpoint OUT (7 bytes): interrupt, wMaxPacketSize 32, bInterval.
0x07, 0x05 /* ENDPOINT */, ep_out, 0x03 /* interrupt */, kEpSize, 0x00, out_interval,
// clang-format on
};
}

} // namespace xinput
} // namespace espp
Loading
Loading