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/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/README.md b/components/usb_device/README.md index e50c574906..be6f276e21 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_xinput_state()` (`include/xinput.hpp`), and rumble/LED reports arrive via an + `on_rumble` callback. Because a PC's XUSB driver only binds a recognized Xbox + 360 VID/PID, and because the built-in vendor class also claims interface class + 0xFF, **use X-Input as the only enabled function** (it then advertises the Xbox + identity + 0xFF/0xFF/0xFF device class so the host recognizes it). See the + [`xinput_example`](xinput_example/). *These are Microsoft's IDs, for emulation / + testing of your own device only.* Interface numbers, endpoint addresses and string indices are allocated *sequentially* as functions are enabled, and the result is checked against the @@ -35,6 +45,8 @@ for back-compatibility. - [Features](#features) - [API](#api) - [Enabling the vendor / WebUSB class](#enabling-the-vendor--webusb-class) + - [Enabling the HID class](#enabling-the-hid-class) + - [Enabling X-Input (Xbox 360)](#enabling-x-input-xbox-360) - [Endpoint budget (ESP32-S3 USB-OTG)](#endpoint-budget-esp32-s3-usb-otg) - [Extending with HID / MSC](#extending-with-hid--msc) - [Example](#example) @@ -126,8 +138,6 @@ The vendor class is gated in `esp_tinyusb` behind a Kconfig option. To use the vendor function, set in your project's `sdkconfig.defaults`: ``` -CONFIG_TINYUSB_CDC_ENABLED=y -CONFIG_TINYUSB_CDC_COUNT=1 CONFIG_TINYUSB_VENDOR_COUNT=1 # THE key enablement: compiles in the vendor class ``` @@ -139,6 +149,18 @@ weak-callback overrides (`tud_descriptor_bos_cb`, `tud_vendor_control_xfer_cb`, `tud_vendor_rx_cb`). If the vendor function is requested but `CFG_TUD_VENDOR == 0`, `initialize()` fails with `std::errc::function_not_supported`. +CDC support is compiled conditionally (`#if CFG_TUD_CDC > 0`), so a vendor-only, +HID-only or X-Input-only build does **not** need CDC enabled. Enable it only when +you use the CDC function: + +``` +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +``` + +(Requesting a CDC function while `CFG_TUD_CDC == 0` fails `initialize()` with +`std::errc::function_not_supported`, matching the vendor/HID checks.) + ## Enabling the HID class Like the vendor class, the HID class is gated in `esp_tinyusb` behind a Kconfig @@ -157,6 +179,29 @@ builds them with the espp `hid-rp` component), assign them to `write_hid_report(report_id, report)`. If the HID function is requested but `CFG_TUD_HID == 0`, `initialize()` fails with `std::errc::function_not_supported`. +## Enabling X-Input (Xbox 360) + +X-Input needs **no** `CFG_TUD_*` count — it is served by a custom TinyUSB +application class driver built into this component (registered via the weak +`usbd_app_driver_get_cb`, forced into the link with `-u`). So an X-Input-only +project needs no CDC/vendor/HID class enabled at all; the +[`xinput_example`](xinput_example/) sdkconfig disables them: + +``` +CONFIG_TINYUSB_CDC_ENABLED=n +CONFIG_TINYUSB_CDC_COUNT=0 +# vendor/HID counts default to 0 — importantly, keep CFG_TUD_VENDOR at 0 so the +# built-in bulk vendor driver does not claim the X-Input 0xFF interface. +``` + +Set `Config::xinput` (only — see the "only enabled function" note above), send +gamepad state with `update_xinput_state(GamepadState)`, and receive rumble/LED reports +via `XInputFunction::on_rumble`. The interface uses one interrupt-IN endpoint +(0x81, 20-byte input reports) and one interrupt-OUT endpoint (rumble/LED); the two +use **separate endpoint numbers**, and the DMA report buffers are word-aligned, as +the ESP32-S3 DWC2 requires. See `include/xinput.hpp` for the report/`GamepadState` +API and the button/axis layout. + ## Endpoint budget (ESP32-S3 USB-OTG) The ESP32-S3 / -S2 USB-OTG core is full-speed and, besides EP0, provides roughly @@ -168,6 +213,7 @@ consumes: | CDC-ACM | 2 (1 interrupt-IN notif + 1 bulk-IN) | 1 (bulk-OUT) | | Vendor / WebUSB | 1 (bulk-IN) | 1 (bulk-OUT) | | HID | 1 (interrupt-IN) | 0 or 1 (optional interrupt-OUT) | +| X-Input (Xbox 360)| 1 (interrupt-IN) | 1 (interrupt-OUT) | | MSC (future) | 1 (bulk-IN) | 1 (bulk-OUT) | This is why the device is **selectable** ("not all at once"). Combinations that 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/idf_component.yml b/components/usb_device/idf_component.yml index dce7685309..7d952a4ab8 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,8 +19,19 @@ tags: - TinyUSB - Serial - Transport + - HID + - XInput + - Gamepad dependencies: idf: version: '>=5.0' espp/base_component: '>=1.0' espressif/esp_tinyusb: '>=2.0' + # The X-Input class driver uses the 5-argument usbd_edpt_xfer(..., is_isr) API + # (and the usbd_class_driver_t `xfer_isr` member). TinyUSB 0.21 provides that + # 5-argument form; earlier releases (e.g. 0.19) still have the 4-argument + # usbd_edpt_xfer, which fails to compile here. esp_tinyusb only requires tinyusb + # >= 0.17, so pin the tested 0.21 floor here. + tinyusb: + version: '>=0.21.0' + public: true diff --git a/components/usb_device/include/usb_device.hpp b/components/usb_device/include/usb_device.hpp index 7ee5483c27..043c72d0ca 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_xinput_state()`) and one interrupt OUT endpoint (8-byte + * rumble / LED reports, delivered to `on_rumble`). Unlike HID it is served by a + * small custom TinyUSB application class driver built into this component (no + * `CFG_TUD_*` count is required). + * + * A PC's XUSB driver only binds a device whose VID/PID is a recognized Xbox 360 + * controller, so `vid` / `pid` default to Microsoft's identifiers + * (`0x045E:0x028E`) -- for emulation / testing of your own device only. When + * the XInput function is the ONLY enabled function these identifiers (and a + * 0xFF/0xFF/0xFF device class) override the top-level Config vid/pid so the + * host recognizes it; combine XInput with other functions only if you do not + * need XUSB to bind (the built-in vendor/WebUSB class also claims class 0xFF). + * + * Consumes 1 interrupt IN + 1 interrupt OUT endpoint. + */ + struct XInputFunction { + std::string interface_name{"espp XInput"}; /**< XInput interface string descriptor. */ + uint16_t vid{espp::xinput::kDefaultVid}; /**< Xbox 360 controller VID (Microsoft). */ + uint16_t pid{espp::xinput::kDefaultPid}; /**< Xbox 360 controller PID. */ + /** @brief Callback invoked with received rumble / LED report bytes (8-byte + * reports on the interrupt OUT endpoint). Runs in the TinyUSB device task. */ + receive_callback_fn on_rumble{nullptr}; + }; + /** * @brief (Future) MSC (mass storage) function extension point. Not implemented yet. * @@ -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_xinput_state(const espp::xinput::GamepadState &state, std::error_code &ec); + + /// @brief Convenience overload of update_xinput_state() that ignores errors. + bool update_xinput_state(const espp::xinput::GamepadState &state); + + /// @brief Whether the XInput function is enabled, mounted and ready to accept a + /// new input report (no report in flight). + bool is_xinput_ready() const; + /// @brief Set or replace the CDC receive callback (nullptr to detach). void set_cdc_receive_callback(const receive_callback_fn &cb); @@ -321,9 +370,17 @@ class UsbDevice : public BaseComponent { /// @brief Whether the vendor function is enabled and the device is mounted. bool is_vendor_connected() const; + /// @brief Opaque bridge letting the TinyUSB C callback trampolines reach the + /// device-task-only methods below (defined in usb_device.cpp). An + /// implementation detail: it is incomplete here, with nothing callable + /// from application code. + struct Callbacks; + +protected: // // 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. @@ -353,6 +410,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 +441,7 @@ class UsbDevice : public BaseComponent { std::mutex cb_mutex_; receive_callback_fn on_cdc_receive_; receive_callback_fn on_vendor_receive_; + receive_callback_fn on_xinput_rumble_; event_callback_fn on_mount_; event_callback_fn on_unmount_; diff --git a/components/usb_device/include/xinput.hpp b/components/usb_device/include/xinput.hpp new file mode 100644 index 0000000000..6c0a8437ce --- /dev/null +++ b/components/usb_device/include/xinput.hpp @@ -0,0 +1,145 @@ +#pragma once + +// Xbox 360 wired controller (X-Input) protocol helpers. +// +// This header is dependency-free and host-testable (no ESP-IDF / TinyUSB +// headers): it defines the X-Input wire constants, a gamepad-state model that +// packs the 20-byte input report, and a builder for the USB interface + XID + +// interrupt-endpoint descriptor bytes. `espp::UsbDevice`'s XInput function +// (usb_device.hpp) consumes these on-device; a host test exercises `report()`. +// +// X-Input is Microsoft's proprietary protocol for the Xbox 360 controller. The +// device presents a vendor-specific interface (bInterfaceClass 0xFF / +// bInterfaceSubClass 0x5D / bInterfaceProtocol 0x01) with one interrupt IN +// endpoint (20-byte input reports) and one interrupt OUT endpoint (8-byte +// rumble / LED reports). A PC's XUSB driver only binds a device whose VID/PID is +// a recognized Xbox 360 controller, so the defaults below are Microsoft's +// (0x045E:0x028E) -- use them only for emulation / testing of your own device. + +#include +#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_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) { + return { + // clang-format off + // Interface descriptor (9 bytes): vendor-specific 0xFF/0x5D/0x01, 2 endpoints. + 0x09, 0x04 /* INTERFACE */, itf_num, 0x00 /* alt */, 0x02 /* num endpoints */, + kInterfaceClass, kInterfaceSubClass, kInterfaceProtocol, str_idx, + // XID "unknown" vendor descriptor (17 bytes), matching the Microsoft wired + // Xbox 360 controller (bLength 0x11, bDescriptorType 0x21). Byte [2] is 0x00 + // on the retail controller and established XInput implementations. [6] = IN + // endpoint address, [7] = IN report size (0x14 = 20), [13] = OUT endpoint + // address, [14] = OUT report size (0x08 = 8). + 0x11, 0x21, 0x00, 0x01, 0x01, 0x25, + ep_in, 0x14, 0x00, 0x00, 0x00, 0x00, 0x13, ep_out, 0x08, 0x00, 0x00, + // Endpoint IN (7 bytes): interrupt, wMaxPacketSize 32, bInterval. + 0x07, 0x05 /* ENDPOINT */, ep_in, 0x03 /* interrupt */, kEpSize, 0x00, in_interval, + // Endpoint OUT (7 bytes): interrupt, wMaxPacketSize 32, bInterval. + 0x07, 0x05 /* ENDPOINT */, ep_out, 0x03 /* interrupt */, kEpSize, 0x00, out_interval, + // clang-format on + }; +} + +} // namespace xinput +} // namespace espp diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index aa9e82ea72..4cf86c5c59 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -2,15 +2,54 @@ #include #include +#include #include +#include "esp_log.h" #include "freertos/FreeRTOS.h" #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. +#include "device/usbd_pvt.h" + +#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 const std::optional &vendor_config(UsbDevice *d) { + return d->vendor_config(); + } +}; +} // namespace espp namespace { @@ -24,8 +63,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 @@ -60,12 +101,162 @@ 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); } +// --- 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 { + // All fields are touched only on the TinyUSB task (open/reset/xfer_cb/log). The + // 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}; + uint8_t ep_in{0}; + uint8_t ep_out{0}; + // 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; + +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. + // 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) + 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)) { + // 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 + 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); + + 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"); + + 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) { + if (stage != CONTROL_STAGE_SETUP) + return true; // DATA / ACK stages: nothing to do + + 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); + + // 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, + 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) + 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, + 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. +// 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_LOGD("espp_xinput", "registering X-Input application class driver"); + *driver_count = 1; + return &s_xinput_class_driver; +} + namespace espp { // Storage for the descriptors that TinyUSB references by pointer for the lifetime @@ -88,6 +279,15 @@ 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_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). + alignas(4) std::array xinput_report{}; }; UsbDevice *UsbDevice::instance() { return s_device; } @@ -97,7 +297,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_) { @@ -108,8 +309,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; } @@ -119,6 +322,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; @@ -128,8 +332,9 @@ 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 extern "C" { @@ -138,7 +343,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) @@ -155,7 +360,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 @@ -166,9 +371,9 @@ 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(); - 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: @@ -178,7 +383,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); @@ -186,7 +391,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); @@ -241,7 +446,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. @@ -374,8 +579,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; } @@ -385,6 +590,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 " @@ -443,7 +656,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 @@ -471,7 +687,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 +706,23 @@ bool UsbDevice::initialize(std::error_code &ec) { impl_->hid_report_desc = config_.hid->report_descriptor; } + 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); + // 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; + } + // --- Endpoint budget check --- if (in_used > kMaxInEndpoints || out_used > kMaxOutEndpoints) { logger_.error("Endpoint budget exceeded: IN={} (max {}), OUT={} (max {})", in_used, @@ -504,6 +739,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 +752,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 +768,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; @@ -534,10 +779,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); @@ -547,6 +794,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 @@ -565,18 +816,25 @@ 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), }; 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 +857,17 @@ 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, impl_->xinput_ep_in, + impl_->xinput_ep_out); + append(d.data(), d.size()); + } }; const uint8_t hid_poll_ms = config_.hid ? config_.hid->poll_interval_ms : 0; @@ -874,6 +1143,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; @@ -890,12 +1160,17 @@ bool UsbDevice::initialize(std::error_code &ec) { return false; } } +#endif 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 +1545,80 @@ 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_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); + return false; + } + // 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) { + // 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; + } + // 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() + // 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(); + 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; + } + return true; +} + +bool UsbDevice::update_xinput_state(const espp::xinput::GamepadState &state) { + std::error_code ec; + return update_xinput_state(state, ec); +} + +bool UsbDevice::is_xinput_ready() const { + if (!initialized_ || !config_.xinput) + return false; + // 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); +} + } // 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..f2716c9bbd --- /dev/null +++ b/components/usb_device/test/xinput_host_test.cpp @@ -0,0 +1,75 @@ +// 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 (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 + +#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, 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 (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] == 0x03 && 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/README.md b/components/usb_device/xinput_example/README.md new file mode 100644 index 0000000000..6388867bec --- /dev/null +++ b/components/usb_device/xinput_example/README.md @@ -0,0 +1,54 @@ +# 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 **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 + +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 # console is on UART0 (USB-UART adapter) +``` + +## 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_xinput_state(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/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..bbaa6290a1 --- /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 UART0; the native +// USB port is reserved for the emulated controller. + +#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_xinput_state(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..8d5343707d --- /dev/null +++ b/components/usb_device/xinput_example/sdkconfig.defaults @@ -0,0 +1,27 @@ +# 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 + +# 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 +# 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 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..2d8fea0926 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_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 / + 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_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 @@ -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_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. + 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 ---------------------------------- @@ -212,3 +242,4 @@ API Reference .. include-build-file:: inc/usb_device.inc .. include-build-file:: inc/usb_cdc.inc +.. include-build-file:: inc/xinput.inc 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 +```