# Implementing USB Joystick HID for Game Controller Input in ESP32 Sim-Racing Pedals

> Learn to implement USB joystick HID for game controller input on ESP32. Explore library options, HID report descriptors, force data normalization, and host reconnection for DIY sim-racing pedals.

- Repository: [chrgri/diy-sim-racing-ffb-pedal](https://github.com/chrgri/diy-sim-racing-ffb-pedal)
- Tags: how-to-guide
- Published: 2026-02-27

---

**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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/TinyusbJoystick.h) (lines 7-25) and declares a six-axis joystick with 16-bit resolution for each axis.

```cpp
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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/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.

```cpp
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.

```cpp
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:

```cpp
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_JOYSTICK` macro in [`Controller.cpp`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/Controller.cpp) to 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 with `setBrakeRange()` and providing adequate enumeration timeout via `WAITTIME_FOR_HOST_TO_RESPOND_TO_HID_REPORT_IN_MS`.
- **Normalize sensor data** using `NormalizeControllerOutputValue()` before mapping to joystick axes with `setBrake()` or `setAccelerator()`.
- **Transmit state changes** through `JoystickSendState()` to update the host only when values change, preventing USB bus saturation.
- **Monitor `_usbDeviceStatus`** and trigger `RestartJoystick()` 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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/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.