From 889d7eaba18d9a1f20c337428a70a00389f465d3 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 00:37:43 -0500 Subject: [PATCH 01/25] feat(usb_device): X-Input (Xbox 360) controller emulation Add an XInputFunction to espp::UsbDevice so a device can present itself as a wired Xbox 360 controller (X-Input) over USB-OTG, recognized by a PC's XUSB driver. X-Input is a vendor-specific interface (0xFF/0x5D/0x01) with interrupt IN+OUT endpoints and a fixed 20-byte input / 8-byte rumble report format -- it is NOT HID, and TinyUSB's built-in vendor driver is bulk-only, so this adds a small custom TinyUSB application class driver (registered via the weak usbd_app_driver_get_cb) that opens the interrupt endpoints, primes the OUT read, and dispatches rumble/LED reports to an on_rumble callback. - include/xinput.hpp: dependency-free, host-testable helpers -- X-Input wire constants, a GamepadState that packs the 20-byte report, a Button flag enum, and the interface+XID+endpoints descriptor builder. - usb_device.hpp/.cpp: XInputFunction config, the interface/endpoint allocation branch, the device-descriptor identity/class override when X-Input is the only function (Xbox 360 VID/PID 0x045E:0x028E + 0xFF/0xFF/0xFF class, so XUSB binds), update_gamepad()/is_xinput_ready(), and the class driver. Also guard the HID descriptor branch with CFG_TUD_HID so the component builds without the HID class driver compiled in. - xinput_example/: an esp32s3 demo that enumerates as an Xbox 360 controller, sweeps sticks/triggers, steps face buttons, and logs rumble. Manager-off build. - test/xinput_host_test.cpp: host test of report() + the descriptor builder. Caveats (deferred to on-device testing): use X-Input as the ONLY function (the built-in vendor class also claims class 0xFF; XUSB only binds a known Xbox VID/PID). The VID/PID are Microsoft's, for emulation/testing only. GIP (Xbox One) is not implemented (it needs a crypto auth handshake). control_xfer_cb stalls X-Input's optional vendor control requests; revisit if a host needs them. Builds clean on ESP-IDF v6.1 (esp32s3): xinput_example.bin, 58% free. Host test passes. Not yet validated against a real PC/XUSB host or hardware. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/README.md | 11 + components/usb_device/include/usb_device.hpp | 61 ++++- components/usb_device/include/xinput.hpp | 142 ++++++++++ components/usb_device/src/usb_device.cpp | 256 +++++++++++++++++- .../usb_device/test/xinput_host_test.cpp | 72 +++++ .../usb_device/xinput_example/CMakeLists.txt | 37 +++ .../xinput_example/main/CMakeLists.txt | 5 + .../xinput_example/main/xinput_example.cpp | 75 +++++ .../xinput_example/sdkconfig.defaults | 18 ++ 9 files changed, 663 insertions(+), 14 deletions(-) create mode 100644 components/usb_device/include/xinput.hpp create mode 100644 components/usb_device/test/xinput_host_test.cpp create mode 100644 components/usb_device/xinput_example/CMakeLists.txt create mode 100644 components/usb_device/xinput_example/main/CMakeLists.txt create mode 100644 components/usb_device/xinput_example/main/xinput_example.cpp create mode 100644 components/usb_device/xinput_example/sdkconfig.defaults diff --git a/components/usb_device/README.md b/components/usb_device/README.md index e50c574906..f99d3162a2 100644 --- a/components/usb_device/README.md +++ b/components/usb_device/README.md @@ -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 @@ -168,6 +178,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 diff --git a/components/usb_device/include/usb_device.hpp b/components/usb_device/include/usb_device.hpp index 7ee5483c27..a1063b4c2d 100644 --- a/components/usb_device/include/usb_device.hpp +++ b/components/usb_device/include/usb_device.hpp @@ -12,7 +12,8 @@ #include #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 { @@ -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. * @@ -168,6 +198,7 @@ class UsbDevice : public BaseComponent { std::optional cdc{}; /**< Enable a CDC-ACM function. */ std::optional vendor{}; /**< Enable a vendor-specific / WebUSB function. */ std::optional hid{}; /**< Enable a HID function. */ + std::optional xinput{}; /**< Enable an X-Input (Xbox 360) function. */ std::optional msc{}; /**< (Future) enable an MSC function. */ espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; /**< Logger verbosity. */ @@ -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); @@ -353,6 +402,15 @@ class UsbDevice : public BaseComponent { /// @brief Internal: config for the vendor control-request handler. const std::optional &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(); @@ -375,6 +433,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_; diff --git a/components/usb_device/include/xinput.hpp b/components/usb_device/include/xinput.hpp new file mode 100644 index 0000000000..289204d4f2 --- /dev/null +++ b/components/usb_device/include/xinput.hpp @@ -0,0 +1,142 @@ +#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 +#include +#include +#include + +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(b); + else + buttons &= static_cast(~static_cast(b)); + } + bool get(Button b) const { return (buttons & static_cast(b)) != 0; } + + /// Serialize the 20-byte X-Input input report (little-endian axes). + std::array report() const { + std::array r{}; + r[0] = 0x00; // message type (input report) + r[1] = 0x14; // message length (20) + r[2] = static_cast(buttons & 0xFF); + r[3] = static_cast((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(v); + r[i] = static_cast(u & 0xFF); + r[i + 1] = static_cast((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_num is the endpoint NUMBER n; the IN endpoint is +/// 0x80|n and the OUT endpoint is n (the XID blob embeds the IN endpoint address +/// and the report sizes, so it is patched to match @p ep_num). +inline std::vector interface_descriptor(uint8_t itf_num, uint8_t str_idx, uint8_t ep_num, + uint8_t in_interval = kInInterval, + uint8_t out_interval = kOutInterval) { + const uint8_t ep_in = static_cast(0x80 | ep_num); + const uint8_t ep_out = ep_num; + 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). [6] = IN endpoint address, + // [7] = IN report size (0x14 = 20), [14] = OUT report size (0x08 = 8). + 0x11, 0x21, 0x00, 0x01, 0x01, 0x25, + ep_in, 0x14, 0x00, 0x00, 0x00, 0x00, 0x13, 0x01, 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 diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index aa9e82ea72..7eb2a25624 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -11,6 +11,12 @@ #include "tinyusb_cdc_acm.h" #include "tinyusb_default_config.h" #include "tusb.h" +// TinyUSB private class-driver API (usbd_class_driver_t, usbd_edpt_*, +// usbd_app_driver_get_cb). `src/device` is a private include of the tinyusb +// component, but `src/` is public, so reach it via the `device/` prefix. +#include "device/usbd_pvt.h" + +#include "xinput.hpp" namespace { @@ -64,8 +70,118 @@ bool on_tinyusb_task() { return xTaskGetCurrentTaskHandle() == s_tinyusb_task.load(std::memory_order_relaxed); } +// --- X-Input (Xbox 360) custom TinyUSB application class driver --------------- +// TinyUSB's built-in vendor driver only handles BULK 0xFF interfaces; X-Input +// needs INTERRUPT IN+OUT on a 0xFF/0x5D/0x01 interface, so we register this +// application class driver via the weak usbd_app_driver_get_cb() override below. +// Only one USB device exists, so the driver's endpoint state is file-scope. The +// driver is always registered but open() only claims an X-Input interface, so it +// is inert when no XInput function is enabled. +struct XInputDriver { + uint8_t itf_num{0xFF}; + uint8_t ep_in{0}; + uint8_t ep_out{0}; + std::array out_buf{}; // interrupt-OUT receive buffer (>= kEpSize) +}; +XInputDriver s_xinput_drv; + +void xinput_drv_init() {} +bool xinput_drv_deinit() { return true; } +void xinput_drv_reset(uint8_t rhport) { + (void)rhport; + s_xinput_drv.itf_num = 0xFF; + s_xinput_drv.ep_in = 0; + s_xinput_drv.ep_out = 0; +} + +uint16_t xinput_drv_open(uint8_t rhport, tusb_desc_interface_t const *desc_itf, uint16_t max_len) { + // Only claim the X-Input interface (0xFF / 0x5D / 0x01); return 0 for anything + // else so the built-in CDC/HID/vendor drivers still handle their interfaces. + if (desc_itf->bInterfaceClass != espp::xinput::kInterfaceClass || + desc_itf->bInterfaceSubClass != espp::xinput::kInterfaceSubClass || + desc_itf->bInterfaceProtocol != espp::xinput::kInterfaceProtocol) + return 0; + + note_tinyusb_task(); + const uint8_t *desc_end = reinterpret_cast(desc_itf) + max_len; + const uint8_t *p = tu_desc_next(desc_itf); // skip the interface descriptor + s_xinput_drv.itf_num = desc_itf->bInterfaceNumber; + s_xinput_drv.ep_in = 0; + s_xinput_drv.ep_out = 0; + + // Walk to the endpoints (the XID vendor descriptor between them is skipped). + while (tu_desc_in_bounds(p, desc_end)) { + const uint8_t type = tu_desc_type(p); + if (type == TUSB_DESC_INTERFACE || type == TUSB_DESC_INTERFACE_ASSOCIATION) + break; + if (type == TUSB_DESC_ENDPOINT) { + const tusb_desc_endpoint_t *ep = reinterpret_cast(p); + if (!usbd_edpt_open(rhport, ep)) + return 0; + if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN) + s_xinput_drv.ep_in = ep->bEndpointAddress; + else + s_xinput_drv.ep_out = ep->bEndpointAddress; + } + p = tu_desc_next(p); + } + + // Prime the interrupt-OUT endpoint to receive the first rumble / LED report. + if (s_xinput_drv.ep_out) + usbd_edpt_xfer(rhport, s_xinput_drv.ep_out, s_xinput_drv.out_buf.data(), espp::xinput::kEpSize, + false); + + return static_cast(reinterpret_cast(p) - + reinterpret_cast(desc_itf)); +} + +bool xinput_drv_control_xfer(uint8_t rhport, uint8_t stage, tusb_control_request_t const *request) { + (void)rhport; + (void)stage; + (void)request; + // X-Input issues a few vendor/class control requests during init; none are + // needed for the interrupt data path, so leave them to be stalled (return + // false = "not handled by this driver"). Windows' XUSB driver tolerates this. + return false; +} + +bool xinput_drv_xfer_cb(uint8_t rhport, uint8_t ep_addr, xfer_result_t result, + uint32_t xferred_bytes) { + note_tinyusb_task(); + if (ep_addr == s_xinput_drv.ep_out) { + if (result == XFER_RESULT_SUCCESS && xferred_bytes > 0) { + auto *dev = s_device.load(); + if (dev) + dev->handle_xinput_out(s_xinput_drv.out_buf.data(), static_cast(xferred_bytes)); + } + // Re-prime the OUT endpoint for the next report. + usbd_edpt_xfer(rhport, s_xinput_drv.ep_out, s_xinput_drv.out_buf.data(), espp::xinput::kEpSize, + false); + } + // IN completion needs no action; usbd_edpt_busy() reflects readiness. + return true; +} + +const usbd_class_driver_t s_xinput_class_driver = { + .name = "xinput", + .init = xinput_drv_init, + .deinit = xinput_drv_deinit, + .reset = xinput_drv_reset, + .open = xinput_drv_open, + .control_xfer_cb = xinput_drv_control_xfer, + .xfer_cb = xinput_drv_xfer_cb, + .xfer_isr = nullptr, + .sof = nullptr, +}; + } // namespace +// Override TinyUSB's weak app-driver hook to register the X-Input class driver. +extern "C" usbd_class_driver_t const *usbd_app_driver_get_cb(uint8_t *driver_count) { + *driver_count = 1; + return &s_xinput_class_driver; +} + namespace espp { // Storage for the descriptors that TinyUSB references by pointer for the lifetime @@ -88,6 +204,12 @@ struct UsbDevice::Impl { // Allocated interface / endpoint identifiers, filled in during initialize(). uint8_t vendor_itf{0xFF}; uint8_t hid_itf{0xFF}; + uint8_t xinput_itf{0xFF}; + uint8_t xinput_ep_in{0}; // 0x80|n, or 0 if the XInput function is disabled + uint8_t xinput_ep_out{0}; // n, or 0 if disabled + // Input-report TX buffer; held for the duration of the async interrupt-IN + // transfer submitted by update_gamepad(). + std::array xinput_report{}; }; UsbDevice *UsbDevice::instance() { return s_device; } @@ -97,7 +219,8 @@ UsbDevice::UsbDevice(const Config &config) , impl_(std::make_unique()) , config_(config) , on_cdc_receive_(config.cdc ? config.cdc->on_receive : nullptr) - , on_vendor_receive_(config.vendor ? config.vendor->on_receive : nullptr) {} + , on_vendor_receive_(config.vendor ? config.vendor->on_receive : nullptr) + , on_xinput_rumble_(config.xinput ? config.xinput->on_rumble : nullptr) {} UsbDevice::~UsbDevice() { if (initialized_) { @@ -374,8 +497,8 @@ bool UsbDevice::initialize(std::error_code &ec) { ec = std::make_error_code(std::errc::device_or_resource_busy); return false; } - if (!config_.cdc && !config_.vendor && !config_.hid) { - logger_.error("No USB function enabled (enable cdc, vendor and/or hid)"); + if (!config_.cdc && !config_.vendor && !config_.hid && !config_.xinput) { + logger_.error("No USB function enabled (enable cdc, vendor, hid and/or xinput)"); ec = std::make_error_code(std::errc::invalid_argument); return false; } @@ -471,7 +594,9 @@ bool UsbDevice::initialize(std::error_code &ec) { impl_->vendor_itf = vendor_itf; } - uint8_t hid_itf = 0, hid_str = 0, hid_in = 0, hid_out = 0; + // hid_str/hid_in/hid_out are consumed only in the CFG_TUD_HID-guarded + // descriptor branch below, so they are unused when HID is not compiled in. + [[maybe_unused]] uint8_t hid_itf = 0, hid_str = 0, hid_in = 0, hid_out = 0; if (config_.hid) { hid_itf = next_itf++; hid_str = next_str++; @@ -488,6 +613,19 @@ bool UsbDevice::initialize(std::error_code &ec) { impl_->hid_report_desc = config_.hid->report_descriptor; } + uint8_t xinput_itf = 0, xinput_str = 0, xinput_ep = 0; + if (config_.xinput) { + xinput_itf = next_itf++; + xinput_str = next_str++; + impl_->owned_strings.push_back(config_.xinput->interface_name); + xinput_ep = next_ep++; // IN = 0x80|n, OUT = n + impl_->xinput_ep_in = static_cast(0x80 | xinput_ep); // interrupt IN + impl_->xinput_ep_out = xinput_ep; // interrupt OUT + in_used++; + out_used++; + impl_->xinput_itf = xinput_itf; + } + // --- Endpoint budget check --- if (in_used > kMaxInEndpoints || out_used > kMaxOutEndpoints) { logger_.error("Endpoint budget exceeded: IN={} (max {}), OUT={} (max {})", in_used, @@ -504,6 +642,11 @@ bool UsbDevice::initialize(std::error_code &ec) { // --- Device descriptor --- const bool webusb = config_.vendor && config_.vendor->webusb; + // When the X-Input function is the ONLY function, the device must present the + // Xbox 360 controller's identity (VID/PID/bcdDevice) and a 0xFF/0xFF/0xFF + // device class so a PC's XUSB driver binds it. Combining XInput with other + // functions keeps the normal composite identity (and XUSB will not bind). + const bool xinput_only = config_.xinput && !config_.cdc && !config_.vendor && !config_.hid; impl_->device_desc = tusb_desc_device_t{}; impl_->device_desc.bLength = sizeof(tusb_desc_device_t); impl_->device_desc.bDescriptorType = TUSB_DESC_DEVICE; @@ -512,8 +655,13 @@ bool UsbDevice::initialize(std::error_code &ec) { // Advertise the IAD-based composite class (0xEF/0x02/0x01) only when CDC is // enabled, since CDC is the function that emits an Interface Association // Descriptor. For a vendor-only and/or HID-only device there is no IAD, so use - // 0x00/0x00/0x00 and let the interface descriptors declare the class(es). - if (config_.cdc) { + // 0x00/0x00/0x00 and let the interface descriptors declare the class(es). An + // X-Input-only device declares the Xbox controller's 0xFF/0xFF/0xFF class. + if (xinput_only) { + impl_->device_desc.bDeviceClass = 0xFF; + impl_->device_desc.bDeviceSubClass = 0xFF; + impl_->device_desc.bDeviceProtocol = 0xFF; + } else if (config_.cdc) { impl_->device_desc.bDeviceClass = TUSB_CLASS_MISC; impl_->device_desc.bDeviceSubClass = MISC_SUBCLASS_COMMON; impl_->device_desc.bDeviceProtocol = MISC_PROTOCOL_IAD; @@ -523,9 +671,9 @@ bool UsbDevice::initialize(std::error_code &ec) { impl_->device_desc.bDeviceProtocol = 0x00; } impl_->device_desc.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE; - impl_->device_desc.idVendor = config_.vid; - impl_->device_desc.idProduct = config_.pid; - impl_->device_desc.bcdDevice = 0x0100; + impl_->device_desc.idVendor = xinput_only ? config_.xinput->vid : config_.vid; + impl_->device_desc.idProduct = xinput_only ? config_.xinput->pid : config_.pid; + impl_->device_desc.bcdDevice = xinput_only ? espp::xinput::kDefaultBcdDevice : 0x0100; impl_->device_desc.iManufacturer = 0x01; impl_->device_desc.iProduct = 0x02; impl_->device_desc.iSerialNumber = 0x03; @@ -547,6 +695,10 @@ bool UsbDevice::initialize(std::error_code &ec) { total_len = static_cast( total_len + (config_.hid->has_out_endpoint ? TUD_HID_INOUT_DESC_LEN : TUD_HID_DESC_LEN)); } + if (config_.xinput) { + itf_count = static_cast(itf_count + 1); + total_len = static_cast(total_len + espp::xinput::kInterfaceDescriptorLen); + } // Build one configuration descriptor for a given bus speed. Bulk endpoints // are 64 bytes at full speed and 512 at high speed; the HID interrupt @@ -577,6 +729,11 @@ bool UsbDevice::initialize(std::error_code &ec) { }; append(d, sizeof(d)); } +#if (CFG_TUD_HID > 0) + // Guarded because the TUD_HID_* macros reference HID class constants only + // declared when the HID class driver is compiled in. config_.hid can never be + // set here when CFG_TUD_HID==0 (initialize() rejects it earlier), so this + // branch is dead in that case and safe to compile out. if (config_.hid) { const uint16_t report_len = static_cast(impl_->hid_report_desc.size()); // Interrupt endpoints are <=64 byte packets at either speed; a 64-byte @@ -599,6 +756,16 @@ bool UsbDevice::initialize(std::error_code &ec) { append(d, sizeof(d)); } } +#endif + if (config_.xinput) { + // Hand-built interface + XID + two interrupt endpoints (the built-in TinyUSB + // descriptor macros can't express X-Input's class triple / XID blob). The + // bIntervals are the full-speed values; on an HS-capable part they are + // interpreted as exponents, but X-Input is a full-speed protocol (and the + // ESP32-S3 USB-OTG is full speed). + const auto d = espp::xinput::interface_descriptor(xinput_itf, xinput_str, xinput_ep); + append(d.data(), d.size()); + } }; const uint8_t hid_poll_ms = config_.hid ? config_.hid->poll_interval_ms : 0; @@ -892,10 +1059,14 @@ bool UsbDevice::initialize(std::error_code &ec) { } initialized_ = true; - logger_.info( - "Initialized native USB device (VID=0x{:04x} PID=0x{:04x}) cdc={} vendor={} hid={}{}", - config_.vid, config_.pid, config_.cdc.has_value(), config_.vendor.has_value(), - config_.hid.has_value(), webusb ? " webusb" : ""); + // Copy the packed descriptor fields into locals: they cannot bind to the + // logger's const-reference parameters directly. + const uint16_t enum_vid = impl_->device_desc.idVendor; + const uint16_t enum_pid = impl_->device_desc.idProduct; + logger_.info("Initialized native USB device (VID=0x{:04x} PID=0x{:04x}) cdc={} vendor={} hid={} " + "xinput={}{}", + enum_vid, enum_pid, config_.cdc.has_value(), config_.vendor.has_value(), + config_.hid.has_value(), config_.xinput.has_value(), webusb ? " webusb" : ""); return true; } @@ -1270,4 +1441,63 @@ bool UsbDevice::is_hid_ready() const { #endif } +// --------------------------------------------------------------------------- +// X-Input (Xbox 360) function. +// --------------------------------------------------------------------------- + +uint8_t UsbDevice::xinput_in_endpoint() const { return impl_->xinput_ep_in; } + +void UsbDevice::handle_xinput_out(const uint8_t *buffer, size_t bufsize) { + receive_callback_fn cb; + { + std::scoped_lock lk(cb_mutex_); + cb = on_xinput_rumble_; + } + if (cb && buffer && bufsize > 0) + cb(std::span(buffer, bufsize)); // TinyUSB task context +} + +bool UsbDevice::update_gamepad(const espp::xinput::GamepadState &state, std::error_code &ec) { + ec.clear(); + if (!initialized_ || !config_.xinput) { + ec = std::make_error_code(std::errc::not_connected); + return false; + } + const uint8_t ep_in = impl_->xinput_ep_in; + if (!tud_mounted() || ep_in == 0) { + ec = std::make_error_code(std::errc::not_connected); + return false; + } + if (usbd_edpt_busy(0, ep_in)) { + // A previous report is still in flight -- transient backpressure, distinct + // from a disconnect so callers can retry on the next tick. + ec = std::make_error_code(std::errc::resource_unavailable_try_again); + return false; + } + // Serialize into the persistent buffer, then submit the interrupt-IN transfer. + // The buffer must outlive the (asynchronous) transfer, so it lives in Impl. + // Single-writer (documented), so a busy check + xfer is sufficient; the CLAIM + // bit is only needed to arbitrate multiple submitters on one endpoint. + impl_->xinput_report = state.report(); + if (!usbd_edpt_xfer(0, ep_in, impl_->xinput_report.data(), + static_cast(impl_->xinput_report.size()), false)) { + logger_.warn_rate_limited("XInput report send failed"); + ec = std::make_error_code(std::errc::io_error); + return false; + } + return true; +} + +bool UsbDevice::update_gamepad(const espp::xinput::GamepadState &state) { + std::error_code ec; + return update_gamepad(state, ec); +} + +bool UsbDevice::is_xinput_ready() const { + if (!initialized_ || !config_.xinput) + return false; + const uint8_t ep_in = impl_->xinput_ep_in; + return tud_mounted() && ep_in != 0 && !usbd_edpt_busy(0, ep_in); +} + } // namespace espp diff --git a/components/usb_device/test/xinput_host_test.cpp b/components/usb_device/test/xinput_host_test.cpp new file mode 100644 index 0000000000..c0c5c03939 --- /dev/null +++ b/components/usb_device/test/xinput_host_test.cpp @@ -0,0 +1,72 @@ +// Host-buildable unit tests for the espp X-Input (Xbox 360) helpers in +// include/xinput.hpp: the 20-byte input-report packing and the interface + XID + +// endpoint descriptor builder. No ESP-IDF / TinyUSB headers required. Build&run: +// c++ -std=c++20 -Wall -Wextra -Werror -I components/usb_device/include \ +// components/usb_device/test/xinput_host_test.cpp -o test && ./test + +#include + +#include "xinput.hpp" + +using namespace espp::xinput; + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf("FAIL (%s:%d): %s\n", __FILE__, __LINE__, #cond); \ + ++g_failures; \ + } \ + } while (0) + +static int16_t rd16(const std::array &r, size_t i) { + return static_cast(r[i] | (r[i + 1] << 8)); +} + +int main() { + // --- input report packing --- + GamepadState s; + s.set(Button::A, true); + s.set(Button::DpadUp, true); + CHECK(s.get(Button::A) && s.get(Button::DpadUp) && !s.get(Button::B)); + s.left_trigger = 200; + s.right_trigger = 50; + s.lx = 1000; + s.ly = -2000; + s.rx = 32767; + s.ry = -32768; + const auto r = s.report(); + CHECK(r.size() == 20); + CHECK(r[0] == 0x00 && r[1] == 0x14); // type + length + CHECK(r[2] == 0x01); // dpad-up -> byte2 bit0 + CHECK(r[3] == 0x10); // A -> byte3 bit4 + CHECK(r[4] == 200 && r[5] == 50); // triggers + CHECK(rd16(r, 6) == 1000 && rd16(r, 8) == -2000); + CHECK(rd16(r, 10) == 32767 && rd16(r, 12) == -32768); + for (size_t i = 14; i < 20; ++i) + CHECK(r[i] == 0); + + // clearing a button + s.set(Button::A, false); + CHECK(s.report()[3] == 0x00); + + // --- descriptor builder (itf 3, string 5, endpoint number 2 -> IN 0x82, OUT 0x02) --- + const auto d = interface_descriptor(3, 5, 2); + CHECK(d.size() == kInterfaceDescriptorLen && d.size() == 40); + // interface descriptor + CHECK(d[0] == 0x09 && d[1] == 0x04 && d[2] == 3 && d[4] == 0x02); + CHECK(d[5] == kInterfaceClass && d[6] == kInterfaceSubClass && d[7] == kInterfaceProtocol); + CHECK(d[8] == 5); + // XID blob, with the IN endpoint address patched in + CHECK(d[9] == 0x11 && d[10] == 0x21 && d[15] == 0x82 && d[16] == 0x14 && d[23] == 0x08); + // IN endpoint (interrupt, size 32) + CHECK(d[26] == 0x07 && d[27] == 0x05 && d[28] == 0x82 && d[29] == 0x03 && d[30] == kEpSize); + // OUT endpoint + CHECK(d[33] == 0x07 && d[34] == 0x05 && d[35] == 0x02 && d[36] == 0x03 && d[37] == kEpSize); + + if (g_failures == 0) + std::printf("all xinput host tests passed\n"); + else + std::printf("%d FAILURE(S)\n", g_failures); + return g_failures == 0 ? 0 : 1; +} diff --git a/components/usb_device/xinput_example/CMakeLists.txt b/components/usb_device/xinput_example/CMakeLists.txt new file mode 100644 index 0000000000..ed57bbda1f --- /dev/null +++ b/components/usb_device/xinput_example/CMakeLists.txt @@ -0,0 +1,37 @@ +# 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/usb_device" +) + +# With the component manager disabled (IDF_COMPONENT_MANAGER=0, e.g. in CI so the +# build does not need the as-yet unpublished espp/* components in the registry), +# esp_tinyusb/tinyusb are not fetched from the registry; add the vendored +# submodule copies under external/ to the search path. esp_tinyusb's CMakeLists +# adds `tinyusb` to its REQUIRES when the manager is off, so both directories +# must be discoverable. +if(DEFINED ENV{IDF_COMPONENT_MANAGER} AND "$ENV{IDF_COMPONENT_MANAGER}" STREQUAL "0") + list(APPEND EXTRA_COMPONENT_DIRS + "../../../external/esp-usb/device/esp_tinyusb" + "../../../external/tinyusb" + ) +endif() + +set( + COMPONENTS + "main esptool_py base_component format logger usb_device esp_tinyusb" + CACHE STRING + "List of components to include" + ) + +project(xinput_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/usb_device/xinput_example/main/CMakeLists.txt b/components/usb_device/xinput_example/main/CMakeLists.txt new file mode 100644 index 0000000000..e264820351 --- /dev/null +++ b/components/usb_device/xinput_example/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." + REQUIRES usb_device esp_tinyusb +) diff --git a/components/usb_device/xinput_example/main/xinput_example.cpp b/components/usb_device/xinput_example/main/xinput_example.cpp new file mode 100644 index 0000000000..9efdfbf2b0 --- /dev/null +++ b/components/usb_device/xinput_example/main/xinput_example.cpp @@ -0,0 +1,75 @@ +// Xbox 360 (X-Input) controller emulation example. +// +// Presents the ESP32-S3 as a wired Xbox 360 controller over the native USB-OTG +// peripheral using espp::UsbDevice's XInput function. A PC's XUSB driver binds +// it (VID/PID default to Microsoft's 0x045E:0x028E), so it shows up as an Xbox +// 360 controller in the OS gamepad tester / games. This demo cycles the buttons +// and sweeps the sticks/triggers so you can see live input, and logs any rumble +// / LED reports the host sends back. The console/logs go to the separate +// built-in USB-Serial-JTAG. + +#include +#include +#include + +#include "logger.hpp" +#include "usb_device.hpp" + +using namespace std::chrono_literals; +using espp::xinput::Button; +using espp::xinput::GamepadState; + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "XInput", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting Xbox 360 (X-Input) controller example"); + + espp::UsbDevice::Config cfg; + cfg.product = "espp XInput Controller"; + cfg.log_level = espp::Logger::Verbosity::INFO; + + espp::UsbDevice::XInputFunction xinput; + // Rumble / LED reports (8-byte host->device on the interrupt OUT endpoint). + xinput.on_rumble = [&](std::span data) { + if (data.size() >= 5 && data[0] == 0x00) // 0x00 = rumble report + logger.info("rumble: left={} right={}", data[3], data[4]); + else if (!data.empty() && data[0] == 0x01) // 0x01 = LED report + logger.info("led pattern: {}", data.size() >= 3 ? data[2] : 0); + }; + cfg.xinput = xinput; // XInput is the ONLY function -> Xbox identity + XUSB bind + + espp::UsbDevice usb(cfg); + std::error_code ec; + if (!usb.initialize(ec)) { + logger.error("Failed to initialize USB device: {}", ec.message()); + return; + } + + logger.info("Ready. Connect to a PC; it should enumerate as an Xbox 360 controller."); + + // Demo input generator: sweep the sticks/triggers in a circle and step the + // face buttons A/B/X/Y one at a time each second. + GamepadState state; + const Button face[] = {Button::A, Button::B, Button::X, Button::Y}; + int tick = 0; + while (true) { + const float t = tick * 0.02f; // 50 Hz + const float two_pi = 6.2831853f; + state.lx = static_cast(std::sin(two_pi * 0.25f * t) * 32000); + state.ly = static_cast(std::cos(two_pi * 0.25f * t) * 32000); + state.rx = static_cast(std::sin(two_pi * 0.5f * t) * 20000); + state.ry = static_cast(std::cos(two_pi * 0.5f * t) * 20000); + const uint8_t tri = static_cast((std::sin(two_pi * 0.5f * t) * 0.5f + 0.5f) * 255); + state.left_trigger = tri; + state.right_trigger = static_cast(255 - tri); + + // One face button on at a time, changing each second. + for (auto b : face) + state.set(b, false); + state.set(face[(tick / 50) % 4], true); + + usb.update_gamepad(state); // no-op / retry-later while not mounted or busy + + tick++; + std::this_thread::sleep_for(20ms); + } +} diff --git a/components/usb_device/xinput_example/sdkconfig.defaults b/components/usb_device/xinput_example/sdkconfig.defaults new file mode 100644 index 0000000000..5465ceea72 --- /dev/null +++ b/components/usb_device/xinput_example/sdkconfig.defaults @@ -0,0 +1,18 @@ +# This example uses the native USB-OTG peripheral, which is only available on the +# ESP32-S3 (also S2 / P4) -- NOT the classic ESP32. Pin the target here so a bare +# `idf.py build` does not fall back to esp32. +CONFIG_IDF_TARGET="esp32s3" + +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# Keep the console/logs on the built-in USB-Serial-JTAG so they stay separate +# from the native USB-OTG interface presented as the Xbox controller. +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y + +# The X-Input interface is served by usb_device's own custom TinyUSB application +# class driver, so NO built-in class driver count is required (vendor/HID stay 0, +# which also avoids the built-in vendor driver claiming the 0xFF interface). But +# usb_device.cpp includes tinyusb_cdc_acm.h unconditionally, so the CDC feature +# must be compiled in even though this example creates no CDC interface. +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 From d4b0a0eebae1c82e4010723eac36dccf152ea316 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 00:40:18 -0500 Subject: [PATCH 02/25] ci(usb_device): wire xinput_example into CI + docs - build.yml: build components/usb_device/xinput_example (esp32s3, IDF_COMPONENT_MANAGER=0, matching the example's vendored-submodule setup), placed after usb_device/example. - Doxyfile: add include/xinput.hpp and xinput_example/main/xinput_example.cpp to the INPUT lists (alphabetical slots). - doc/en/buses/usb_cdc.rst: include inc/xinput.inc so the X-Input API renders on the usb_device docs page. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .github/workflows/build.yml | 3 +++ doc/Doxyfile | 2 ++ doc/en/buses/usb_cdc.rst | 1 + 3 files changed, 6 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 13fe62190f..4f7f29e1c5 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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' - path: 'components/vl53l/example' target: esp32s3 - path: 'components/wifi/example' diff --git a/doc/Doxyfile b/doc/Doxyfile index bd06826e89..3f1a0b9e69 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -199,6 +199,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/twai/example/main/twai_example.cpp \ $(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/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 \ @@ -465,6 +466,7 @@ INPUT = \ $(PROJECT_PATH)/components/tt21100/include/tt21100.hpp \ $(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/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/usb_cdc.rst b/doc/en/buses/usb_cdc.rst index 5d4ff3295b..8278eb2e1d 100644 --- a/doc/en/buses/usb_cdc.rst +++ b/doc/en/buses/usb_cdc.rst @@ -212,3 +212,4 @@ API Reference .. include-build-file:: inc/usb_device.inc .. include-build-file:: inc/usb_cdc.inc +.. include-build-file:: inc/xinput.inc From 8be0d8d90ca6c0603c5f6ebff44e71dbe9db5604 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 09:55:38 -0500 Subject: [PATCH 03/25] fix(usb_device): force X-Input class-driver registration + safe IN submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-Input enumerated (Windows shows the Xbox 360 controller by VID/PID) but sent no input. Two root causes: 1. usbd_app_driver_get_cb() is a weak symbol that TinyUSB's usbd.c both defines AND calls in the same translation unit, so our strong override could be ignored at link time — the app class driver never registered, its open() never ran, and the interrupt endpoints were never opened. Force the linker to bind our strong definition with `-u usbd_app_driver_get_cb` (component CMakeLists). Verified in the ELF: usbd_app_driver_get_cb is now our global symbol returning driver_count=1 and &s_xinput_class_driver. 2. update_gamepad() submitted the interrupt-IN transfer from the caller's task with a bare busy-check + usbd_edpt_xfer(), which can race the USB task and wedge the endpoint. Claim the endpoint first (usbd_edpt_claim, releasing on failure) — the same cross-task pattern tud_hid_report() uses. Also add USB-Serial-JTAG diagnostics so the data path is observable on device: the class driver logs when it registers and when it opens (with the endpoint addresses), and update_gamepad logs a "reports flowing" heartbeat plus the reason on a not-ready / failed send. Builds clean on ESP-IDF v6.1 (esp32s3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/CMakeLists.txt | 10 +++++++ components/usb_device/src/usb_device.cpp | 33 +++++++++++++++++++----- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/components/usb_device/CMakeLists.txt b/components/usb_device/CMakeLists.txt index 4fed54b8eb..59b8cb1810 100644 --- a/components/usb_device/CMakeLists.txt +++ b/components/usb_device/CMakeLists.txt @@ -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") diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index 7eb2a25624..bffd044a16 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -4,6 +4,7 @@ #include #include +#include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -131,6 +132,15 @@ uint16_t xinput_drv_open(uint8_t rhport, tusb_desc_interface_t const *desc_itf, usbd_edpt_xfer(rhport, s_xinput_drv.ep_out, s_xinput_drv.out_buf.data(), espp::xinput::kEpSize, false); + // Diagnostic (USB-Serial-JTAG console): if this line does NOT appear when the + // host enumerates the device, the app class driver was not registered (the + // usbd_app_driver_get_cb weak override did not take effect) and no reports can + // flow even though Windows shows the device by VID/PID. + ESP_LOGI("espp_xinput", "class driver open: itf=%u ep_in=0x%02x ep_out=0x%02x", + s_xinput_drv.itf_num, s_xinput_drv.ep_in, s_xinput_drv.ep_out); + if (s_xinput_drv.ep_in == 0) + ESP_LOGW("espp_xinput", "no interrupt IN endpoint opened -- host will get no input reports"); + return static_cast(reinterpret_cast(p) - reinterpret_cast(desc_itf)); } @@ -177,7 +187,11 @@ const usbd_class_driver_t s_xinput_class_driver = { } // namespace // Override TinyUSB's weak app-driver hook to register the X-Input class driver. +// NOTE: usbd.c both defines this as weak AND calls it in the same translation +// unit, so this strong override only wins if the linker keeps it — the +// usb_device component CMakeLists forces it with `-u usbd_app_driver_get_cb`. extern "C" usbd_class_driver_t const *usbd_app_driver_get_cb(uint8_t *driver_count) { + ESP_LOGI("espp_xinput", "registering X-Input application class driver"); *driver_count = 1; return &s_xinput_class_driver; } @@ -1465,26 +1479,31 @@ bool UsbDevice::update_gamepad(const espp::xinput::GamepadState &state, std::err } const uint8_t ep_in = impl_->xinput_ep_in; if (!tud_mounted() || ep_in == 0) { + logger_.warn_rate_limited("XInput not ready to send: mounted={} ep_in=0x{:02x}", tud_mounted(), + ep_in); ec = std::make_error_code(std::errc::not_connected); return false; } - if (usbd_edpt_busy(0, ep_in)) { - // A previous report is still in flight -- transient backpressure, distinct - // from a disconnect so callers can retry on the next tick. + // update_gamepad() runs on the caller's task, not the TinyUSB task, so claim + // the endpoint (atomic, mutex-guarded) before submitting — the same pattern + // tud_hid_report() uses. This both arbitrates against the USB task and is the + // reliable way to hand a transfer to the interrupt-IN endpoint cross-task; a + // bare busy-check + xfer can race and wedge the endpoint. claim() fails if a + // previous report is still in flight (transient backpressure). + if (!usbd_edpt_claim(0, ep_in)) { ec = std::make_error_code(std::errc::resource_unavailable_try_again); return false; } - // Serialize into the persistent buffer, then submit the interrupt-IN transfer. // The buffer must outlive the (asynchronous) transfer, so it lives in Impl. - // Single-writer (documented), so a busy check + xfer is sufficient; the CLAIM - // bit is only needed to arbitrate multiple submitters on one endpoint. impl_->xinput_report = state.report(); if (!usbd_edpt_xfer(0, ep_in, impl_->xinput_report.data(), static_cast(impl_->xinput_report.size()), false)) { - logger_.warn_rate_limited("XInput report send failed"); + usbd_edpt_release(0, ep_in); // undo the claim so the endpoint isn't wedged + logger_.warn_rate_limited("XInput report send (usbd_edpt_xfer) failed on ep 0x{:02x}", ep_in); ec = std::make_error_code(std::errc::io_error); return false; } + logger_.info_rate_limited("XInput reports flowing on ep 0x{:02x}", ep_in); return true; } From 46da78c978f82d4702d0ab6baad2731cc7fcbaf7 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 10:09:48 -0500 Subject: [PATCH 04/25] fix(usb_device): tie XInput readiness to the actually-opened endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review on the X-Input driver: - is_xinput_ready()/update_gamepad() now use the endpoint the class driver actually OPENED (s_xinput_drv.ep_in, set in open() and cleared on bus reset) instead of the planned address from allocation. So if open() never ran (e.g. the app driver failed to register) they correctly report not-ready rather than pretending the endpoint exists. - Document that TinyUSB tries application class drivers BEFORE the built-in ones (usbd.c get_driver/process_set_config iterate app drivers first), so our X-Input driver claims the 0xFF/0x5D/0x01 interface before the built-in vendor (bulk) driver can — even when CFG_TUD_VENDOR>0. (The two review comments assume the built-in vendor driver wins the interface, and that a 0xC1 interface- recipient vendor request routes to the global tud_vendor_control_xfer_cb; per usbd.c both are handled by the owning app driver instead.) Builds clean on ESP-IDF v6.1 (esp32s3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/src/usb_device.cpp | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index bffd044a16..e09a89cf98 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -98,6 +98,10 @@ void xinput_drv_reset(uint8_t rhport) { uint16_t xinput_drv_open(uint8_t rhport, tusb_desc_interface_t const *desc_itf, uint16_t max_len) { // Only claim the X-Input interface (0xFF / 0x5D / 0x01); return 0 for anything // else so the built-in CDC/HID/vendor drivers still handle their interfaces. + // NOTE: application class drivers are tried BEFORE the built-in ones + // (usbd.c get_driver / process_set_config iterate app drivers first, "to allow + // overwriting built-in ones"), so even when CFG_TUD_VENDOR>0 this driver claims + // the X-Input 0xFF interface before the built-in vendor (bulk) driver can. if (desc_itf->bInterfaceClass != espp::xinput::kInterfaceClass || desc_itf->bInterfaceSubClass != espp::xinput::kInterfaceSubClass || desc_itf->bInterfaceProtocol != espp::xinput::kInterfaceProtocol) @@ -1477,7 +1481,11 @@ bool UsbDevice::update_gamepad(const espp::xinput::GamepadState &state, std::err ec = std::make_error_code(std::errc::not_connected); return false; } - const uint8_t ep_in = impl_->xinput_ep_in; + // Use the endpoint the class driver actually OPENED (set in xinput_drv_open, + // cleared on bus reset), not the planned address from allocation. So if open() + // never ran (e.g. the app driver failed to register) this correctly reports + // not-ready instead of pretending the endpoint exists. + const uint8_t ep_in = s_xinput_drv.ep_in; if (!tud_mounted() || ep_in == 0) { logger_.warn_rate_limited("XInput not ready to send: mounted={} ep_in=0x{:02x}", tud_mounted(), ep_in); @@ -1515,7 +1523,9 @@ bool UsbDevice::update_gamepad(const espp::xinput::GamepadState &state) { bool UsbDevice::is_xinput_ready() const { if (!initialized_ || !config_.xinput) return false; - const uint8_t ep_in = impl_->xinput_ep_in; + // The endpoint the class driver actually opened (0 until open() runs), so this + // never reports ready before the interface is truly configured. + const uint8_t ep_in = s_xinput_drv.ep_in; return tud_mounted() && ep_in != 0 && !usbd_edpt_busy(0, ep_in); } From 1811890d855c77f4b69d06f58394a4ad7d7f3196 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 10:54:49 -0500 Subject: [PATCH 05/25] =?UTF-8?q?fix(usb=5Fdevice):=20X-Input=20review=20?= =?UTF-8?q?=E2=80=94=20tinyusb=20floor,=20atomic=20ep=20state,=20test=20-W?= =?UTF-8?q?comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The X-Input class driver uses the usbd_class_driver_t `xfer_isr` member and the 5-arg usbd_edpt_xfer(..., is_isr) API, both TinyUSB >= 0.19. esp_tinyusb only requires tinyusb >= 0.17, so a managed build could resolve 0.17/0.18 and fail to compile. Pin `tinyusb: '>=0.19.0'` in the component manifest. (CI is unaffected: the manager-off build uses the vendored tinyusb 0.21.) - s_xinput_drv.ep_in/ep_out are written by open()/reset() on the TinyUSB task and read by update_gamepad()/is_xinput_ready() on the caller's task — a data race on plain uint8_t. Make them std::atomic. - The host-test file documented its build command in a `//` comment with a trailing backslash, which trips -Wcomment under the documented -Werror. Move the command into a block comment. Verified: example builds clean on ESP-IDF v6.1 (esp32s3); the host test compiles under `c++ -std=c++20 -Wall -Wextra -Werror ...` as documented and passes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/idf_component.yml | 6 ++++++ components/usb_device/src/usb_device.cpp | 11 +++++++---- components/usb_device/test/xinput_host_test.cpp | 8 +++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/components/usb_device/idf_component.yml b/components/usb_device/idf_component.yml index dce7685309..1313ab4ee8 100644 --- a/components/usb_device/idf_component.yml +++ b/components/usb_device/idf_component.yml @@ -23,3 +23,9 @@ dependencies: 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 diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index e09a89cf98..110c210975 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -79,9 +79,12 @@ bool on_tinyusb_task() { // driver is always registered but open() only claims an X-Input interface, so it // is inert when no XInput function is enabled. struct XInputDriver { - uint8_t itf_num{0xFF}; - uint8_t ep_in{0}; - uint8_t ep_out{0}; + uint8_t itf_num{0xFF}; // touched only on the TinyUSB task (open/reset/log) + // ep_in / ep_out are written by open()/reset() on the TinyUSB task and read by + // update_gamepad()/is_xinput_ready() on the caller's task, so they are atomic + // to avoid a data race on disconnect/reset concurrent with reporting. + std::atomic ep_in{0}; + std::atomic ep_out{0}; std::array out_buf{}; // interrupt-OUT receive buffer (>= kEpSize) }; XInputDriver s_xinput_drv; @@ -141,7 +144,7 @@ uint16_t xinput_drv_open(uint8_t rhport, tusb_desc_interface_t const *desc_itf, // usbd_app_driver_get_cb weak override did not take effect) and no reports can // flow even though Windows shows the device by VID/PID. ESP_LOGI("espp_xinput", "class driver open: itf=%u ep_in=0x%02x ep_out=0x%02x", - s_xinput_drv.itf_num, s_xinput_drv.ep_in, s_xinput_drv.ep_out); + s_xinput_drv.itf_num, s_xinput_drv.ep_in.load(), s_xinput_drv.ep_out.load()); if (s_xinput_drv.ep_in == 0) ESP_LOGW("espp_xinput", "no interrupt IN endpoint opened -- host will get no input reports"); diff --git a/components/usb_device/test/xinput_host_test.cpp b/components/usb_device/test/xinput_host_test.cpp index c0c5c03939..2e711c6dca 100644 --- a/components/usb_device/test/xinput_host_test.cpp +++ b/components/usb_device/test/xinput_host_test.cpp @@ -1,8 +1,10 @@ // Host-buildable unit tests for the espp X-Input (Xbox 360) helpers in // include/xinput.hpp: the 20-byte input-report packing and the interface + XID + -// endpoint descriptor builder. No ESP-IDF / TinyUSB headers required. Build&run: -// c++ -std=c++20 -Wall -Wextra -Werror -I components/usb_device/include \ -// components/usb_device/test/xinput_host_test.cpp -o test && ./test +// endpoint descriptor builder. No ESP-IDF / TinyUSB headers required. +// +/* Build & run (a block comment so the line-continuation doesn't trip -Wcomment): + c++ -std=c++20 -Wall -Wextra -Werror -I components/usb_device/include \ + components/usb_device/test/xinput_host_test.cpp -o test && ./test */ #include From 9e6ec3c70388bf461297dda067352ad22669c3a9 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 12:38:03 -0500 Subject: [PATCH 06/25] debug(usb_device): log + respond to X-Input control requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interrupt-IN report path works (host polls and reads our reports — the "reports flowing" heartbeat keeps firing, which requires each prior transfer to complete), but Windows shows no input. The remaining suspect is XUSB's init control-request handshake, which we were stalling. - Log every control request to the X-Input interface (and any that reach the global vendor path) on the USB-Serial-JTAG console, so the XUSB handshake is observable and we can see exactly what it asks for and where it lands. - Respond to the interface's VENDOR control requests instead of stalling (zero-filled IN response of the requested length; ACK for OUT/no-data), a best-effort "don't stall the handshake" while we learn the real requests. Investigative; builds clean on ESP-IDF v6.1 (esp32s3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/include/usb_device.hpp | 3 ++ components/usb_device/src/usb_device.cpp | 37 ++++++++++++++++---- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/components/usb_device/include/usb_device.hpp b/components/usb_device/include/usb_device.hpp index a1063b4c2d..a1d4ccb211 100644 --- a/components/usb_device/include/usb_device.hpp +++ b/components/usb_device/include/usb_device.hpp @@ -402,6 +402,9 @@ class UsbDevice : public BaseComponent { /// @brief Internal: config for the vendor control-request handler. const std::optional &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). diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index 110c210975..3881546884 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -153,12 +153,29 @@ uint16_t xinput_drv_open(uint8_t rhport, tusb_desc_interface_t const *desc_itf, } bool xinput_drv_control_xfer(uint8_t rhport, uint8_t stage, tusb_control_request_t const *request) { - (void)rhport; - (void)stage; - (void)request; - // X-Input issues a few vendor/class control requests during init; none are - // needed for the interrupt data path, so leave them to be stalled (return - // false = "not handled by this driver"). Windows' XUSB driver tolerates this. + if (stage != CONTROL_STAGE_SETUP) + return true; // DATA / ACK stages: nothing to do + + // Log every control request the host sends to the X-Input interface so the + // XUSB init handshake is observable on the USB-Serial-JTAG console. + ESP_LOGI("espp_xinput", "control SETUP bmReq=0x%02x bReq=0x%02x wVal=0x%04x wIdx=0x%04x wLen=%u", + request->bmRequestType, request->bRequest, request->wValue, request->wIndex, + request->wLength); + + // XUSB sends a few VENDOR-type control requests during init. Respond to them + // (instead of stalling) so the driver proceeds to the interrupt-IN report + // stream: an IN request gets a zero-filled buffer of the requested length; an + // OUT / no-data request is ACKed. We do not implement the real semantics -- + // this is a best-effort "don't stall the handshake". Standard / class requests + // are left to TinyUSB's default handling (return false). + if (request->bmRequestType_bit.type == TUSB_REQ_TYPE_VENDOR) { + if (request->bmRequestType_bit.direction == TUSB_DIR_IN) { + static uint8_t resp[64] = {0}; + uint16_t len = request->wLength <= sizeof(resp) ? request->wLength : sizeof(resp); + return tud_control_xfer(rhport, request, resp, len); + } + return tud_control_status(rhport, request); + } return false; } @@ -310,6 +327,14 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, if (stage != CONTROL_STAGE_SETUP) return true; // nothing to do on DATA / ACK stages auto *dev = s_device.load(); + // Diagnostic: surface any vendor control request that reaches the *global* + // vendor path (e.g. device-recipient) rather than the per-interface X-Input + // handler, so we can tell where XUSB's init requests actually land. + if (dev && dev->xinput_active()) + ESP_LOGI("espp_xinput", + "global vendor control bmReq=0x%02x bReq=0x%02x wVal=0x%04x wIdx=0x%04x wLen=%u", + request->bmRequestType, request->bRequest, request->wValue, request->wIndex, + request->wLength); if (!dev || !dev->vendor_config().has_value()) return false; const auto &vendor = *dev->vendor_config(); From 4601df0a0aa4ae1e072f63f81c49fa58d767372b Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 12:42:56 -0500 Subject: [PATCH 07/25] debug(usb_device): log raw X-Input OUT (rumble/LED) reports Steam can send a rumble command but nothing appears on the console. Rumble is a host->device report on the interrupt-OUT endpoint, a separate path from the input reports we can see the host reading. Log every received OUT report RAW and unconditionally in handle_xinput_out(), so we can tell whether the OUT path receives anything at all, independent of how the app callback filters it (the example only logs data[0]==0x00 rumble / 0x01 LED reports). Investigative; builds clean on ESP-IDF v6.1 (esp32s3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/src/usb_device.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index 3881546884..966a355dc4 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include "esp_log.h" @@ -1494,6 +1495,16 @@ bool UsbDevice::is_hid_ready() const { uint8_t UsbDevice::xinput_in_endpoint() const { return impl_->xinput_ep_in; } void UsbDevice::handle_xinput_out(const uint8_t *buffer, size_t bufsize) { + // Diagnostic: log every OUT (rumble/LED) report the host sends, RAW and + // unconditionally, so we can tell whether the interrupt-OUT path receives + // anything at all (independent of how the app callback filters it). + if (buffer && bufsize > 0) { + char hex[3 * 16 + 1] = {0}; + const size_t n = bufsize < 16 ? bufsize : 16; + for (size_t i = 0; i < n; i++) + snprintf(hex + i * 3, 4, "%02x ", buffer[i]); + ESP_LOGI("espp_xinput", "OUT report (%u bytes): %s", static_cast(bufsize), hex); + } receive_callback_fn cb; { std::scoped_lock lk(cb_mutex_); From 13453901a83975d415902dc89b4ab71175ad1045 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 12:49:50 -0500 Subject: [PATCH 08/25] fix(usb_device): restore correct X-Input IN endpoint (regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-device the report send started failing with "usbd_edpt_xfer failed on ep 0x03" — update_gamepad() was reading the wrong endpoint. A previous change had switched it to read s_xinput_drv.ep_in (written by the class driver's open() on the TinyUSB task); despite open() logging ep_in=0x81, the cross-task read produced a bad value (0x03), breaking the data path that previously worked ("reports flowing on 0x81"). Use impl_->xinput_ep_in instead — the address fixed at initialize() and immutable afterwards, so it is always the correct 0x81 and needs no cross-task synchronization. tud_mounted() already implies the class driver opened this (only) interface's endpoints, so readiness is still accurate. This also resolves the data-race review comment: no app-task code reads the TinyUSB-task-owned s_xinput_drv fields anymore, so they revert to plain uint8_t. Builds clean on ESP-IDF v6.1 (esp32s3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/src/usb_device.cpp | 31 ++++++++++++------------ 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index 966a355dc4..f0abfcd232 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -80,12 +80,13 @@ bool on_tinyusb_task() { // driver is always registered but open() only claims an X-Input interface, so it // is inert when no XInput function is enabled. struct XInputDriver { - uint8_t itf_num{0xFF}; // touched only on the TinyUSB task (open/reset/log) - // ep_in / ep_out are written by open()/reset() on the TinyUSB task and read by - // update_gamepad()/is_xinput_ready() on the caller's task, so they are atomic - // to avoid a data race on disconnect/reset concurrent with reporting. - std::atomic ep_in{0}; - std::atomic ep_out{0}; + // All fields are touched only on the TinyUSB task (open/reset/xfer_cb/log). The + // app-facing update_gamepad()/is_xinput_ready() use UsbDevice's own + // impl_->xinput_ep_in (fixed at initialize(), immutable afterwards) instead of + // reading these, so there is no cross-task access here to synchronize. + uint8_t itf_num{0xFF}; + uint8_t ep_in{0}; + uint8_t ep_out{0}; std::array out_buf{}; // interrupt-OUT receive buffer (>= kEpSize) }; XInputDriver s_xinput_drv; @@ -145,7 +146,7 @@ uint16_t xinput_drv_open(uint8_t rhport, tusb_desc_interface_t const *desc_itf, // usbd_app_driver_get_cb weak override did not take effect) and no reports can // flow even though Windows shows the device by VID/PID. ESP_LOGI("espp_xinput", "class driver open: itf=%u ep_in=0x%02x ep_out=0x%02x", - s_xinput_drv.itf_num, s_xinput_drv.ep_in.load(), s_xinput_drv.ep_out.load()); + s_xinput_drv.itf_num, s_xinput_drv.ep_in, s_xinput_drv.ep_out); if (s_xinput_drv.ep_in == 0) ESP_LOGW("espp_xinput", "no interrupt IN endpoint opened -- host will get no input reports"); @@ -1520,11 +1521,11 @@ bool UsbDevice::update_gamepad(const espp::xinput::GamepadState &state, std::err ec = std::make_error_code(std::errc::not_connected); return false; } - // Use the endpoint the class driver actually OPENED (set in xinput_drv_open, - // cleared on bus reset), not the planned address from allocation. So if open() - // never ran (e.g. the app driver failed to register) this correctly reports - // not-ready instead of pretending the endpoint exists. - const uint8_t ep_in = s_xinput_drv.ep_in; + // The interrupt-IN endpoint address, fixed at initialize() and immutable after + // (so no cross-task synchronization is needed). tud_mounted() gates on the host + // having SET_CONFIGURATION, which is exactly when the class driver's open() runs + // for this (only) interface — so a mounted device has its endpoint open. + const uint8_t ep_in = impl_->xinput_ep_in; if (!tud_mounted() || ep_in == 0) { logger_.warn_rate_limited("XInput not ready to send: mounted={} ep_in=0x{:02x}", tud_mounted(), ep_in); @@ -1562,9 +1563,9 @@ bool UsbDevice::update_gamepad(const espp::xinput::GamepadState &state) { bool UsbDevice::is_xinput_ready() const { if (!initialized_ || !config_.xinput) return false; - // The endpoint the class driver actually opened (0 until open() runs), so this - // never reports ready before the interface is truly configured. - const uint8_t ep_in = s_xinput_drv.ep_in; + // Fixed at initialize(), immutable after; tud_mounted() implies the class + // driver has opened this interface's endpoints (it is the only function). + const uint8_t ep_in = impl_->xinput_ep_in; return tud_mounted() && ep_in != 0 && !usbd_edpt_busy(0, ep_in); } From c1436da3d21c6ce25993c307eb49fedb834cfd85 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 13:02:44 -0500 Subject: [PATCH 09/25] fix(usb_device): match retail Xbox 360 XID descriptor byte Set the XID "unknown" vendor descriptor's byte [2] to 0x10 (from 0x00), matching a real wired Xbox 360 controller byte-for-byte, in case xusb22 keys off it. Low-risk descriptor-fidelity change; the host test still passes (it does not pin that byte). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/include/xinput.hpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/components/usb_device/include/xinput.hpp b/components/usb_device/include/xinput.hpp index 289204d4f2..88104a7f52 100644 --- a/components/usb_device/include/xinput.hpp +++ b/components/usb_device/include/xinput.hpp @@ -126,9 +126,11 @@ inline std::vector interface_descriptor(uint8_t itf_num, uint8_t str_id // 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). [6] = IN endpoint address, - // [7] = IN report size (0x14 = 20), [14] = OUT report size (0x08 = 8). - 0x11, 0x21, 0x00, 0x01, 0x01, 0x25, + // XID "unknown" vendor descriptor (17 bytes), matched byte-for-byte to a + // real wired Xbox 360 controller (bLength 0x11, bDescriptorType 0x21). [2] + // is 0x10 on the retail controller; [6] = IN endpoint address, [7] = IN + // report size (0x14 = 20), [14] = OUT report size (0x08 = 8). + 0x11, 0x21, 0x10, 0x01, 0x01, 0x25, ep_in, 0x14, 0x00, 0x00, 0x00, 0x00, 0x13, 0x01, 0x08, 0x00, 0x00, // Endpoint IN (7 bytes): interrupt, wMaxPacketSize 32, bInterval. 0x07, 0x05 /* ENDPOINT */, ep_in, 0x03 /* interrupt */, kEpSize, 0x00, in_interval, From 3a15bd210fae3514b92b1982d16e0155af6b0dcf Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 14:05:51 -0500 Subject: [PATCH 10/25] debug(usb_device): log outgoing X-Input report bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A USB capture shows every interrupt-IN report arriving on the wire with a spurious leading 0x01 (our correct "00 14 .." report shifted right by one, last byte truncated), which makes XUSB reject them (byte[0] != 0x00) — explaining "reads reports, shows no input". report() provably writes 0x00 at [0] and we xfer exactly 20 bytes, so log the bytes we hand to the stack to confirm the 0x01 is injected below us (esp_tinyusb / DWC2) rather than in our code. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/src/usb_device.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index f0abfcd232..46f831f60d 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -1544,6 +1544,13 @@ bool UsbDevice::update_gamepad(const espp::xinput::GamepadState &state, std::err } // The buffer must outlive the (asynchronous) transfer, so it lives in Impl. impl_->xinput_report = state.report(); + // Debug: log the exact bytes we hand to the stack. A USB capture shows the wire + // reports arriving with a spurious leading 0x01 (00 14 .. shifted by one); this + // confirms whether that byte originates here or below us in esp_tinyusb/DWC2. + logger_.info_rate_limited( + "XInput TX[{}]: {:02x} {:02x} {:02x} {:02x} {:02x} {:02x}", impl_->xinput_report.size(), + impl_->xinput_report[0], impl_->xinput_report[1], impl_->xinput_report[2], + impl_->xinput_report[3], impl_->xinput_report[4], impl_->xinput_report[5]); if (!usbd_edpt_xfer(0, ep_in, impl_->xinput_report.data(), static_cast(impl_->xinput_report.size()), false)) { usbd_edpt_release(0, ep_in); // undo the claim so the endpoint isn't wedged From ed055881bfe8ffb577cee11656ea4a6e290b6dc6 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 14:10:28 -0500 Subject: [PATCH 11/25] fix(usb_device): give X-Input IN/OUT separate endpoint numbers (DWC2 corruption) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A USB capture proved the cause of "reads reports but shows no input": every interrupt-IN report reached the host with a spurious leading 0x01 (our correct "00 14 .." report shifted right by one), so XUSB rejected them (byte[0] != 0x00). A TX log confirmed our code hands "00 14 .." to the stack, so the 0x01 is injected below us — the ESP32-S3 DWC2 corrupting the IN stream because IN (0x81) and OUT (0x01) shared endpoint NUMBER 1. Allocate a separate OUT endpoint number (IN 0x81, OUT 0x02) so the two directions no longer share DWC2 endpoint state. interface_descriptor() now takes explicit IN and OUT endpoint addresses and patches both into the XID blob ([6]=IN, [13]=OUT). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/include/xinput.hpp | 19 ++++++++++--------- components/usb_device/src/usb_device.cpp | 15 ++++++++++----- .../usb_device/test/xinput_host_test.cpp | 11 ++++++----- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/components/usb_device/include/xinput.hpp b/components/usb_device/include/xinput.hpp index 88104a7f52..f6e7b80f23 100644 --- a/components/usb_device/include/xinput.hpp +++ b/components/usb_device/include/xinput.hpp @@ -113,14 +113,14 @@ struct GamepadState { }; /// Build the interface + XID + two interrupt-endpoint descriptor bytes for an -/// X-Input interface. @p ep_num is the endpoint NUMBER n; the IN endpoint is -/// 0x80|n and the OUT endpoint is n (the XID blob embeds the IN endpoint address -/// and the report sizes, so it is patched to match @p ep_num). -inline std::vector interface_descriptor(uint8_t itf_num, uint8_t str_idx, uint8_t ep_num, - uint8_t in_interval = kInInterval, +/// 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 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) { - const uint8_t ep_in = static_cast(0x80 | ep_num); - const uint8_t ep_out = ep_num; return { // clang-format off // Interface descriptor (9 bytes): vendor-specific 0xFF/0x5D/0x01, 2 endpoints. @@ -129,9 +129,10 @@ inline std::vector interface_descriptor(uint8_t itf_num, uint8_t str_id // XID "unknown" vendor descriptor (17 bytes), matched byte-for-byte to a // real wired Xbox 360 controller (bLength 0x11, bDescriptorType 0x21). [2] // is 0x10 on the retail controller; [6] = IN endpoint address, [7] = IN - // report size (0x14 = 20), [14] = OUT report size (0x08 = 8). + // report size (0x14 = 20), [13] = OUT endpoint address, [14] = OUT report + // size (0x08 = 8). 0x11, 0x21, 0x10, 0x01, 0x01, 0x25, - ep_in, 0x14, 0x00, 0x00, 0x00, 0x00, 0x13, 0x01, 0x08, 0x00, 0x00, + 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. diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index 46f831f60d..2afd238b96 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -661,14 +661,18 @@ bool UsbDevice::initialize(std::error_code &ec) { impl_->hid_report_desc = config_.hid->report_descriptor; } - uint8_t xinput_itf = 0, xinput_str = 0, xinput_ep = 0; + uint8_t xinput_itf = 0, xinput_str = 0; if (config_.xinput) { xinput_itf = next_itf++; xinput_str = next_str++; impl_->owned_strings.push_back(config_.xinput->interface_name); - xinput_ep = next_ep++; // IN = 0x80|n, OUT = n - impl_->xinput_ep_in = static_cast(0x80 | xinput_ep); // interrupt IN - impl_->xinput_ep_out = xinput_ep; // interrupt OUT + // Use SEPARATE endpoint numbers for IN and OUT. The retail controller shares + // number 1, but the ESP32-S3 DWC2 corrupts the interrupt-IN stream (a leading + // 0x01 byte) when the same number is used for both directions. + const uint8_t in_ep = next_ep++; + const uint8_t out_ep = next_ep++; + impl_->xinput_ep_in = static_cast(0x80 | in_ep); // interrupt IN + impl_->xinput_ep_out = out_ep; // interrupt OUT in_used++; out_used++; impl_->xinput_itf = xinput_itf; @@ -811,7 +815,8 @@ bool UsbDevice::initialize(std::error_code &ec) { // bIntervals are the full-speed values; on an HS-capable part they are // interpreted as exponents, but X-Input is a full-speed protocol (and the // ESP32-S3 USB-OTG is full speed). - const auto d = espp::xinput::interface_descriptor(xinput_itf, xinput_str, xinput_ep); + const auto d = espp::xinput::interface_descriptor(xinput_itf, xinput_str, impl_->xinput_ep_in, + impl_->xinput_ep_out); append(d.data(), d.size()); } }; diff --git a/components/usb_device/test/xinput_host_test.cpp b/components/usb_device/test/xinput_host_test.cpp index 2e711c6dca..f2716c9bbd 100644 --- a/components/usb_device/test/xinput_host_test.cpp +++ b/components/usb_device/test/xinput_host_test.cpp @@ -52,19 +52,20 @@ int main() { s.set(Button::A, false); CHECK(s.report()[3] == 0x00); - // --- descriptor builder (itf 3, string 5, endpoint number 2 -> IN 0x82, OUT 0x02) --- - const auto d = interface_descriptor(3, 5, 2); + // --- descriptor builder (itf 3, string 5, IN 0x82, OUT 0x03) --- + const auto d = interface_descriptor(3, 5, 0x82, 0x03); CHECK(d.size() == kInterfaceDescriptorLen && d.size() == 40); // interface descriptor CHECK(d[0] == 0x09 && d[1] == 0x04 && d[2] == 3 && d[4] == 0x02); CHECK(d[5] == kInterfaceClass && d[6] == kInterfaceSubClass && d[7] == kInterfaceProtocol); CHECK(d[8] == 5); - // XID blob, with the IN endpoint address patched in - CHECK(d[9] == 0x11 && d[10] == 0x21 && d[15] == 0x82 && d[16] == 0x14 && d[23] == 0x08); + // XID blob, with the IN (d[15]) and OUT (d[22]) endpoint addresses patched in + CHECK(d[9] == 0x11 && d[10] == 0x21 && d[15] == 0x82 && d[16] == 0x14); + CHECK(d[22] == 0x03 && d[23] == 0x08); // IN endpoint (interrupt, size 32) CHECK(d[26] == 0x07 && d[27] == 0x05 && d[28] == 0x82 && d[29] == 0x03 && d[30] == kEpSize); // OUT endpoint - CHECK(d[33] == 0x07 && d[34] == 0x05 && d[35] == 0x02 && d[36] == 0x03 && d[37] == kEpSize); + CHECK(d[33] == 0x07 && d[34] == 0x05 && d[35] == 0x03 && d[36] == 0x03 && d[37] == kEpSize); if (g_failures == 0) std::printf("all xinput host tests passed\n"); From e553b3244e8337d244b80fdc336a7fbeec8b64b2 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 18:39:52 -0500 Subject: [PATCH 12/25] fix(usb_device): don't answer X-Input GET_CAPABILITIES with zeros MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After separating the IN/OUT endpoints the controller enumerated and joy.cpl showed it, but no input registered anywhere. Cause: a debug change had made the class driver's control handler answer XUSB's vendor control requests — including GET_CAPABILITIES (bmReq 0xC1, bReq 0x01, wValue 0x0100, wLen 20) — with a zero-filled buffer. A zero capabilities report tells XUSB the controller has no controls, so it reads our input reports and ignores them. A real wired 360 controller and the known-working esp32s3 references leave these vendor requests unanswered and XUSB falls back to full default capabilities. Stop synthesizing responses; return true (handled, no data) to match. Keep the request logging for now. Builds clean on ESP-IDF v6.1 (esp32s3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/src/usb_device.cpp | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index 2afd238b96..358bc59c39 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -164,21 +164,14 @@ bool xinput_drv_control_xfer(uint8_t rhport, uint8_t stage, tusb_control_request request->bmRequestType, request->bRequest, request->wValue, request->wIndex, request->wLength); - // XUSB sends a few VENDOR-type control requests during init. Respond to them - // (instead of stalling) so the driver proceeds to the interrupt-IN report - // stream: an IN request gets a zero-filled buffer of the requested length; an - // OUT / no-data request is ACKed. We do not implement the real semantics -- - // this is a best-effort "don't stall the handshake". Standard / class requests - // are left to TinyUSB's default handling (return false). - if (request->bmRequestType_bit.type == TUSB_REQ_TYPE_VENDOR) { - if (request->bmRequestType_bit.direction == TUSB_DIR_IN) { - static uint8_t resp[64] = {0}; - uint16_t len = request->wLength <= sizeof(resp) ? request->wLength : sizeof(resp); - return tud_control_xfer(rhport, request, resp, len); - } - return tud_control_status(rhport, request); - } - return false; + // Do NOT synthesize responses to XUSB's vendor control requests. In particular + // GET_CAPABILITIES (bmReq 0xC1, bReq 0x01, wValue 0x0100) expects a real 20-byte + // capabilities report; answering it with zeros tells XUSB the controller has no + // controls, so it ignores all input. A real wired 360 controller and the + // known-working esp32s3 references simply leave these requests unanswered and + // XUSB falls back to full default capabilities. Return true to consider them + // handled (no data/stall), matching that behavior. + return true; } bool xinput_drv_xfer_cb(uint8_t rhport, uint8_t ep_addr, xfer_result_t result, From bc705ad21a2035aeb91d405d34fff822904bbd85 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 18:52:55 -0500 Subject: [PATCH 13/25] =?UTF-8?q?fix(usb=5Fdevice):=20don't=20post=20OUT?= =?UTF-8?q?=20reads=20=E2=80=94=20they=20corrupt=20the=20X-Input=20IN=20st?= =?UTF-8?q?ream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two USB captures proved the IN reports still arrive corrupted after separating the endpoints: every interrupt-IN report reaches the wire with the OUT endpoint NUMBER prepended (0x01 when OUT was ep1, 0x02 when OUT was ep2), shifting our correct "00 14 .." report one byte right — so XUSB rejects them (byte[0]!=0x00) and no input registers, even though the controller enumerates and joy.cpl shows it. The corruption byte tracking the OUT endpoint number, plus the known-working esp32s3-tinyusb-xinput reference which opens the OUT endpoint but never posts a read on it, point to the OUT read as the trigger on the ESP32-S3 DWC2. Stop priming the interrupt-OUT endpoint (in open() and xfer_cb). The endpoint stays declared and opened (a real 360 controller has one), we just never drive it. Consequence: host->device rumble/LED reports are no longer consumed. That is the same tradeoff the reference makes; getting input working is the priority, and a rumble path can be revisited once the DWC2 behavior is understood. Builds clean on ESP-IDF v6.1 (esp32s3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/src/usb_device.cpp | 30 ++++++++++++------------ 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index 358bc59c39..b425f81cdd 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -136,10 +136,14 @@ uint16_t xinput_drv_open(uint8_t rhport, tusb_desc_interface_t const *desc_itf, p = tu_desc_next(p); } - // Prime the interrupt-OUT endpoint to receive the first rumble / LED report. - if (s_xinput_drv.ep_out) - usbd_edpt_xfer(rhport, s_xinput_drv.ep_out, s_xinput_drv.out_buf.data(), espp::xinput::kEpSize, - false); + // NOTE: We deliberately do NOT post a read on the interrupt-OUT endpoint. On + // the ESP32-S3 DWC2, posting an OUT read corrupts the interrupt-IN stream — a + // USB capture showed every IN report arriving with the OUT endpoint NUMBER + // prepended (shifting the report by one byte), so XUSB rejects them and no + // input registers. The endpoint is still declared/opened (a real 360 controller + // has an OUT endpoint), we just never drive it — matching the known-working + // esp32s3-tinyusb-xinput reference. Consequence: host->device rumble/LED + // reports are not consumed (see handle_xinput_out). // Diagnostic (USB-Serial-JTAG console): if this line does NOT appear when the // host enumerates the device, the app class driver was not registered (the @@ -176,18 +180,14 @@ bool xinput_drv_control_xfer(uint8_t rhport, uint8_t stage, tusb_control_request bool xinput_drv_xfer_cb(uint8_t rhport, uint8_t ep_addr, xfer_result_t result, uint32_t xferred_bytes) { + (void)rhport; + (void)ep_addr; + (void)result; + (void)xferred_bytes; note_tinyusb_task(); - if (ep_addr == s_xinput_drv.ep_out) { - if (result == XFER_RESULT_SUCCESS && xferred_bytes > 0) { - auto *dev = s_device.load(); - if (dev) - dev->handle_xinput_out(s_xinput_drv.out_buf.data(), static_cast(xferred_bytes)); - } - // Re-prime the OUT endpoint for the next report. - usbd_edpt_xfer(rhport, s_xinput_drv.ep_out, s_xinput_drv.out_buf.data(), espp::xinput::kEpSize, - false); - } - // IN completion needs no action; usbd_edpt_busy() reflects readiness. + // We never post OUT reads (see xinput_drv_open — priming OUT corrupts the IN + // stream on the ESP32-S3 DWC2), so there is nothing to re-prime here. IN + // completion needs no action; usbd_edpt_busy() reflects readiness. return true; } From 6bb210f5c0c65f25c6c996cd01c5daaeeaefe5f3 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 19:03:48 -0500 Subject: [PATCH 14/25] test(usb_device): don't open the X-Input OUT endpoint (isolate IN corruption) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three captures now show the interrupt-IN reports still arrive with the OUT endpoint NUMBER prepended (0x02), even after moving the OUT endpoint to a separate number and after we stopped posting OUT reads. The descriptor is verified correct (IN 0x81/32/int, OUT 0x02/32/int, class ff/5d/01) and our report bytes are correct (00 14 ..), so the byte is injected in the DWC2/esp_tinyusb IN data path, and it tracks the OUT endpoint number. Test whether an *active* interrupt-OUT endpoint is the trigger: keep the OUT endpoint in the descriptor (XUSB still sees a normal 2-endpoint 360 controller) but do NOT usbd_edpt_open() it, so the DWC2 never configures an OUT interrupt endpoint. If the IN prefix disappears, the dual active interrupt endpoints are the cause and we ship input-only (no rumble) — the reference is effectively that too. Builds clean on ESP-IDF v6.1 (esp32s3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/src/usb_device.cpp | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index b425f81cdd..b9bd3cd72b 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -126,12 +126,21 @@ uint16_t xinput_drv_open(uint8_t rhport, tusb_desc_interface_t const *desc_itf, break; if (type == TUSB_DESC_ENDPOINT) { const tusb_desc_endpoint_t *ep = reinterpret_cast(p); - if (!usbd_edpt_open(rhport, ep)) - return 0; - if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN) + if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN) { + if (!usbd_edpt_open(rhport, ep)) + return 0; s_xinput_drv.ep_in = ep->bEndpointAddress; - else + } else { + // EXPERIMENT: record the OUT endpoint address (it stays in the descriptor + // so XUSB sees a normal 2-endpoint 360 controller) but do NOT open it in + // the DWC2. Two captures showed every interrupt-IN report arriving with + // the OUT endpoint NUMBER prepended (0x01, then 0x02 after we moved it), + // shifting the report and making XUSB reject it. The corruption tracks the + // OUT endpoint number and persisted even after we stopped posting OUT + // reads, so an *active* interrupt-OUT endpoint in the DWC2 appears to be + // the trigger. Leaving it unopened tests that (at the cost of no rumble). s_xinput_drv.ep_out = ep->bEndpointAddress; + } } p = tu_desc_next(p); } From 96c0b892532210ef7680bdbf12b975d04f6017a9 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 20:09:07 -0500 Subject: [PATCH 15/25] fix(usb_device): 4-byte align X-Input DMA buffers (the real IN-corruption cause) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the corrupted interrupt-IN reports, found from the captures: the prefix byte was never the OUT endpoint "leaking" — it was the byte in memory immediately BEFORE the report buffer. In Impl, xinput_report followed the three uint8_t fields (…, xinput_ep_out) with array alignment 1, so it landed at a misaligned address. The ESP32-S3 DWC2 reads the TX buffer by DMA and, for a misaligned buffer, reads from the aligned-down address — prepending the preceding byte (xinput_ep_out: 0x02, or 0x01 back when IN/OUT shared endpoint 1) to every report and dropping the last byte. XUSB then saw byte[0] != 0x00 and rejected all input, even though enumeration and joy.cpl worked and our TX log showed correct bytes. Fix: alignas(4) on xinput_report (and the OUT out_buf). With the buffer word aligned the DMA reads from the correct address and reports go out intact. This makes the earlier endpoint experiments unnecessary, so revert them: IN and OUT keep their separate numbers (harmless), the OUT endpoint is opened again, and OUT priming is restored so host->device rumble/LED reports are received. Builds clean on ESP-IDF v6.1 (esp32s3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/src/usb_device.cpp | 58 +++++++++++------------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index b9bd3cd72b..d429e3c50c 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -87,7 +87,9 @@ struct XInputDriver { uint8_t itf_num{0xFF}; uint8_t ep_in{0}; uint8_t ep_out{0}; - std::array out_buf{}; // interrupt-OUT receive buffer (>= kEpSize) + // 4-byte aligned: the DWC2 also reads/writes endpoint buffers by DMA (see the + // note on Impl::xinput_report), so keep this on a word boundary too. + alignas(4) std::array out_buf{}; // interrupt-OUT receive buffer (>= kEpSize) }; XInputDriver s_xinput_drv; @@ -126,33 +128,20 @@ uint16_t xinput_drv_open(uint8_t rhport, tusb_desc_interface_t const *desc_itf, break; if (type == TUSB_DESC_ENDPOINT) { const tusb_desc_endpoint_t *ep = reinterpret_cast(p); - if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN) { - if (!usbd_edpt_open(rhport, ep)) - return 0; + if (!usbd_edpt_open(rhport, ep)) + return 0; + if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN) s_xinput_drv.ep_in = ep->bEndpointAddress; - } else { - // EXPERIMENT: record the OUT endpoint address (it stays in the descriptor - // so XUSB sees a normal 2-endpoint 360 controller) but do NOT open it in - // the DWC2. Two captures showed every interrupt-IN report arriving with - // the OUT endpoint NUMBER prepended (0x01, then 0x02 after we moved it), - // shifting the report and making XUSB reject it. The corruption tracks the - // OUT endpoint number and persisted even after we stopped posting OUT - // reads, so an *active* interrupt-OUT endpoint in the DWC2 appears to be - // the trigger. Leaving it unopened tests that (at the cost of no rumble). + else s_xinput_drv.ep_out = ep->bEndpointAddress; - } } p = tu_desc_next(p); } - // NOTE: We deliberately do NOT post a read on the interrupt-OUT endpoint. On - // the ESP32-S3 DWC2, posting an OUT read corrupts the interrupt-IN stream — a - // USB capture showed every IN report arriving with the OUT endpoint NUMBER - // prepended (shifting the report by one byte), so XUSB rejects them and no - // input registers. The endpoint is still declared/opened (a real 360 controller - // has an OUT endpoint), we just never drive it — matching the known-working - // esp32s3-tinyusb-xinput reference. Consequence: host->device rumble/LED - // reports are not consumed (see handle_xinput_out). + // Prime the interrupt-OUT endpoint to receive the first rumble / LED report. + if (s_xinput_drv.ep_out) + usbd_edpt_xfer(rhport, s_xinput_drv.ep_out, s_xinput_drv.out_buf.data(), espp::xinput::kEpSize, + false); // Diagnostic (USB-Serial-JTAG console): if this line does NOT appear when the // host enumerates the device, the app class driver was not registered (the @@ -189,14 +178,18 @@ bool xinput_drv_control_xfer(uint8_t rhport, uint8_t stage, tusb_control_request bool xinput_drv_xfer_cb(uint8_t rhport, uint8_t ep_addr, xfer_result_t result, uint32_t xferred_bytes) { - (void)rhport; - (void)ep_addr; - (void)result; - (void)xferred_bytes; note_tinyusb_task(); - // We never post OUT reads (see xinput_drv_open — priming OUT corrupts the IN - // stream on the ESP32-S3 DWC2), so there is nothing to re-prime here. IN - // completion needs no action; usbd_edpt_busy() reflects readiness. + if (ep_addr == s_xinput_drv.ep_out) { + if (result == XFER_RESULT_SUCCESS && xferred_bytes > 0) { + auto *dev = s_device.load(); + if (dev) + dev->handle_xinput_out(s_xinput_drv.out_buf.data(), static_cast(xferred_bytes)); + } + // Re-prime the OUT endpoint for the next report. + usbd_edpt_xfer(rhport, s_xinput_drv.ep_out, s_xinput_drv.out_buf.data(), espp::xinput::kEpSize, + false); + } + // IN completion needs no action; usbd_edpt_busy() reflects readiness. return true; } @@ -250,8 +243,11 @@ struct UsbDevice::Impl { uint8_t xinput_ep_in{0}; // 0x80|n, or 0 if the XInput function is disabled uint8_t xinput_ep_out{0}; // n, or 0 if disabled // Input-report TX buffer; held for the duration of the async interrupt-IN - // transfer submitted by update_gamepad(). - std::array xinput_report{}; + // transfer submitted by update_gamepad(). MUST be 4-byte aligned: the ESP32-S3 + // DWC2 reads it by DMA and a misaligned buffer makes the controller read from + // the aligned-down address, prepending the preceding byte to every report + // (which shifted our "00 14 .." report by one and made XUSB reject all input). + alignas(4) std::array xinput_report{}; }; UsbDevice *UsbDevice::instance() { return s_device; } From d52c1bb57cc73320db6a5cc2dc7d2668341e4abb Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 22:17:06 -0500 Subject: [PATCH 16/25] fix(usb_device): address X-Input review (control, claim/release, cleanup, CDC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - control_xfer_cb: return false to STALL XUSB's vendor control requests instead of returning true without completing the transfer (which left it pending). A stall is unambiguous "not supported" — XUSB falls back to default capabilities, which the interrupt-IN input path needs; the comment now matches the behavior. - update_gamepad: follow the TinyUSB endpoint contract exactly — busy-check, then claim/xfer/release, releasing the claim after the transfer is queued on BOTH success and failure, so a missed completion can't leave the endpoint permanently claimed. (Also drops the temporary TX debug log.) - xinput_drv_open: if a later usbd_edpt_open() fails, close any endpoint already opened and reset ep_in/ep_out before returning 0, instead of leaving partial endpoint state. - CDC is now compiled conditionally (#if CFG_TUD_CDC>0): the tinyusb_cdc_acm.h include, kCdcPort, the CDC RX trampoline, the CDC descriptor + init, and the deinit are all guarded, with an early function_not_supported check when a CDC function is requested but CFG_TUD_CDC==0 (mirroring the vendor/HID checks). The xinput example now disables CDC entirely (leaner: 58% free vs 57%), removing the previous "must enable CDC even unused" workaround. Verified: xinput example builds clean with CDC off AND on; the CDC/vendor/HID descriptor paths still compile; host descriptor test passes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/src/usb_device.cpp | 96 +++++++++++++------ .../xinput_example/sdkconfig.defaults | 12 +-- 2 files changed, 74 insertions(+), 34 deletions(-) diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index d429e3c50c..bdf356ea5c 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -10,9 +10,14 @@ #include "freertos/task.h" #include "tinyusb.h" -#include "tinyusb_cdc_acm.h" #include "tinyusb_default_config.h" #include "tusb.h" +// Only pull in the CDC-ACM helper when the CDC class is actually compiled in +// (CONFIG_TINYUSB_CDC_COUNT > 0 -> CFG_TUD_CDC). This keeps XInput-only / vendor- +// only builds from forcing CDC support. tusb.h above defines CFG_TUD_CDC. +#if (CFG_TUD_CDC > 0) +#include "tinyusb_cdc_acm.h" +#endif // TinyUSB private class-driver API (usbd_class_driver_t, usbd_edpt_*, // usbd_app_driver_get_cb). `src/device` is a private include of the tinyusb // component, but `src/` is public, so reach it via the `device/` prefix. @@ -32,8 +37,10 @@ namespace { // destructing instance. std::atomic s_device{nullptr}; +#if (CFG_TUD_CDC > 0) // The CDC port this component uses. A single dedicated CDC-ACM interface. constexpr tinyusb_cdcacm_itf_t kCdcPort = TINYUSB_CDC_ACM_0; +#endif // Backpressure tuning shared by write_cdc() and write_vendor() so the two TX // paths stay consistent. kUsbWriteTimeoutTicks bounds how long a blocking write @@ -68,7 +75,9 @@ void note_tinyusb_task() { s_tinyusb_task.store(xTaskGetCurrentTaskHandle(), std::memory_order_relaxed); } -bool on_tinyusb_task() { +// [[maybe_unused]]: only the CDC/vendor write-drain paths call this, so it is +// unused in an X-Input-only build (CFG_TUD_CDC == CFG_TUD_VENDOR == 0). +[[maybe_unused]] bool on_tinyusb_task() { return xTaskGetCurrentTaskHandle() == s_tinyusb_task.load(std::memory_order_relaxed); } @@ -128,8 +137,16 @@ uint16_t xinput_drv_open(uint8_t rhport, tusb_desc_interface_t const *desc_itf, break; if (type == TUSB_DESC_ENDPOINT) { const tusb_desc_endpoint_t *ep = reinterpret_cast(p); - if (!usbd_edpt_open(rhport, ep)) + if (!usbd_edpt_open(rhport, ep)) { + // Close any endpoint already opened so we don't leave partial state. + if (s_xinput_drv.ep_in) + usbd_edpt_close(rhport, s_xinput_drv.ep_in); + if (s_xinput_drv.ep_out) + usbd_edpt_close(rhport, s_xinput_drv.ep_out); + s_xinput_drv.ep_in = 0; + s_xinput_drv.ep_out = 0; return 0; + } if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN) s_xinput_drv.ep_in = ep->bEndpointAddress; else @@ -166,14 +183,16 @@ bool xinput_drv_control_xfer(uint8_t rhport, uint8_t stage, tusb_control_request request->bmRequestType, request->bRequest, request->wValue, request->wIndex, request->wLength); - // Do NOT synthesize responses to XUSB's vendor control requests. In particular - // GET_CAPABILITIES (bmReq 0xC1, bReq 0x01, wValue 0x0100) expects a real 20-byte - // capabilities report; answering it with zeros tells XUSB the controller has no - // controls, so it ignores all input. A real wired 360 controller and the - // known-working esp32s3 references simply leave these requests unanswered and - // XUSB falls back to full default capabilities. Return true to consider them - // handled (no data/stall), matching that behavior. - return true; + // Stall XUSB's vendor control requests (return false -> TinyUSB STALLs the + // request). In particular GET_CAPABILITIES (bmReq 0xC1, bReq 0x01, wValue + // 0x0100) expects a real 20-byte capabilities report; answering it with zeros + // tells XUSB the controller has no controls (so it ignores all input), and + // returning true without completing the control transfer leaves it pending. + // Stalling is unambiguous "not supported": XUSB falls back to full default + // capabilities, which is what a wired 360 controller's driver does and what the + // input path (interrupt IN reports) needs. If a specific request must be + // answered later, handle it explicitly with tud_control_xfer/tud_control_status. + return false; } bool xinput_drv_xfer_cb(uint8_t rhport, uint8_t ep_addr, xfer_result_t result, @@ -269,8 +288,10 @@ UsbDevice::~UsbDevice() { // initialize()). UsbDevice *expected = this; s_device.compare_exchange_strong(expected, nullptr); +#if (CFG_TUD_CDC > 0) if (config_.cdc) tinyusb_cdcacm_deinit(kCdcPort); +#endif tinyusb_driver_uninstall(); initialized_ = false; } @@ -280,6 +301,7 @@ UsbDevice::~UsbDevice() { // TinyUSB C callbacks (global; routed to the active instance). // --------------------------------------------------------------------------- +#if (CFG_TUD_CDC > 0) // CDC RX trampoline registered with esp_tinyusb; runs in the TinyUSB task. static void cdc_rx_trampoline(int itf, cdcacm_event_t *event) { (void)event; @@ -291,6 +313,7 @@ static void cdc_rx_trampoline(int itf, cdcacm_event_t *event) { if (dev) dev->handle_cdc_rx(); } +#endif extern "C" { @@ -554,6 +577,14 @@ bool UsbDevice::initialize(std::error_code &ec) { ec = std::make_error_code(std::errc::function_not_supported); return false; } + if (config_.cdc) { +#if (CFG_TUD_CDC == 0) + logger_.error("CDC function requested but CFG_TUD_CDC==0. Set " + "CONFIG_TINYUSB_CDC_COUNT>0 in sdkconfig."); + ec = std::make_error_code(std::errc::function_not_supported); + return false; +#endif + } if (config_.vendor) { #if (CFG_TUD_VENDOR == 0) logger_.error("Vendor function requested but CFG_TUD_VENDOR==0. Set " @@ -612,7 +643,10 @@ bool UsbDevice::initialize(std::error_code &ec) { impl_->owned_strings = {config_.manufacturer, config_.product, config_.serial_number}; uint8_t next_str = 4; - uint8_t cdc_itf = 0, cdc_str = 0, cdc_notif = 0, cdc_out = 0, cdc_in = 0; + // [[maybe_unused]]: these feed TUD_CDC_DESCRIPTOR, which is compiled only when + // CFG_TUD_CDC>0; without CDC the block below never runs (config_.cdc is + // rejected earlier) and the values are unused. + [[maybe_unused]] uint8_t cdc_itf = 0, cdc_str = 0, cdc_notif = 0, cdc_out = 0, cdc_in = 0; if (config_.cdc) { cdc_itf = next_itf; next_itf = static_cast(next_itf + 2); // comm + data interfaces @@ -732,10 +766,12 @@ bool UsbDevice::initialize(std::error_code &ec) { // --- Configuration descriptor --- uint8_t itf_count = 0; uint16_t total_len = TUD_CONFIG_DESC_LEN; +#if (CFG_TUD_CDC > 0) if (config_.cdc) { itf_count = static_cast(itf_count + 2); total_len = static_cast(total_len + TUD_CDC_DESC_LEN); } +#endif if (config_.vendor) { itf_count = static_cast(itf_count + 1); total_len = static_cast(total_len + TUD_VENDOR_DESC_LEN); @@ -767,12 +803,14 @@ bool UsbDevice::initialize(std::error_code &ec) { }; append(hdr, sizeof(hdr)); } +#if (CFG_TUD_CDC > 0) if (config_.cdc) { const uint8_t d[] = { TUD_CDC_DESCRIPTOR(cdc_itf, cdc_str, cdc_notif, 8, cdc_out, cdc_in, bulk_ep_size), }; append(d, sizeof(d)); } +#endif if (config_.vendor) { const uint8_t d[] = { TUD_VENDOR_DESCRIPTOR(vendor_itf, vendor_str, vendor_out, vendor_in, bulk_ep_size), @@ -1092,6 +1130,7 @@ bool UsbDevice::initialize(std::error_code &ec) { } // --- Initialize the CDC-ACM function (vendor needs no explicit init) --- +#if (CFG_TUD_CDC > 0) if (config_.cdc) { tinyusb_config_cdcacm_t acm_cfg = {}; acm_cfg.cdc_port = kCdcPort; @@ -1108,6 +1147,7 @@ bool UsbDevice::initialize(std::error_code &ec) { return false; } } +#endif initialized_ = true; // Copy the packed descriptor fields into locals: they cannot bind to the @@ -1535,28 +1575,28 @@ bool UsbDevice::update_gamepad(const espp::xinput::GamepadState &state, std::err ec = std::make_error_code(std::errc::not_connected); return false; } - // update_gamepad() runs on the caller's task, not the TinyUSB task, so claim - // the endpoint (atomic, mutex-guarded) before submitting — the same pattern - // tud_hid_report() uses. This both arbitrates against the USB task and is the - // reliable way to hand a transfer to the interrupt-IN endpoint cross-task; a - // bare busy-check + xfer can race and wedge the endpoint. claim() fails if a - // previous report is still in flight (transient backpressure). + // update_gamepad() runs on the caller's task, not the TinyUSB task. Follow the + // TinyUSB endpoint contract exactly (busy-check, then claim/xfer/release): + // - usbd_edpt_busy() rejects submitting while a previous report is still in + // flight (transient backpressure) — and is required because usbd_edpt_xfer() + // asserts the endpoint is not busy. + // - usbd_edpt_claim() arbitrates against the USB task; it is released after the + // transfer is QUEUED (on both success and failure) so the endpoint is never + // left permanently claimed if a completion is missed. + if (usbd_edpt_busy(0, ep_in)) { + ec = std::make_error_code(std::errc::resource_unavailable_try_again); + return false; + } if (!usbd_edpt_claim(0, ep_in)) { ec = std::make_error_code(std::errc::resource_unavailable_try_again); return false; } // The buffer must outlive the (asynchronous) transfer, so it lives in Impl. impl_->xinput_report = state.report(); - // Debug: log the exact bytes we hand to the stack. A USB capture shows the wire - // reports arriving with a spurious leading 0x01 (00 14 .. shifted by one); this - // confirms whether that byte originates here or below us in esp_tinyusb/DWC2. - logger_.info_rate_limited( - "XInput TX[{}]: {:02x} {:02x} {:02x} {:02x} {:02x} {:02x}", impl_->xinput_report.size(), - impl_->xinput_report[0], impl_->xinput_report[1], impl_->xinput_report[2], - impl_->xinput_report[3], impl_->xinput_report[4], impl_->xinput_report[5]); - if (!usbd_edpt_xfer(0, ep_in, impl_->xinput_report.data(), - static_cast(impl_->xinput_report.size()), false)) { - usbd_edpt_release(0, ep_in); // undo the claim so the endpoint isn't wedged + const bool queued = usbd_edpt_xfer(0, ep_in, impl_->xinput_report.data(), + static_cast(impl_->xinput_report.size()), false); + usbd_edpt_release(0, ep_in); // pair with claim(), regardless of queue result + if (!queued) { logger_.warn_rate_limited("XInput report send (usbd_edpt_xfer) failed on ep 0x{:02x}", ep_in); ec = std::make_error_code(std::errc::io_error); return false; diff --git a/components/usb_device/xinput_example/sdkconfig.defaults b/components/usb_device/xinput_example/sdkconfig.defaults index 5465ceea72..561b04480c 100644 --- a/components/usb_device/xinput_example/sdkconfig.defaults +++ b/components/usb_device/xinput_example/sdkconfig.defaults @@ -10,9 +10,9 @@ CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y # The X-Input interface is served by usb_device's own custom TinyUSB application -# class driver, so NO built-in class driver count is required (vendor/HID stay 0, -# which also avoids the built-in vendor driver claiming the 0xFF interface). But -# usb_device.cpp includes tinyusb_cdc_acm.h unconditionally, so the CDC feature -# must be compiled in even though this example creates no CDC interface. -CONFIG_TINYUSB_CDC_ENABLED=y -CONFIG_TINYUSB_CDC_COUNT=1 +# class driver, so NO built-in class driver is required: CDC, vendor and HID all +# stay disabled (0). Keeping the built-in vendor driver off also avoids it +# claiming the 0xFF interface. usb_device now compiles its CDC support +# conditionally (#if CFG_TUD_CDC), so an X-Input-only build needs no CDC at all. +CONFIG_TINYUSB_CDC_ENABLED=n +CONFIG_TINYUSB_CDC_COUNT=0 From 9ac59daca40bf50d77203d0141c040903a9c6240 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 10 Sep 2026 23:25:49 -0500 Subject: [PATCH 17/25] docs(usb_device): update README for conditional CDC + X-Input enablement - CDC is now compiled conditionally, so the vendor/WebUSB enablement no longer lists the CDC config lines as required; added a note that CDC is opt-in (CONFIG_TINYUSB_CDC_ENABLED / CFG_TUD_CDC) and only needed for the CDC function. - New "Enabling X-Input (Xbox 360)" section: no CFG_TUD_* count (custom app class driver via -u usbd_app_driver_get_cb), the example's CDC-off sdkconfig, and the separate interrupt IN/OUT endpoints + word-aligned DMA buffers the ESP32-S3 DWC2 requires. TOC updated (also adds the previously-missing HID entry). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/README.md | 39 +++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/components/usb_device/README.md b/components/usb_device/README.md index f99d3162a2..c86c3516a2 100644 --- a/components/usb_device/README.md +++ b/components/usb_device/README.md @@ -45,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) @@ -136,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 ``` @@ -149,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 @@ -167,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 From 2798fcbee62d886b6fe1fb9fe9201a05c23a677b Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 08:10:09 -0500 Subject: [PATCH 18/25] =?UTF-8?q?fix(usb=5Fdevice):=20X-Input=20review=20?= =?UTF-8?q?=E2=80=94=20XID=20byte,=20manifest=20example,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - XID descriptor byte [2] is 0x00 on the Microsoft wired Xbox 360 controller and established XInput implementations, not 0x10. Use 0x00 (the byte XUSB inspects) and correct the comment's "byte-for-byte" claim. - idf_component.yml: publish the xinput_example (add `- path: xinput_example`) so the Component Manager exposes it, and add HID/XInput/Gamepad tags. - Docs (doc/en/buses/usb_cdc.rst): the hand-written page now lists X-Input in the overview + features, adds an X-Input row to the endpoint-budget table, an "Enabling X-Input" section, and a xinput_example toctree entry. New xinput_example/README.md (+ doc include) documents the demo, the emulation-only identity, and basic usage. Host descriptor test passes; example builds clean on ESP-IDF v6.1 (esp32s3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/idf_component.yml | 4 ++ components/usb_device/include/xinput.hpp | 12 ++--- .../usb_device/xinput_example/README.md | 51 +++++++++++++++++++ doc/en/buses/usb_cdc.rst | 30 +++++++++++ doc/en/buses/xinput_example.md | 2 + 5 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 components/usb_device/xinput_example/README.md create mode 100644 doc/en/buses/xinput_example.md diff --git a/components/usb_device/idf_component.yml b/components/usb_device/idf_component.yml index 1313ab4ee8..a75b5ee984 100644 --- a/components/usb_device/idf_component.yml +++ b/components/usb_device/idf_component.yml @@ -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 @@ -18,6 +19,9 @@ tags: - TinyUSB - Serial - Transport + - HID + - XInput + - Gamepad dependencies: idf: version: '>=5.0' diff --git a/components/usb_device/include/xinput.hpp b/components/usb_device/include/xinput.hpp index f6e7b80f23..6c0a8437ce 100644 --- a/components/usb_device/include/xinput.hpp +++ b/components/usb_device/include/xinput.hpp @@ -126,12 +126,12 @@ inline std::vector interface_descriptor(uint8_t itf_num, uint8_t str_id // 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), matched byte-for-byte to a - // real wired Xbox 360 controller (bLength 0x11, bDescriptorType 0x21). [2] - // is 0x10 on the retail controller; [6] = IN endpoint address, [7] = IN - // report size (0x14 = 20), [13] = OUT endpoint address, [14] = OUT report - // size (0x08 = 8). - 0x11, 0x21, 0x10, 0x01, 0x01, 0x25, + // 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, diff --git a/components/usb_device/xinput_example/README.md b/components/usb_device/xinput_example/README.md new file mode 100644 index 0000000000..9b040c2cac --- /dev/null +++ b/components/usb_device/xinput_example/README.md @@ -0,0 +1,51 @@ +# X-Input (Xbox 360) controller example + +Presents the ESP32-S3 as a wired **Xbox 360 controller** over the native USB-OTG +peripheral, using `espp::UsbDevice`'s X-Input function. A PC's XUSB driver binds +it, so it shows up as an Xbox 360 controller in Windows' "Set up USB game +controllers" (`joy.cpl`) and any X-Input game, or under Linux `xpad`. + +The demo sweeps the sticks/triggers in a circle and steps the face buttons +A/B/X/Y one at a time each second, so you can see live input, and logs any +rumble / LED reports the host sends back. The console/logs go to the separate +built-in USB-Serial-JTAG so they stay off the emulated controller interface. + +## Build & flash + +X-Input is served by a custom TinyUSB application class driver built into the +`usb_device` component, so it needs **no** built-in USB class enabled — the +example's `sdkconfig.defaults` disables CDC/vendor/HID entirely. Flash it to an +ESP32-S3 and plug the native USB-OTG port into a PC: + +```sh +idf.py -p flash monitor # monitor is the USB-Serial-JTAG console +``` + +## Identity (emulation only) + +The device enumerates with **Microsoft's Xbox 360 VID/PID (`0x045E:0x028E`)** and +device class `0xFF/0xFF/0xFF` — that identity is what makes the host's XUSB driver +bind it. These are Microsoft's identifiers, for **emulation / testing of your own +device only**; a shipped product must not enumerate under them. + +## Using it in your own code + +```cpp +espp::UsbDevice::Config cfg; +espp::UsbDevice::XInputFunction xinput; // default VID/PID = Xbox 360 wired +xinput.on_rumble = [](std::span data) { /* drive motors / LEDs */ }; +cfg.xinput = xinput; // X-Input as the ONLY function +espp::UsbDevice usb(cfg); +std::error_code ec; +usb.initialize(ec); + +espp::xinput::GamepadState state; +state.set(espp::xinput::Button::A, true); +state.lx = 20000; // left stick X +usb.update_gamepad(state); // send a 20-byte input report +``` + +See `components/usb_device/include/xinput.hpp` for the `GamepadState` / +`Button` API and the report layout, and the component +[README](../README.md#enabling-x-input-xbox-360) for the constraints (X-Input must +be the only enabled function). diff --git a/doc/en/buses/usb_cdc.rst b/doc/en/buses/usb_cdc.rst index 8278eb2e1d..9897022811 100644 --- a/doc/en/buses/usb_cdc.rst +++ b/doc/en/buses/usb_cdc.rst @@ -19,6 +19,14 @@ 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 (for example 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** (a custom TinyUSB application class driver built into this + component — no ``CFG_TUD_*`` count needed). Gamepad state is sent with + ``update_gamepad()`` (see ``xinput.hpp``) and rumble/LED reports arrive via an + ``on_rumble`` callback. Because the host's XUSB driver only binds a recognized + Xbox 360 VID/PID and the built-in vendor class also claims interface class 0xFF, + **use X-Input as the only enabled function** (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 @@ -42,6 +50,8 @@ Features - Vendor-specific interface (class 0xFF) with a bulk IN + bulk OUT raw byte stream - HID interface with an application-supplied report descriptor (built with ``hid-rp`` in the example) and ``write_hid_report()`` +- X-Input interface (wired Xbox 360 controller) via a custom application class + driver, with ``update_gamepad()`` and an ``on_rumble`` callback - WebUSB: BOS descriptor + WebUSB URL descriptor + MS OS 2.0 descriptor for driverless browser access, with a configurable landing-page URL - Sequential interface / endpoint / string allocation with an endpoint-budget check @@ -129,6 +139,22 @@ example builds them with the espp ``hid-rp`` component), assign them to ``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``). An X-Input-only +project therefore enables no built-in USB class; the ``xinput_example`` disables +them all (``CONFIG_TINYUSB_CDC_ENABLED=n``). Keep ``CFG_TUD_VENDOR`` at 0 so the +built-in bulk vendor driver does not claim the X-Input 0xFF interface, and use +X-Input as the **only** enabled function (it then advertises the Xbox 360 identity ++ ``0xFF/0xFF/0xFF`` device class so the host's XUSB driver binds it). Send gamepad +state with ``update_gamepad()`` and receive rumble/LED via ``on_rumble``. The +interface uses one interrupt-IN (0x81) + one interrupt-OUT endpoint with separate +endpoint numbers, and the report DMA buffers are word-aligned as the ESP32-S3 DWC2 +requires. + Endpoint budget (ESP32-S3 USB-OTG) ---------------------------------- @@ -151,6 +177,9 @@ OUT endpoints**. Each function consumes: * - 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) @@ -204,6 +233,7 @@ Notes .. toctree:: usb_cdc_example.md + xinput_example.md .. ---------------------------- API Reference ---------------------------------- diff --git a/doc/en/buses/xinput_example.md b/doc/en/buses/xinput_example.md new file mode 100644 index 0000000000..821229211e --- /dev/null +++ b/doc/en/buses/xinput_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/usb_device/xinput_example/README.md +``` From b3c7d66bb17c5e295cb04e88cb9fbe3d7c2e4fd0 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 11:32:37 -0500 Subject: [PATCH 19/25] =?UTF-8?q?refactor(usb=5Fdevice):=20rename=20update?= =?UTF-8?q?=5Fgamepad=E2=86=92update=5Fxinput=5Fstate;=20hide=20internal?= =?UTF-8?q?=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review: - rename `update_gamepad()` (+ its error-ignoring overload) to `update_xinput_state()` to mirror `write_hid_report()` above and `is_xinput_ready()` below (the X-Input functions read as a set). - the device-task-only methods invoked from the TinyUSB C callbacks (handle_cdc_rx / handle_vendor_rx / handle_xinput_out, the descriptor accessors, vendor_config / xinput_active / xinput_in_endpoint, instance) were public. Move them to `protected` so they leave the public API. The TinyUSB trampolines reach them through a new nested `UsbDevice::Callbacks` bridge (a nested type has access to the enclosing class's non-public members), which avoids friend declarations for the variously file-static / extern "C" / version-conditional callbacks. Builds clean on IDF v6.1 esp32s3: both the CDC+vendor+HID example and the X-Input example. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/README.md | 4 +- components/usb_device/include/usb_device.hpp | 18 ++++-- components/usb_device/src/usb_device.cpp | 60 ++++++++++++++----- .../usb_device/xinput_example/README.md | 2 +- .../xinput_example/main/xinput_example.cpp | 2 +- 5 files changed, 61 insertions(+), 25 deletions(-) diff --git a/components/usb_device/README.md b/components/usb_device/README.md index c86c3516a2..be6f276e21 100644 --- a/components/usb_device/README.md +++ b/components/usb_device/README.md @@ -20,7 +20,7 @@ Today it can enable, in any combination (subject to the endpoint budget): - 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 + `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 @@ -195,7 +195,7 @@ CONFIG_TINYUSB_CDC_COUNT=0 ``` Set `Config::xinput` (only — see the "only enabled function" note above), send -gamepad state with `update_gamepad(GamepadState)`, and receive rumble/LED reports +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 diff --git a/components/usb_device/include/usb_device.hpp b/components/usb_device/include/usb_device.hpp index a1d4ccb211..2df37dcf2d 100644 --- a/components/usb_device/include/usb_device.hpp +++ b/components/usb_device/include/usb_device.hpp @@ -149,7 +149,7 @@ class UsbDevice : public BaseComponent { * * 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 + * 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). @@ -333,10 +333,10 @@ class UsbDevice : public BaseComponent { * @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); + bool update_xinput_state(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 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). @@ -370,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: // // 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. diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index bdf356ea5c..c03e073339 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -25,6 +25,33 @@ #include "xinput.hpp" +namespace espp { +// Bridges the global TinyUSB C callback trampolines to UsbDevice's device-task- +// only methods, which are non-public (protected). A nested type has access to the +// enclosing class's non-public members, so these thin static forwarders keep those +// methods off the public API without a raft of friend declarations for the +// (variously file-static / extern "C" / version-conditional) callbacks. +struct UsbDevice::Callbacks { + static void cdc_rx(UsbDevice *d) { d->handle_cdc_rx(); } + static void vendor_rx(UsbDevice *d, const uint8_t *buf, size_t n) { d->handle_vendor_rx(buf, n); } + static void xinput_out(UsbDevice *d, const uint8_t *buf, size_t n) { + d->handle_xinput_out(buf, n); + } + static const uint8_t *bos(UsbDevice *d) { return d->bos_descriptor(); } + static const uint8_t *ms_os_20(UsbDevice *d, uint16_t &len) { + return d->ms_os_20_descriptor(len); + } + static const uint8_t *webusb_url(UsbDevice *d, uint8_t &len) { + return d->webusb_url_descriptor(len); + } + static const uint8_t *hid_report(UsbDevice *d) { return d->hid_report_descriptor(); } + static bool xinput_active(UsbDevice *d) { return d->xinput_active(); } + static const std::optional &vendor_config(UsbDevice *d) { + return d->vendor_config(); + } +}; +} // namespace espp + namespace { // Only a single USB device exists on the chip; the BOS descriptor and the vendor @@ -90,7 +117,7 @@ void note_tinyusb_task() { // is inert when no XInput function is enabled. struct XInputDriver { // All fields are touched only on the TinyUSB task (open/reset/xfer_cb/log). The - // app-facing update_gamepad()/is_xinput_ready() use UsbDevice's own + // app-facing update_xinput_state()/is_xinput_ready() use UsbDevice's own // impl_->xinput_ep_in (fixed at initialize(), immutable afterwards) instead of // reading these, so there is no cross-task access here to synchronize. uint8_t itf_num{0xFF}; @@ -202,7 +229,8 @@ bool xinput_drv_xfer_cb(uint8_t rhport, uint8_t ep_addr, xfer_result_t result, if (result == XFER_RESULT_SUCCESS && xferred_bytes > 0) { auto *dev = s_device.load(); if (dev) - dev->handle_xinput_out(s_xinput_drv.out_buf.data(), static_cast(xferred_bytes)); + espp::UsbDevice::Callbacks::xinput_out(dev, s_xinput_drv.out_buf.data(), + static_cast(xferred_bytes)); } // Re-prime the OUT endpoint for the next report. usbd_edpt_xfer(rhport, s_xinput_drv.ep_out, s_xinput_drv.out_buf.data(), espp::xinput::kEpSize, @@ -262,7 +290,7 @@ struct UsbDevice::Impl { uint8_t xinput_ep_in{0}; // 0x80|n, or 0 if the XInput function is disabled uint8_t xinput_ep_out{0}; // n, or 0 if disabled // Input-report TX buffer; held for the duration of the async interrupt-IN - // transfer submitted by update_gamepad(). MUST be 4-byte aligned: the ESP32-S3 + // transfer submitted by update_xinput_state(). MUST be 4-byte aligned: the ESP32-S3 // DWC2 reads it by DMA and a misaligned buffer makes the controller read from // the aligned-down address, prepending the preceding byte to every report // (which shifted our "00 14 .." report by one and made XUSB reject all input). @@ -311,7 +339,7 @@ static void cdc_rx_trampoline(int itf, cdcacm_event_t *event) { // load once: the pointer must not be re-read between check and use auto *dev = s_device.load(); if (dev) - dev->handle_cdc_rx(); + UsbDevice::Callbacks::cdc_rx(dev); } #endif @@ -322,7 +350,7 @@ extern "C" { uint8_t const *tud_descriptor_bos_cb(void) { note_tinyusb_task(); auto *dev = s_device.load(); - return dev ? dev->bos_descriptor() : nullptr; + return dev ? UsbDevice::Callbacks::bos(dev) : nullptr; } #if (CFG_TUD_VENDOR > 0) @@ -339,7 +367,7 @@ void tud_vendor_rx_cb(uint8_t itf, uint8_t const *buffer, uint32_t bufsize) { // tud_vendor_read); the zero-copy variant passes the received bytes directly. auto *dev = s_device.load(); if (dev) - dev->handle_vendor_rx(buffer, static_cast(bufsize)); + UsbDevice::Callbacks::vendor_rx(dev, buffer, static_cast(bufsize)); } // Vendor control-transfer callback: answer the WebUSB URL and MS OS 2.0 @@ -353,14 +381,14 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, // Diagnostic: surface any vendor control request that reaches the *global* // vendor path (e.g. device-recipient) rather than the per-interface X-Input // handler, so we can tell where XUSB's init requests actually land. - if (dev && dev->xinput_active()) + if (dev && UsbDevice::Callbacks::xinput_active(dev)) ESP_LOGI("espp_xinput", "global vendor control bmReq=0x%02x bReq=0x%02x wVal=0x%04x wIdx=0x%04x wLen=%u", request->bmRequestType, request->bRequest, request->wValue, request->wIndex, request->wLength); - if (!dev || !dev->vendor_config().has_value()) + if (!dev || !UsbDevice::Callbacks::vendor_config(dev).has_value()) return false; - const auto &vendor = *dev->vendor_config(); + const auto &vendor = *UsbDevice::Callbacks::vendor_config(dev); switch (request->bmRequestType_bit.type) { case TUSB_REQ_TYPE_VENDOR: @@ -370,7 +398,7 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, if (request->bRequest == vendor.webusb_vendor_code && request->wIndex == 2) { // Return the WebUSB landing-page URL descriptor. uint8_t len = 0; - const uint8_t *url = dev->webusb_url_descriptor(len); + const uint8_t *url = UsbDevice::Callbacks::webusb_url(dev, len); if (!url) return false; return tud_control_xfer(rhport, request, (void *)(uintptr_t)url, len); @@ -378,7 +406,7 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, if (request->bRequest == vendor.ms_os_vendor_code && request->wIndex == 7) { // Return the MS OS 2.0 descriptor set. uint16_t total_len = 0; - const uint8_t *ms = dev->ms_os_20_descriptor(total_len); + const uint8_t *ms = UsbDevice::Callbacks::ms_os_20(dev, total_len); if (!ms) return false; return tud_control_xfer(rhport, request, (void *)(uintptr_t)ms, total_len); @@ -433,7 +461,7 @@ extern "C" void espp_usb_device_event_cb(tinyusb_event_t *event, void *arg) { uint8_t const *tud_hid_descriptor_report_cb(uint8_t instance) { (void)instance; auto *dev = s_device.load(); - return dev ? dev->hid_report_descriptor() : nullptr; + return dev ? UsbDevice::Callbacks::hid_report(dev) : nullptr; } // HID GET_REPORT control request: this device is input-only, so nothing to do. @@ -1558,7 +1586,7 @@ void UsbDevice::handle_xinput_out(const uint8_t *buffer, size_t bufsize) { cb(std::span(buffer, bufsize)); // TinyUSB task context } -bool UsbDevice::update_gamepad(const espp::xinput::GamepadState &state, std::error_code &ec) { +bool UsbDevice::update_xinput_state(const espp::xinput::GamepadState &state, std::error_code &ec) { ec.clear(); if (!initialized_ || !config_.xinput) { ec = std::make_error_code(std::errc::not_connected); @@ -1575,7 +1603,7 @@ bool UsbDevice::update_gamepad(const espp::xinput::GamepadState &state, std::err ec = std::make_error_code(std::errc::not_connected); return false; } - // update_gamepad() runs on the caller's task, not the TinyUSB task. Follow the + // update_xinput_state() runs on the caller's task, not the TinyUSB task. Follow the // TinyUSB endpoint contract exactly (busy-check, then claim/xfer/release): // - usbd_edpt_busy() rejects submitting while a previous report is still in // flight (transient backpressure) — and is required because usbd_edpt_xfer() @@ -1605,9 +1633,9 @@ bool UsbDevice::update_gamepad(const espp::xinput::GamepadState &state, std::err return true; } -bool UsbDevice::update_gamepad(const espp::xinput::GamepadState &state) { +bool UsbDevice::update_xinput_state(const espp::xinput::GamepadState &state) { std::error_code ec; - return update_gamepad(state, ec); + return update_xinput_state(state, ec); } bool UsbDevice::is_xinput_ready() const { diff --git a/components/usb_device/xinput_example/README.md b/components/usb_device/xinput_example/README.md index 9b040c2cac..eaea4f7abf 100644 --- a/components/usb_device/xinput_example/README.md +++ b/components/usb_device/xinput_example/README.md @@ -42,7 +42,7 @@ usb.initialize(ec); espp::xinput::GamepadState state; state.set(espp::xinput::Button::A, true); state.lx = 20000; // left stick X -usb.update_gamepad(state); // send a 20-byte input report +usb.update_xinput_state(state); // send a 20-byte input report ``` See `components/usb_device/include/xinput.hpp` for the `GamepadState` / diff --git a/components/usb_device/xinput_example/main/xinput_example.cpp b/components/usb_device/xinput_example/main/xinput_example.cpp index 9efdfbf2b0..698d4b2272 100644 --- a/components/usb_device/xinput_example/main/xinput_example.cpp +++ b/components/usb_device/xinput_example/main/xinput_example.cpp @@ -67,7 +67,7 @@ extern "C" void app_main(void) { state.set(b, false); state.set(face[(tick / 50) % 4], true); - usb.update_gamepad(state); // no-op / retry-later while not mounted or busy + usb.update_xinput_state(state); // no-op / retry-later while not mounted or busy tick++; std::this_thread::sleep_for(20ms); From 96e45e9c729c465798a20316f199ae74dd06f172 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 11:57:37 -0500 Subject: [PATCH 20/25] fix(usb_device): console on UART0, not USB-Serial-JTAG, in the examples Both the CDC/vendor/HID example and the X-Input example hand the native USB-OTG port to TinyUSB, but set the console to USB-Serial-JTAG. On the ESP32-S3 the USB-Serial-JTAG controller and USB-OTG share the SAME internal USB PHY / physical port (they are NOT separate, despite the old comments), so the console contends with the TinyUSB interface for that one port and the device reboot-loops at boot. Switch both to UART0 primary + USB-Serial-JTAG secondary (early-boot logs before TinyUSB comes up), matching the ota example. Correct the READMEs' "separate / distinct USB" claims. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/example/README.md | 16 ++++++++++------ .../example/sdkconfig.defaults.esp32s3 | 15 +++++++++++---- components/usb_device/xinput_example/README.md | 9 ++++++--- .../usb_device/xinput_example/sdkconfig.defaults | 15 ++++++++++++--- 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/components/usb_device/example/README.md b/components/usb_device/example/README.md index 0417f43915..0b1b92d863 100644 --- a/components/usb_device/example/README.md +++ b/components/usb_device/example/README.md @@ -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 @@ -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 @@ -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. diff --git a/components/usb_device/example/sdkconfig.defaults.esp32s3 b/components/usb_device/example/sdkconfig.defaults.esp32s3 index bcdec26af9..4328b0f758 100644 --- a/components/usb_device/example/sdkconfig.defaults.esp32s3 +++ b/components/usb_device/example/sdkconfig.defaults.esp32s3 @@ -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 diff --git a/components/usb_device/xinput_example/README.md b/components/usb_device/xinput_example/README.md index eaea4f7abf..6388867bec 100644 --- a/components/usb_device/xinput_example/README.md +++ b/components/usb_device/xinput_example/README.md @@ -7,8 +7,11 @@ controllers" (`joy.cpl`) and any X-Input game, or under Linux `xpad`. The demo sweeps the sticks/triggers in a circle and steps the face buttons A/B/X/Y one at a time each second, so you can see live input, and logs any -rumble / LED reports the host sends back. The console/logs go to the separate -built-in USB-Serial-JTAG so they stay off the emulated controller interface. +rumble / LED reports the host sends back. The console/logs go to **UART0** (with +USB-Serial-JTAG as an early-boot secondary): on the ESP32-S3 the USB-Serial-JTAG +controller shares the native USB port's PHY with USB-OTG, so keeping the console +on it would contend with the emulated controller interface and reboot-loop the +device. Use a UART / USB-UART adapter on UART0 for `idf.py monitor`. ## Build & flash @@ -18,7 +21,7 @@ example's `sdkconfig.defaults` disables CDC/vendor/HID entirely. Flash it to an ESP32-S3 and plug the native USB-OTG port into a PC: ```sh -idf.py -p flash monitor # monitor is the USB-Serial-JTAG console +idf.py -p flash monitor # console is on UART0 (USB-UART adapter) ``` ## Identity (emulation only) diff --git a/components/usb_device/xinput_example/sdkconfig.defaults b/components/usb_device/xinput_example/sdkconfig.defaults index 561b04480c..8d5343707d 100644 --- a/components/usb_device/xinput_example/sdkconfig.defaults +++ b/components/usb_device/xinput_example/sdkconfig.defaults @@ -5,9 +5,18 @@ CONFIG_IDF_TARGET="esp32s3" CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 -# Keep the console/logs on the built-in USB-Serial-JTAG so they stay separate -# from the native USB-OTG interface presented as the Xbox controller. -CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y +# Put the console on UART0, NOT the USB-Serial-JTAG peripheral. This example hands +# the native USB-OTG port to TinyUSB (the X-Input interface), 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 X-Input interface for that one port +# and makes the device reboot-loop at boot. UART0 is an independent peripheral, so +# the console always has a place to go once TinyUSB takes over the USB port. +# (Connect a UART / USB-UART adapter to UART0 for `idf.py monitor`.) USB-Serial-JTAG +# stays as the SECONDARY console so early-boot / bootloader logs are still visible +# on the native USB port before TinyUSB comes up. +CONFIG_ESP_CONSOLE_UART_DEFAULT=y +CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y # The X-Input interface is served by usb_device's own custom TinyUSB application # class driver, so NO built-in class driver is required: CDC, vendor and HID all From 9703c9d813742fb2d332fcdb50f8dfa051a4c2cc Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 12:01:43 -0500 Subject: [PATCH 21/25] refactor(usb_device): drop X-Input hardware-debug logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input is confirmed working, so remove the noisy diagnostics added while chasing the DMA-alignment bug: the per-control-request SETUP dumps (X-Input + the global vendor path), the raw OUT-report hex dump, the "not ready to send" warning that fired on every update while unmounted (normal — the ec already reports it), and the rate-limited "reports flowing" line after mount. Downgrade the one-time class-driver-open / registration lines to ESP_LOGD. Keep the genuine warnings (no IN endpoint opened, usbd_edpt_xfer failed). Removing the global-vendor-control diagnostic left xinput_active() unused, so drop it (and its Callbacks bridge) too. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/include/usb_device.hpp | 3 -- components/usb_device/src/usb_device.cpp | 36 +++----------------- 2 files changed, 5 insertions(+), 34 deletions(-) diff --git a/components/usb_device/include/usb_device.hpp b/components/usb_device/include/usb_device.hpp index 2df37dcf2d..043c72d0ca 100644 --- a/components/usb_device/include/usb_device.hpp +++ b/components/usb_device/include/usb_device.hpp @@ -410,9 +410,6 @@ class UsbDevice : public BaseComponent { /// @brief Internal: config for the vendor control-request handler. const std::optional &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). diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index c03e073339..4cf86c5c59 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -45,7 +45,6 @@ struct UsbDevice::Callbacks { return d->webusb_url_descriptor(len); } static const uint8_t *hid_report(UsbDevice *d) { return d->hid_report_descriptor(); } - static bool xinput_active(UsbDevice *d) { return d->xinput_active(); } static const std::optional &vendor_config(UsbDevice *d) { return d->vendor_config(); } @@ -187,11 +186,7 @@ uint16_t xinput_drv_open(uint8_t rhport, tusb_desc_interface_t const *desc_itf, usbd_edpt_xfer(rhport, s_xinput_drv.ep_out, s_xinput_drv.out_buf.data(), espp::xinput::kEpSize, false); - // Diagnostic (USB-Serial-JTAG console): if this line does NOT appear when the - // host enumerates the device, the app class driver was not registered (the - // usbd_app_driver_get_cb weak override did not take effect) and no reports can - // flow even though Windows shows the device by VID/PID. - ESP_LOGI("espp_xinput", "class driver open: itf=%u ep_in=0x%02x ep_out=0x%02x", + ESP_LOGD("espp_xinput", "class driver open: itf=%u ep_in=0x%02x ep_out=0x%02x", s_xinput_drv.itf_num, s_xinput_drv.ep_in, s_xinput_drv.ep_out); if (s_xinput_drv.ep_in == 0) ESP_LOGW("espp_xinput", "no interrupt IN endpoint opened -- host will get no input reports"); @@ -204,9 +199,7 @@ bool xinput_drv_control_xfer(uint8_t rhport, uint8_t stage, tusb_control_request if (stage != CONTROL_STAGE_SETUP) return true; // DATA / ACK stages: nothing to do - // Log every control request the host sends to the X-Input interface so the - // XUSB init handshake is observable on the USB-Serial-JTAG console. - ESP_LOGI("espp_xinput", "control SETUP bmReq=0x%02x bReq=0x%02x wVal=0x%04x wIdx=0x%04x wLen=%u", + ESP_LOGD("espp_xinput", "control SETUP bmReq=0x%02x bReq=0x%02x wVal=0x%04x wIdx=0x%04x wLen=%u", request->bmRequestType, request->bRequest, request->wValue, request->wIndex, request->wLength); @@ -259,7 +252,7 @@ const usbd_class_driver_t s_xinput_class_driver = { // unit, so this strong override only wins if the linker keeps it — the // usb_device component CMakeLists forces it with `-u usbd_app_driver_get_cb`. extern "C" usbd_class_driver_t const *usbd_app_driver_get_cb(uint8_t *driver_count) { - ESP_LOGI("espp_xinput", "registering X-Input application class driver"); + ESP_LOGD("espp_xinput", "registering X-Input application class driver"); *driver_count = 1; return &s_xinput_class_driver; } @@ -378,14 +371,6 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, if (stage != CONTROL_STAGE_SETUP) return true; // nothing to do on DATA / ACK stages auto *dev = s_device.load(); - // Diagnostic: surface any vendor control request that reaches the *global* - // vendor path (e.g. device-recipient) rather than the per-interface X-Input - // handler, so we can tell where XUSB's init requests actually land. - if (dev && UsbDevice::Callbacks::xinput_active(dev)) - ESP_LOGI("espp_xinput", - "global vendor control bmReq=0x%02x bReq=0x%02x wVal=0x%04x wIdx=0x%04x wLen=%u", - request->bmRequestType, request->bRequest, request->wValue, request->wIndex, - request->wLength); if (!dev || !UsbDevice::Callbacks::vendor_config(dev).has_value()) return false; const auto &vendor = *UsbDevice::Callbacks::vendor_config(dev); @@ -1567,16 +1552,6 @@ bool UsbDevice::is_hid_ready() const { uint8_t UsbDevice::xinput_in_endpoint() const { return impl_->xinput_ep_in; } void UsbDevice::handle_xinput_out(const uint8_t *buffer, size_t bufsize) { - // Diagnostic: log every OUT (rumble/LED) report the host sends, RAW and - // unconditionally, so we can tell whether the interrupt-OUT path receives - // anything at all (independent of how the app callback filters it). - if (buffer && bufsize > 0) { - char hex[3 * 16 + 1] = {0}; - const size_t n = bufsize < 16 ? bufsize : 16; - for (size_t i = 0; i < n; i++) - snprintf(hex + i * 3, 4, "%02x ", buffer[i]); - ESP_LOGI("espp_xinput", "OUT report (%u bytes): %s", static_cast(bufsize), hex); - } receive_callback_fn cb; { std::scoped_lock lk(cb_mutex_); @@ -1598,8 +1573,8 @@ bool UsbDevice::update_xinput_state(const espp::xinput::GamepadState &state, std // for this (only) interface — so a mounted device has its endpoint open. const uint8_t ep_in = impl_->xinput_ep_in; if (!tud_mounted() || ep_in == 0) { - logger_.warn_rate_limited("XInput not ready to send: mounted={} ep_in=0x{:02x}", tud_mounted(), - ep_in); + // Normal before the host mounts the device (the app may poll update_* in a + // loop): report it via ec and let the caller decide -- don't log. ec = std::make_error_code(std::errc::not_connected); return false; } @@ -1629,7 +1604,6 @@ bool UsbDevice::update_xinput_state(const espp::xinput::GamepadState &state, std ec = std::make_error_code(std::errc::io_error); return false; } - logger_.info_rate_limited("XInput reports flowing on ep 0x{:02x}", ep_in); return true; } From 20eebc7103207f449e25b8cf263223e0e3415d9f Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 12:19:22 -0500 Subject: [PATCH 22/25] Clarify logging and USB port usage in comments Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- components/usb_device/xinput_example/main/xinput_example.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/usb_device/xinput_example/main/xinput_example.cpp b/components/usb_device/xinput_example/main/xinput_example.cpp index 698d4b2272..bbaa6290a1 100644 --- a/components/usb_device/xinput_example/main/xinput_example.cpp +++ b/components/usb_device/xinput_example/main/xinput_example.cpp @@ -5,8 +5,8 @@ // it (VID/PID default to Microsoft's 0x045E:0x028E), so it shows up as an Xbox // 360 controller in the OS gamepad tester / games. This demo cycles the buttons // and sweeps the sticks/triggers so you can see live input, and logs any rumble -// / LED reports the host sends back. The console/logs go to the separate -// built-in USB-Serial-JTAG. +// / LED reports the host sends back. The console/logs go to UART0; the native +// USB port is reserved for the emulated controller. #include #include From 1f781be9218eb33ab7c2feddf94bd64ce849a75e Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 12:21:34 -0500 Subject: [PATCH 23/25] Update documentation for X-Input function Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- doc/en/buses/usb_cdc.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/en/buses/usb_cdc.rst b/doc/en/buses/usb_cdc.rst index 9897022811..fb94857e0e 100644 --- a/doc/en/buses/usb_cdc.rst +++ b/doc/en/buses/usb_cdc.rst @@ -22,7 +22,7 @@ Today it can enable, in any combination (subject to the endpoint budget): - An **X-Input** function that presents the device as a wired **Xbox 360 controller** (a custom TinyUSB application class driver built into this component — no ``CFG_TUD_*`` count needed). Gamepad state is sent with - ``update_gamepad()`` (see ``xinput.hpp``) and rumble/LED reports arrive via an + ``update_xinput_state()`` (see ``xinput.hpp``) and rumble/LED reports arrive via an ``on_rumble`` callback. Because the host's XUSB driver only binds a recognized Xbox 360 VID/PID and the built-in vendor class also claims interface class 0xFF, **use X-Input as the only enabled function** (Microsoft's IDs, for emulation / From ef9eb8b442ac6887f1c24327807a1435a55259e6 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 12:27:10 -0500 Subject: [PATCH 24/25] docs(usb_device): update_gamepad -> update_xinput_state in usb_cdc.rst The method was renamed to update_xinput_state(); two remaining references in the usb_cdc docs still used the old name (which would not compile if copied). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- doc/en/buses/usb_cdc.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/en/buses/usb_cdc.rst b/doc/en/buses/usb_cdc.rst index fb94857e0e..2d8fea0926 100644 --- a/doc/en/buses/usb_cdc.rst +++ b/doc/en/buses/usb_cdc.rst @@ -51,7 +51,7 @@ Features - HID interface with an application-supplied report descriptor (built with ``hid-rp`` in the example) and ``write_hid_report()`` - X-Input interface (wired Xbox 360 controller) via a custom application class - driver, with ``update_gamepad()`` and an ``on_rumble`` callback + driver, with ``update_xinput_state()`` and an ``on_rumble`` callback - WebUSB: BOS descriptor + WebUSB URL descriptor + MS OS 2.0 descriptor for driverless browser access, with a configurable landing-page URL - Sequential interface / endpoint / string allocation with an endpoint-budget check @@ -150,7 +150,7 @@ them all (``CONFIG_TINYUSB_CDC_ENABLED=n``). Keep ``CFG_TUD_VENDOR`` at 0 so the built-in bulk vendor driver does not claim the X-Input 0xFF interface, and use X-Input as the **only** enabled function (it then advertises the Xbox 360 identity + ``0xFF/0xFF/0xFF`` device class so the host's XUSB driver binds it). Send gamepad -state with ``update_gamepad()`` and receive rumble/LED via ``on_rumble``. The +state with ``update_xinput_state()`` and receive rumble/LED via ``on_rumble``. The interface uses one interrupt-IN (0x81) + one interrupt-OUT endpoint with separate endpoint numbers, and the report DMA buffers are word-aligned as the ESP32-S3 DWC2 requires. From 394b54f8bae567e5db56fcea12d8b724cde688a2 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 11 Sep 2026 12:34:36 -0500 Subject: [PATCH 25/25] fix(usb_device): require tinyusb >= 0.21 for the 5-arg usbd_edpt_xfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The X-Input driver calls usbd_edpt_xfer(rhport, ep, buf, len, is_isr) — the 5-argument form. That trailing is_isr parameter is present in TinyUSB 0.21 (the version this builds against) but not in 0.19/0.20, whose usbd_edpt_xfer takes 4 arguments. The manifest floor of >=0.19.0 therefore permitted a managed dependency resolution that fails to compile; raise it to >=0.21.0. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/usb_device/idf_component.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/components/usb_device/idf_component.yml b/components/usb_device/idf_component.yml index a75b5ee984..7d952a4ab8 100644 --- a/components/usb_device/idf_component.yml +++ b/components/usb_device/idf_component.yml @@ -27,9 +27,11 @@ dependencies: 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. + # 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.19.0' + version: '>=0.21.0' public: true