Implementing USB Joystick HID for Game Controller Input in ESP32 Sim-Racing Pedals
Implementing USB joystick HID for game controller input requires selecting between the Joystick_ESP32S2 library or a custom TinyUSB class, defining a six-axis HID report descriptor, normalizing pedal force data, and managing host enumeration with timeout and reconnection logic.
The diy-sim-racing-ffb-pedal project demonstrates a complete firmware stack for converting ESP32-S2/S3 microcontrollers into standard USB game controllers. By implementing USB joystick HID for game controller input, the device appears as a native joystick to PC racing simulators, transmitting six-axis data derived from high-resolution pedal force sensors. The architecture supports both USB and Bluetooth Low Energy transports through a unified abstraction layer.
Selecting the USB HID Implementation Strategy
The firmware supports two distinct hardware abstraction paths controlled by preprocessor macros in Controller.cpp. The USB_JOYSTICK macro selects the USB implementation, while BLUETOOTH_GAMEPAD enables a BLE gamepad fallback.
- Joystick_ESP32S2 Library Path: A ready-made Arduino library that handles HID descriptor generation and USB enumeration automatically. This path minimizes code complexity but offers less control over the report structure.
- Custom TinyUSB Path: A lightweight implementation using Espressif’s TinyUSB stack directly, defined in
ESP32_master/include/TinyusbJoystick.h. This allows custom PID/VID configuration and precise control over the six-axis report layout.
The build configuration in Firmware_for_V3/PedalFirmware/platformio.ini pulls in the necessary dependencies, including the Joystick_ESP32S2 library when enabled.
Defining the HID Report Descriptor
The HID report descriptor tells the host operating system how to interpret incoming data packets. In the custom TinyUSB implementation, the descriptor resides in TinyusbJoystick.h (lines 7-25) and declares a six-axis joystick with 16-bit resolution for each axis.
uint8_t const desc_hid_report[] = {
0x05,0x01, // Usage Page (Generic Desktop)
0x09,0x04, // Usage (Joystick)
0xA1,0x01, // Collection (Application)
0x09,0x30,0x09,0x31,0x09,0x32, // X, Y, Z axes
0x09,0x33,0x09,0x34,0x09,0x35, // Rx, Ry, Rz axes
0x15,0x00,0x26,0xFF,0xFF, // Logical min 0, max 65535
0x75,0x10,0x95,0x06,0x81,0x02, // 16-bit, 6 fields, Input
0xC0
};
This descriptor maps to the generic Joystick usage page (0x04), ensuring immediate recognition by Windows, Linux, and macOS without custom drivers.
Initializing the USB Joystick Device
Device initialization occurs in SetupController() within Firmware_for_V3/PedalFirmware/src/Controller.cpp (lines 29-33). The code configures axis ranges, optionally sets a custom USB product name, and begins enumeration with a host timeout.
Joystick.setBrakeRange(JOYSTICK_MIN_VALUE, JOYSTICK_MAX_VALUE);
delay(100);
Joystick.begin(false, WAITTIME_FOR_HOST_TO_RESPOND_TO_HID_REPORT_IN_MS);
For custom TinyUSB implementations, SetupController_USB() (lines 71-80) performs equivalent initialization using usb_hid.begin(). The WAITTIME_FOR_HOST_TO_RESPOND_TO_HID_REPORT_IN_MS constant ensures the host has adequate time to complete HID report descriptor parsing before the firmware proceeds to the main loop.
Mapping Pedal Data to Joystick Axes
Raw force feedback data from load cells requires normalization before transmission. The SetControllerOutputValue() function (lines 83-89) scales raw sensor values to the joystick’s logical range using NormalizeControllerOutputValue(), then maps them to specific axes.
int32_t norm = NormalizeControllerOutputValue(rawForce, 0, 10000, 100);
if (norm != previousTransmittedControllerValue_u32) {
Joystick.setBrake(norm); // map to HID brake axis
newControllerValueReceived_b = true;
}
The firmware supports dual-axis configurations through SetControllerOutputValue_rudder(), allowing independent brake and accelerator mapping. All axis setters update internal state buffers without immediately transmitting, enabling atomic updates across multiple axes.
Transmitting HID Reports to the Host
State transmission is handled by JoystickSendState() (lines 102-108), which checks the newControllerValueReceived_b flag before calling the hardware-specific send function.
- Library Path: Calls
Joystick.sendState()to transmit the pre-formatted HID report. - TinyUSB Path: Invokes
usb_hid.sendReport(0, &hid_report, sizeof(hid_report))with the raw descriptor-defined structure.
This conditional transmission prevents unnecessary USB traffic when pedal values remain static, optimizing bus bandwidth for high-frequency force feedback updates.
Handling Host Disconnection and Re-enumeration
Robust USB implementation requires handling host sleep, disconnect, and enumeration failures. The firmware monitors _usbDeviceStatus through GetJoystickStatus() to detect communication errors.
When the host connection drops, RestartJoystick() (lines 110-118) performs a clean re-enumeration sequence:
void RestartJoystick() {
Joystick.end(); // detach from USB
delay(1000);
SetupController(); // re-initialise with new PID/name if needed
}
This forced re-enumeration ensures the device reappears correctly after PC sleep cycles or USB topology changes, preventing the "ghost device" issue common in DIY controllers.
Bluetooth Gamepad Fallback
When USB_JOYSTICK is undefined and BLUETOOTH_GAMEPAD is active, the same high-level API (SetControllerOutputValue, JoystickSendState) forwards data to a BleGamepad instance. This abstraction allows the force feedback control loop to remain agnostic to the underlying transport protocol, simplifying maintenance and enabling wireless operation for users lacking available USB ports.
Summary
- Choose between Joystick_ESP32S2 library or custom TinyUSB via the
USB_JOYSTICKmacro inController.cppto balance ease-of-use against configuration flexibility. - Define a six-axis HID report descriptor in
desc_hid_report[]using Generic Desktop (0x01) and Joystick (0x04) usage pages for universal OS compatibility. - Initialize with
SetupController(), setting axis ranges withsetBrakeRange()and providing adequate enumeration timeout viaWAITTIME_FOR_HOST_TO_RESPOND_TO_HID_REPORT_IN_MS. - Normalize sensor data using
NormalizeControllerOutputValue()before mapping to joystick axes withsetBrake()orsetAccelerator(). - Transmit state changes through
JoystickSendState()to update the host only when values change, preventing USB bus saturation. - Monitor
_usbDeviceStatusand triggerRestartJoystick()to force USB re-enumeration when the host connection is lost or unstable.
Frequently Asked Questions
What microcontroller hardware does this implementation target?
The firmware specifically targets the ESP32-S2 and ESP32-S3 microcontrollers, which include native USB OTG peripherals required for USB HID device mode. The ESP32-S2 variant is the primary target for the DIY Sim-Racing FFB Pedal project, though the code remains compatible with S3 variants due to identical USB peripheral architectures.
How does the firmware handle different pedal configurations?
The codebase abstracts transport logic from pedal mechanics through SetControllerOutputValue() for single-axis brake pedals and SetControllerOutputValue_rudder() for dual-axis brake/accelerator setups. Both functions normalize raw force values to 16-bit integers and set the appropriate joystick axes using setBrake() or setAccelerator() before flagging newControllerValueReceived_b for transmission.
What happens when the USB connection drops during gameplay?
The firmware continuously monitors connection status via _usbDeviceStatus in GetJoystickStatus(). Upon detecting a disconnection, the main loop calls RestartJoystick(), which executes Joystick.end() to detach from the bus, waits one second, then re-runs SetupController() to force complete re-enumeration. This process ensures the device reappears as a new joystick instance, clearing any stale handle references in the operating system.
Can the device function as both USB and Bluetooth simultaneously?
No, the build system uses mutually exclusive preprocessor definitions (USB_JOYSTICK vs BLUETOOTH_GAMEPAD) in Controller.cpp to select the transport layer at compile time. The high-level API remains identical between paths—both implement SetControllerOutputValue() and JoystickSendState()—but the underlying hardware initialization and report transmission differ significantly, preventing simultaneous operation.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →