Device Config Reference

Device configs are TOML files in devices/<vendor>/<model>.toml.

[device]

FieldTypeRequiredDescription
namestringyesHuman-readable device name
vidintegeryesUSB vendor ID (hex literal ok: 0x054c)
pidintegeryesUSB product ID
modestringnoDevice mode identifier
block_kernel_driversstring[]noKernel driver names to unbind via udev at install time, e.g. block_kernel_drivers = ["xpad"]. When padctl install runs as root, it also walks /sys/bus/usb/drivers/<driver>/unbind for matching VID:PID pairs immediately.

[[device.interface]]

FieldTypeRequiredDescription
idintegeryesUSB interface number
classstringyes"hid" or "vendor"
ep_inintegernoIN endpoint number
ep_outintegernoOUT endpoint number

[device.init]

Optional initialization sequence sent after device open.

FieldTypeRequiredDescription
commandsstring[]yesHex byte strings sent in order
response_prefixinteger[]yesExpected response prefix bytes
enablestringnoHex byte string sent to activate extended mode (e.g. BT mode switch)
disablestringnoHex byte string sent on shutdown
interfaceintegernoInterface to send init commands on
report_sizeintegernoExpected report size after init

[[report]]

Describes one incoming HID report.

FieldTypeRequiredDescription
namestringyesReport name (unique within device)
interfaceintegeryesWhich interface this report arrives on
sizeintegeryesReport byte length

[report.match]

Disambiguates reports when multiple share an interface.

FieldTypeDescription
offsetintegerByte position to inspect
expectinteger[]Expected bytes at that offset

[report.fields]

Inline table mapping field names to their layout:

[report.fields]
left_x = { offset = 1, type = "u8", transform = "scale(-32768, 32767)" }
gyro_x = { offset = 16, type = "i16le" }
battery_level = { bits = [53, 0, 4] }
FieldTypeDescription
offsetintegerByte offset in report
typestringData type (see below)
bitsinteger[3]Sub-byte extraction: [byte_offset, bit_offset, bit_count]
transformstringComma-separated transform chain

Use offset + type for whole-byte fields. Use bits for sub-byte bit extraction (e.g. a 4-bit battery level packed within a byte).

Note: When using bits, the type field must be null, "unsigned", or "signed" — standard type strings like "u8" or "i16le" are not valid.

Data Types

u8 i8 u16le i16le u16be i16be u32le i32le u32be i32be

Transform DSL

Transforms are applied left-to-right as a comma-separated chain:

TransformDescription
scale(min, max)Linearly scale the raw value to the target range
negateNegate the value (multiply by -1)
absTake the absolute value
clampClamp to the output axis range
deadzoneApply deadzone filtering

Example: transform = "scale(-32768, 32767), negate" — scales a u8 (0–255) to -32768..32767, then negates the result.

[report.button_group]

Maps a contiguous byte range to named buttons via bit index.

[report.button_group]
source = { offset = 8, size = 3 }
map = { A = 5, B = 6, X = 4, Y = 7, LB = 8, RB = 9 }
FieldTypeDescription
source.offsetintegerStarting byte offset within the report
source.sizeintegerGroup width in bytes; must be 1..=8 (the interpreter packs the group into a u64; values above 8 are skipped at parse time with a warning logged to stderr and the report group falls back to all buttons unmapped)
maptableButton = bit_index. Bit indexes must satisfy 0 <= bit_index < size * 8.

Button names must be valid ButtonId values:

A B X Y LB RB LT RT Start Select Home Capture LS RS DPadUp DPadDown DPadLeft DPadRight M1 M2 M3 M4 Paddle1 Paddle2 Paddle3 Paddle4 TouchPad Mic C Z LM RM O

[report.checksum]

Optional integrity check on the report.

FieldTypeDescription
algostringcrc32 sum8 xor
rangeinteger[2][start, end] byte range to checksum
seedintegerInitial seed value prepended to CRC calculation (e.g. 0xa1 for DualSense BT)
expect.offsetintegerWhere the checksum is stored in the report
expect.typestringStorage type of the checksum field

[commands.<name>]

Output command templates (rumble, LED, adaptive triggers, etc.). Template placeholders use {name:type} syntax.

[commands.rumble]
interface = 3
template = "02 01 00 {weak:u8} {strong:u8} 00 ..."

Adaptive Trigger Commands

DualSense-style adaptive triggers use a naming convention of adaptive_trigger_<mode>:

[commands.adaptive_trigger_off]
interface = 3
template = "02 0c 00 ..."

[commands.adaptive_trigger_feedback]
interface = 3
template = "02 0c 00 ... 01 {r_position:u8} {r_strength:u8} ... 01 {l_position:u8} {l_strength:u8} ..."

[commands.adaptive_trigger_weapon]
interface = 3
template = "02 0c 00 ... 02 {r_start:u8} {r_end:u8} {r_strength:u8} ... 02 {l_start:u8} {l_end:u8} {l_strength:u8} ..."

[commands.adaptive_trigger_vibration]
interface = 3
template = "02 0c 00 ... 06 {r_position:u8} {r_amplitude:u8} {r_frequency:u8} ... 06 {l_position:u8} {l_amplitude:u8} {l_frequency:u8} ..."

[output]

Declares the uinput device emitted by padctl.

FieldTypeDescription
emulatestringPreset emulation profile
namestringuinput device name
vidintegerEmulated vendor ID
pidintegerEmulated product ID

[output.axes]

[output.axes]
left_x = { code = "ABS_X", min = -32768, max = 32767, fuzz = 16, flat = 128 }

[output.buttons]

[output.buttons]
A = "BTN_SOUTH"
B = "BTN_EAST"

[output.dpad]

[output.dpad]
type = "hat"   # or "buttons"

[output.force_feedback]

Two backends are supported: legacy rumble via uinput (default), and HID PID passthrough via UHID for devices whose firmware speaks the USB HID PID class spec directly (most racing wheels).

Rumble (uinput, default)

[output.force_feedback]
type = "rumble"
max_effects = 16
auto_stop = true     # default; set false to disable userspace auto-stop
FieldTypeDefaultDescription
typestring"rumble"Force-feedback type. "rumble" is the only legacy value.
max_effectsint16Maximum number of concurrent FF effect slots
auto_stopbooltrueEnable userspace rumble auto-stop. When true, padctl emits a stop frame to the HID device after each effect's replay.length elapses — compensating for the fact that uinput does not use the kernel's ff-memless auto-stop timer. Set to false only for devices whose firmware handles auto-stop internally.
backendstring"uinput""uinput" (rumble) or "uhid" (PID passthrough — see below).
kindstring"rumble""rumble" or "pid".

HID PID passthrough (UHID, racing wheels)

For devices that already implement HID PID effects in firmware (constant force, spring, damper, friction, sine periodic, etc.), padctl can publish a UHID node with the device's own PID descriptor and forward UHID_OUTPUT events back to the physical wheel. The kernel's hid-pidff driver then exposes the standard evdev FF interface to games and SDL — no userspace effect synthesis.

Phase 13 Wave 6 introduced this path; closes issue #82 (Moza, Logitech G-series, Thrustmaster T-series, Fanatec ClubSport).

[output.force_feedback]
backend       = "uhid"
kind          = "pid"
clone_vid_pid = true   # publish the UHID node with the wheel's real VID/PID
FieldTypeDefaultDescription
backendstring"uinput"Set to "uhid" for PID passthrough.
kindstring"rumble"Set to "pid" for PID passthrough.
clone_vid_pidboolfalseWhen true, the emitted UHID node uses [device].vid / [device].pid so games and hid-pidff recognize the wheel by its real identifiers. Requires non-zero VID and PID in [device].

Validation matrix — the parser rejects illegal combinations at config load:

backendkindResult
"uinput""rumble"OK (default; legacy uinput rumble)
"uinput""pid"rejected
"uhid""rumble"rejected
"uhid""pid"OK — also requires [output.imu] to be declared (UHID routing gate)

clone_vid_pid = true requires [device].vid and [device].pid to be non-zero.

Kernel requirement: the hid-pidff driver must be loaded, and the hid-universal-pidff quirk module is recommended for non-Logitech wheels. See the Bazzite / Immutable Distros guide for distro-specific notes.

[output.aux]

Auxiliary output device (mouse or keyboard).

FieldTypeDescription
typestring"mouse" or "keyboard"
namestringuinput device name
keyboardboolCreate keyboard capability
buttonstableButton-to-event mapping

[output.touchpad]

Touchpad output device.

FieldTypeDescription
namestringuinput device name
x_min / x_maxintegerX axis range
y_min / y_maxintegerY axis range
max_slotsintegerMaximum multitouch slots

[output.imu]

IMU (accelerometer + gyroscope) output via a separate UHID node. When declared, padctl creates a second UHID device that shares the same uniq as the primary gamepad output, enabling SDL3 to pair the IMU sensor with the controller automatically (ADR-015 UHID IMU migration; see PR #159).

Validation rule: when [output.imu] is present, backend must be "uhid". The validator rejects "uinput" per ADR-015 — UHID is the only supported backend for IMU output. Omitting [output.imu] entirely keeps the legacy uinput-primary path.

Distinction from [output.aux]: [output.imu] is the gamepad's accelerometer/gyroscope UHID node; [output.aux] is a secondary uinput device for mouse/keyboard remapping. They serve different purposes and can coexist.

See ADR-015 for the design rationale. This section enables SDL3-visible sensor pairing on Steam games.

FieldTypeRequiredDefaultDescription
backendstringno"uhid"Must be "uhid"; only legal value (validator rejects "uinput")
namestringnoUHID device name shown to userspace
vidintegernoinherits from [device].vidEmulated vendor ID
pidintegernoinherits from [device].pidEmulated product ID
accel_rangeint[2]no[-32768, 32767]Accelerometer output range [min, max]
gyro_rangeint[2]no[-32768, 32767]Gyroscope output range [min, max]

Example:

[output.imu]
backend = "uhid"
name = "vader5_imu"
vid = 0x11ff
pid = 0x1211
accel_range = [-16384, 16384]
gyro_range = [-32768, 32767]

[wasm]

WASM plugin for stateful/custom protocols (Phase 4+).

FieldTypeDescription
pluginstringPath to .wasm plugin file

[wasm.overrides]

FieldTypeDescription
process_reportboolPlugin handles report processing