SInput Mode

August 30, 2026 · View on GitHub

SInput is a community protocol for BLE gamepads that provides native recognition by SDL3 and Steam. The ESP32 presents with a fixed VID/PID and a standardized report layout that SDL's hidapi driver understands natively, turning it into a full SDL_GameController with rumble, player LED, RGB, IMU, and touchpad support.

Overview

SInput was created by HandHeldLegend to solve the problem of BLE gamepads not being recognized by games without per-device driver support. Instead of each device needing its own kernel driver or SDL driver, SInput defines a fixed report layout that a single SDL driver handles.

Use SInput mode when:

  • You're building a gamepad for use with SDL3 games or Steam
  • You want native SDL_GameController recognition without custom drivers
  • You need rumble, player LED, RGB, IMU, or touchpad support
  • You're targeting Linux, Windows, or macOS with SDL3

References

Protocol

SInput uses three HID Report IDs, all within the standard HID-over-GATT transport:

Report IDDirectionPurpose
0x01Device -> HostRegular gamepad input state
0x02Device -> HostCommand/feature response (reuses Input report type)
0x03Host -> DeviceOutput commands (haptic, features, LED, RGB)

Fixed VID/PID

SInput mode automatically sets VID to 0x2E8A and PID to 0x10C6. The host OS identifies Bluetooth devices by their VID/PID and loads the appropriate driver. SDL's SInput hidapi driver hardcodes this exact VID/PID pair in its allowlist — if you change it, SDL won't recognize the device and you'll lose native SDL_GameController support.

Do not call setVid()/setPid() after setGamepadMode(GamepadMode::SInput) — the VID/PID is intentionally fixed. If you've previously paired with a different VID/PID, remove the old bond first (bluetoothctl remove <address>).

Input Report 0x01 -- Gamepad State

63-byte payload (Report ID stripped by the BLE HID stack). Sent at the configured polling rate.

Byte Layout

OffsetSizeFieldNotes
0uint8Plug status0=unknown, 1=no battery, 2=charging, 3=charged, 4=on battery
1uint8Charge level0-100%
2uint8Buttons 0South, East, West, North, DUp, DDown, DLeft, DRight
3uint8Buttons 1StickL, StickR, LShoulder, RShoulder, LTrigger, RTrigger, LPaddle1, RPaddle1
4uint8Buttons 2Start, Back, Guide, Capture, LPaddle2, RPaddle2, TouchpadL, TouchpadR
5uint8Buttons 3Power, Misc1-7
6-7int16Left Stick X-32768..32767
8-9int16Left Stick Y-32768..32767
10-11int16Right Stick X-32768..32767
12-13int16Right Stick Y-32768..32767
14-15int16Left Trigger-32768..32767
16-17int16Right Trigger-32768..32767
18-21uint32IMU TimestampMicroseconds since boot
22-23int16Accel X+/-8g (configurable range)
24-25int16Accel Y
26-27int16Accel Z
28-29int16Gyro X+/-2000dps (configurable range)
30-31int16Gyro Y
32-33int16Gyro Z
34uint8(reserved)
35-36int16Touch 1 X-32768..32767
37-38int16Touch 1 Y
39-40uint16Touch 1 Pressure0..32767
41-42int16Touch 2 X
43-44int16Touch 2 Y
45-46uint16Touch 2 Pressure
47-62(reserved/serial)MAC address bytes 18-23, zeroed by this library

Button Bitmasks

Buttons 0 (byte 2):

BitButton
0South (A/Cross)
1East (B/Circle)
2West (X/Square)
3North (Y/Triangle)
4D-pad Up
5D-pad Down
6D-pad Left
7D-pad Right

Buttons 1 (byte 3):

BitButton
0Left Stick Click
1Right Stick Click
2Left Bumper
3Right Bumper
4Left Trigger (digital)
5Right Trigger (digital)
6Left Paddle 1
7Right Paddle 1

Buttons 2 (byte 4):

BitButton
0Start
1Back
2Guide/Home
3Capture/Share
4Left Paddle 2
5Right Paddle 2
6Touchpad 1 (click)
7Touchpad 2 (click)

Buttons 3 (byte 5):

BitButton
0Power
1Misc 1
2Misc 2
3Misc 3
4Misc 4
5Misc 5
6Misc 6
7Misc 7

Feature Response Report 0x02

Sent in response to a "get features" command (0x02 on Output Report 0x03). Describes device capabilities.

Byte Layout

OffsetSizeFieldNotes
0uint8Command echo0x02 (features command ID)
1-2uint16Protocol versionLE, currently 1
3uint8Capabilities 0See bitmask below
4uint8Capabilities 1See bitmask below
5uint8Gamepad typeSDL_GamepadType enum value
6uint8Face styleBits 7-5: face style, Bits 4-0: sub-product
7-8uint16Polling rate (us)LE, microseconds between reports
9-10uint16Accel rangeLE, +/- g (e.g. 8)
11-12uint16Gyro rangeLE, +/- dps (e.g. 2000)
13uint8Usage mask 0Button usage bits 0-7
14uint8Usage mask 1Button usage bits 8-15
15uint8Usage mask 2Button usage bits 16-23
16uint8Usage mask 3Button usage bits 24-31
17uint8Touchpad count0, 1, or 2
18uint8Touchpad finger count1 or 2 per touchpad

Capability Bitmask 0 (byte 3)

BitCapability
0Rumble
1Player LED
2Accelerometer
3Gyroscope
4Left analog stick
5Right analog stick
6Left analog trigger
7Right analog trigger

Capability Bitmask 1 (byte 4)

BitCapability
0Touchpad
1Joystick RGB
2Handheld mode

Gamepad Type Values

ValueType
0Unknown
1Standard
2Xbox 360
3Xbox One
4PS3
5PS4
6PS5
7Nintendo Pro
8Joy-Con Left
9Joy-Con Right
10Joy-Con Pair
11GameCube
12Steam

Output Report 0x03 -- Host Commands

47-byte payload from host to device. Byte 0 is the command ID.

Command Table

CommandValuePayloadDescription
Haptic0x01See belowRumble/vibration
Features0x02(empty)Request feature response on next input report
Player LED0x03uint8 indexSet player LED (1-based, 0 = off)
Joystick RGB0x04R, G, B (3 bytes)Set RGB LED color

Haptic Command (Type 2 = ERM Simulation)

OffsetSizeField
0uint8Command ID (0x01)
1uint8Type (0x02 = ERM)
2uint8Left motor amplitude (0-255)
3uint8Left motor brake (0/1)
4uint8Right motor amplitude (0-255)
5uint8Right motor brake (0/1)

Configuration

Enabling SInput Mode

BleGamepadConfiguration config;
config.setGamepadMode(GamepadMode::SInput);

This automatically:

  • Sets VID/PID to 0x2E8A/0x10C6
  • Sets button count to 25 (all SInput buttons)
  • Sets hat switch count to 1
  • Enables rumble
  • Disables output/feature reports (SInput owns those Report IDs)
  • Enables touchpad (1 pad, 2 fingers)

Configuration Options

OptionDefault (SInput)Description
setEnableRumble()trueEnable haptic/rumble reception
setEnableSInputIMU()falseEnable gyroscope + accelerometer
setEnableSInputRGB()falseEnable RGB LED command reception
setEnableTouchpad()trueEnable touchpad data in reports
setTouchpadCount()1Number of touchpads (0-2)
setTouchpadFingerCount()2Fingers per touchpad (1-2)
setSInputGamepadType()1 (Standard)SDL gamepad type hint
setSInputFaceStyle()1 (ABXY)Face button layout hint
setButtonCount()25Number of buttons (face, shoulders, stick clicks, triggers, paddles, capture, touchpad clicks, power, misc)
setHatSwitchCount()1D-pad

API Reference

Input Methods

All standard BleGamepad input methods work in SInput mode. The library maps them to SInput's fixed byte positions:

MethodSInput Mapping
press(BUTTON_1..4)South/East/West/North (buttons_0 bits 0-3)
press(BUTTON_5..6)Left/Right Shoulder (buttons_1 bits 2-3)
press(BUTTON_7..8)Left/Right Stick Click (buttons_1 bits 0-1)
press(BUTTON_9..10)Left/Right Trigger digital (buttons_1 bits 4-5)
press(BUTTON_11..12)Left/Right Paddle 1 (buttons_1 bits 6-7)
press(BUTTON_13)Capture/Share (buttons_2 bit 3)
press(BUTTON_14..15)Left/Right Paddle 2 (buttons_2 bits 4-5)
press(BUTTON_16..17)Touchpad 1/2 Click (buttons_2 bits 6-7)
press(BUTTON_18)Power (buttons_3 bit 0)
press(BUTTON_19..25)Misc 1-7 (buttons_3 bits 1-7)
setLeftThumb(x, y)Left stick X/Y
setRightThumb(z, rz)Right stick X/Y
setLeftTrigger(rx)Left trigger
setRightTrigger(ry)Right trigger
setHat1(val)D-pad
pressStart()Start button
pressBack() / pressSelect()Back button
pressHome()Guide button
setGyroscope(gX, gY, gZ)Gyro data (when IMU enabled)
setAccelerometer(aX, aY, aZ)Accel data (when IMU enabled)
setTouchpad(pad, x, y, pressure)Touchpad data (when touchpad enabled)

Output/Feedback Methods

MethodDescription
isRumbleReceived()Check if haptic command arrived
getRumbleLeftAmplitude()Left motor amplitude (0-255)
getRumbleRightAmplitude()Right motor amplitude (0-255)
isPlayerLedReceived()Check if player LED command arrived
getPlayerLedIndex()Player LED index (1-based, 0 = off)
isRgbReceived()Check if RGB command arrived
getRgbRed() / getRgbGreen() / getRgbBlue()RGB color values

Touchpad API

void setTouchpad(uint8_t pad, int16_t x, int16_t y, uint16_t pressure);
  • pad: 0 = left/touch1, 1 = right/touch2
  • x, y: Signed 16-bit (-32768..32767). SDL normalizes to 0.0-1.0.
  • pressure: Unsigned (0..32767). SDL normalizes to 0.0-1.0.

Touchpad

SInput supports up to 2 touchpads, each with 1 finger (or 1 touchpad with 2 fingers).

Coordinate System

  • X: -32768 (left) to 32767 (right)
  • Y: -32768 (top) to 32767 (bottom)
  • Pressure: 0 (no touch) to 32767 (maximum)

SDL Normalization

SDL3 converts raw values to normalized coordinates:

  • normalized_x = raw_x / 65536.0 + 0.5
  • normalized_y = raw_y / 65536.0 + 0.5
  • normalized_pressure = raw_pressure / 32768.0

Configuration

config.setEnableTouchpad(true);       // default true in SInput mode
config.setTouchpadCount(1);           // 0, 1, or 2 touchpads
config.setTouchpadFingerCount(2);     // fingers per touchpad (1 or 2)

IMU (Gyroscope + Accelerometer)

When enabled, IMU data is packed into the input report at bytes 18-33.

Timestamp

A 32-bit microsecond timestamp at bytes 18-21. SDL uses this for sensor fusion. The library auto-generates this from millis() * 1000.

Scaling

SensorDefault RangeScale Factor
Accelerometer+/-8g9.81 / (32768 / range) m/s^2
Gyroscope+/-2000 dps(pi/180) / (32768 / range) rad/s

Configuration

config.setEnableSInputIMU(true);

API

bleGamepad.setGyroscope(gX, gY, gZ);       // int16, -32768..32767
bleGamepad.setAccelerometer(aX, aY, aZ);   // int16, -32768..32767

Player LED

The host sends a player index (1-based) via Output Report 0x03, command 0x03.

if (bleGamepad.isPlayerLedReceived()) {
    uint8_t playerIndex = bleGamepad.getPlayerLedIndex(); // 1 = Player 1, 0 = off
    digitalWrite(LED_BUILTIN, playerIndex == 1 ? HIGH : LOW);
}

RGB LED

The host sends an RGB color via Output Report 0x03, command 0x04.

if (bleGamepad.isRgbReceived()) {
    uint8_t r = bleGamepad.getRgbRed();
    uint8_t g = bleGamepad.getRgbGreen();
    uint8_t b = bleGamepad.getRgbBlue();
    // Drive your RGB LED here
}

Rumble / Haptics

The host sends ERM-style rumble via Output Report 0x03, command 0x01, type 2.

if (bleGamepad.isRumbleReceived()) {
    uint8_t weakMotor = bleGamepad.getRumbleLeftAmplitude();    // 0-255
    uint8_t strongMotor = bleGamepad.getRumbleRightAmplitude(); // 0-255
    analogWrite(WEAK_MOTOR_PIN, weakMotor);
    analogWrite(STRONG_MOTOR_PIN, strongMotor);
}

Battery / Power State

SInput has its own power state reporting in the input report (bytes 0-1), independent of the standard BLE Battery Service.

Plug StatusValueMeaning
Unknown0Not reported
No Battery1External power, no battery
Charging2Charging
Charged3Charge complete
On Battery4Running on battery
bleGamepad.setBatteryPowerInformation(POWER_STATE_PRESENT);
bleGamepad.setDischargingState(POWER_STATE_DISCHARGING);
bleGamepad.setBatteryLevel(75); // also updates standard Battery Service

SDL3 Integration

Requirements

  • SDL 3.4.x or newer (SInput driver landed via PR #13343)
  • SDL_JOYSTICK_HIDAPI_SINPUT=1 environment variable (should be on by default)

Building SDL3 from Source (if needed)

git clone --branch release-3.4.14 --depth 1 https://github.com/libsdl-org/SDL.git ~/src/SDL3
cmake -S ~/src/SDL3 -B ~/src/SDL3/build -DCMAKE_BUILD_TYPE=Release
cmake --build ~/src/SDL3/build -j$(nproc)
sudo cmake --install ~/src/SDL3/build
sudo ldconfig

Testing

See examples/SInput/SInputPlayerLED/host_test/SDL3Testing.md for a complete testing walkthrough with a ready-to-run SDL3 test program.

Linux Pairing

bluetoothctl
scan on
# find the ESP32 (name "ESP32 BLE Gamepad", VID 0x2E8A)
pair <MAC>
trust <MAC>
connect <MAC>

If re-pairing after a firmware change, remove the old bond first:

bluetoothctl remove <MAC>

If pairing fails with AuthenticationFailed, fully erase the ESP32's flash before reflashing:

esptool.py --port <port> erase_flash

macOS Compatibility

How It Works on macOS

macOS recognizes SInput mode as a Bluetooth HID gamepad via HID-over-GATT (HOGP). Since SInput uses standard HID Input/Output/Feature Reports, macOS handles it like any other BLE gamepad. The device appears in System Settings > Bluetooth and is accessible via Apple's GCController framework.

Pairing

  1. Open System Settings > Bluetooth
  2. Put the ESP32 into pairing mode (call begin() with GamepadMode::SInput)
  3. Click "Connect" next to the device name
  4. The device appears as a gamepad in any app that supports controllers

HID API Access (hidapi)

On macOS, the hidapi library uses IOHIDManager as its backend. Feature Reports are accessible:

import hid

dev = hid.Device(path="/dev/hidraw0")
# Feature Report 1: capability query
caps = dev.get_feature_report(1, 64)
print("Capabilities:", list(caps))

# Feature Report 5: haptic/rumble (63 bytes)
rumble = bytes([5] + [0x00]*62)
dev.send_feature_report(rumble)
dev.close()

Note: macOS's hidapi backend strips the leading Report ID byte from get_feature_report() return values (unlike Linux which includes it). Handle both if writing cross-platform code:

data = list(result)[1:] if list(result)[:1] == [REPORT_ID] else list(result)

GameController Framework (Swift/Objective-C)

The device is accessible via GCController:

import GameController

NotificationCenter.default.addObserver(
    forName: .GCControllerDidConnect, object: nil, queue: nil
) { notification in
    if let controller = notification.object as? GCController {
        print("Connected: \(controller.vendorName ?? "Unknown")")
        // Map buttons, axes, triggers, etc.
    }
}

SDL3 Integration on macOS

SDL3's SInput driver works on macOS. Build SDL3 from source:

git clone --branch release-3.4.14 --depth 1 https://github.com/libsdl-org/SDL.git ~/src/SDL3
cmake -S ~/src/SDL3 -B ~/src/SDL3/build -DCMAKE_BUILD_TYPE=Release
cmake --build ~/src/SDL3/build -j$(sysctl -n hw.ncpu)
sudo cmake --install ~/src/SDL3/build

Battery Level

Battery level is not automatically surfaced to macOS system tools. You must read it via the Battery Service characteristic using hidapi or equivalent.

Troubleshooting

  • Device doesn't appear in Bluetooth settings: Confirm sketch is running begin() with GamepadMode::SInput, wait for BLE scan
  • hid.enumerate() doesn't find it: Use blueutil --info XX:XX:XX:XX:XX:XX to confirm pairing
  • Controller detected but buttons/axes wrong: Check your BleGamepadConfiguration matches what the game expects
  • Feature Report reads return wrong size: macOS strips the leading Report ID byte (see note above)
  • Connection drops intermittently: Ensure the ESP32 has adequate power; BLE on macOS can be sensitive to signal strength

Linux Testing (Quick Reference)

For detailed Linux testing see LinuxHIDTesting.md. Key commands:

# Pair
bluetoothctl
agent NoInputNoOutput
default-agent
scan on
pair XX:XX:XX:XX:XX:XX
trust XX:XX:XX:XX:XX:XX
connect XX:XX:XX:XX:XX:XX

# Test input
jstest --normal /dev/input/js0
evtest /dev/input/event0

# Feature Reports (rumble)
sudo .venv/bin/python -c "
import hid
dev = hid.Device(path='/dev/hidraw0')
rumble = bytes([5] + [0x00]*62)
dev.send_feature_report(rumble)
dev.close()
"

# Monitor
sudo btmon -i hci0

# Battery
upower -e | grep gaming_input

Steam Integration

How Steam Recognizes SInput

Steam ships with SDL3 and includes the SInput HIDAPI driver. When your ESP32 connects in SInput mode, Steam automatically:

  1. Detects the device via its VID/PID (0x2E8A:0x10C6)
  2. Loads the SInput driver
  3. Maps it as a SDL_GameController
  4. Enables rumble, player LED, IMU, and touchpad passthrough

No configuration is needed. Steam Input handles the mapping for all games.

Steam Input Configuration

In Steam's controller settings (Settings > Controller):

  • The device appears as "Steam Controller" or "ESP32 BLE Gamepad"
  • Button mapping is automatic for most games
  • Rumble is forwarded to the ESP32's haptic motors
  • IMU data is available to games that support gyro aiming
  • Touchpad can be used as a mouse or for custom mappings

Per-Game Configuration

For games that need custom mappings:

  1. Right-click the game in Steam Library > Properties > Controller
  2. Override the per-game setting to "Enable Steam Input"
  3. Use the controller configurator to remap buttons/axes

Troubleshooting in Steam

  • Device not recognized: Ensure you're running SInput mode (GamepadMode::SInput), not Generic
  • Rumble not working: Check setEnableRumble(true) in your sketch
  • IMU not detected: Some games need "Enable Gyro" in Steam Input settings
  • Steam shows "Generic Controller": Remove the device from Steam's controller list and re-pair

Examples

See the examples/SInput/ directory:

  • SInputPlayerLED.ino -- Rumble + player LED + battery ramp
  • SInputRGB.ino -- RGB LED via discrete PWM pins
  • SInputRGB_NeoPixel.ino -- RGB LED via WS2812/NeoPixel strip
  • SInputFullGamepad.ino -- All inputs (25 buttons, 2 sticks, 2 triggers, D-pad, Start/Back/Home)
  • SInputIMU.ino -- Gyroscope + accelerometer with simulated data
  • SInputTouchpad.ino -- Dual touchpad with simulated circular touch

Features & Limitations

What works

  • Native SDL3 SDL_GameController recognition
  • Buttons, sticks, triggers, D-pad
  • Gyroscope and accelerometer
  • Dual touchpad (1-2 pads, 1-2 fingers)
  • ERM-style rumble/vibration
  • Player LED (1-based index)
  • RGB LED (24-bit color)
  • Battery/power state reporting
  • Works on Linux, Windows, macOS with SDL3 3.4+

Limitations

  • Fixed VID/PID (0x2E8A/0x10C6) -- cannot be customized
  • Fixed report layout -- not configurable like Generic mode
  • Up to 25 regular buttons (face, shoulders, stick clicks, trigger digital, paddles, capture, touchpad clicks, power, misc) plus Start/Back/Guide and D-pad
  • Only 1 hat switch
  • No sliders or simulation controls
  • No iOS support
  • Requires SDL 3.4.x+ -- older SDL versions won't recognize the device
  • Rumble is ERM-style only (no HD/rumble2 support)
  • No per-gamepad calibration in the protocol

Comparison with SINPUT-LIB-HID

The reference SINPUT-LIB-HID library provides the same protocol in a portable C library for firmware authors. Key differences:

AspectThis LibrarySINPUT-LIB-HID
PlatformESP32 + NimBLEAny MCU, any RTOS
IntegrationArduino libraryCMake static library
TouchpadYes (implemented)Hook-based (you implement)
IMUYes (implemented)Hook-based
RumbleERM type 2Both HD and ERM hooks
RGBYes (implemented)Hook-based
Player LEDYes (implemented)Hook-based

Both produce identical wire-format reports. The choice depends on your platform and whether you want Arduino convenience or bare-metal portability.