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, e.g. block_kernel_drivers = ["xpad"]. The generated rule only detaches the kernel driver while a padctl daemon is running (its control socket exists), so a stopped daemon leaves the device to the kernel driver. 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[]noHex byte strings sent in order
response_prefixinteger[]noExpected response prefix bytes. It may be omitted when command-prefix correlation alone identifies replies.
response_command_prefix_lenintegernoAlso require the response to match the first N bytes of the command just sent. Before each correlated command, a bounded best-effort drain reduces stale-response risk; this is not a transport transaction barrier. Use this when unsolicited input reports share response_prefix with command replies.
enablestringnoHex byte string sent to activate extended mode (e.g. BT mode switch)
disablestringnoReserved shutdown hex command. It is parsed but the current runtime does not send it yet.
interfaceintegernoInterface to send init commands on
report_sizeintegernoExpected report size after init
require_responseboolnoWhen true, commands or enable and at least one ACK criterion are required; failure to receive a response matching every configured criterion fails init/re-init instead of continuing. Use for devices whose input reports are valid only after an acknowledged mode switch.
feature_reportinteger[]noHID feature report sent via HIDIOCSFEATURE immediately after commands. Encoded as a list of byte values (0–255); byte[0] is the report ID.

[[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 ..."
FieldTypeDefaultDescription
interfaceintegerrequiredPhysical interface used for the command
templatestringrequiredOutput bytes with typed placeholders
min_interval_msinteger10Minimum interval between physical PLAY writes (11000 ms); newer queued states replace older ones

STOP commands remain immediate so a throttled PLAY cannot leave a motor running. Once the last successful physical state is already identical, including zero, padctl suppresses the redundant write.

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
default_profilestringOptional name for the default [output] profile
namestringuinput device name
vidintegerEmulated vendor ID
pidintegerEmulated product ID

Built-in emulate profiles:

ProfileVID:PIDNotes
xbox-360045e:028eStandard Xbox 360 layout
xbox-elite2045e:0b00Xbox Elite Series 2 identity
dualsense054c:0ce6DualSense identity with touchpad and mic buttons
dualsense-edge054c:0df2DualSense Edge identity, high-resolution stick ranges, and M1-M4 mapped to BTN_TRIGGER_HAPPY1-BTN_TRIGGER_HAPPY4
switch-pro057e:2009Nintendo Switch Pro layout

emulate fills only missing [output] fields. Explicit vid, pid, name, [output.axes], or [output.buttons] entries remain authoritative, so a device can use a preset identity without losing device-specific stick ranges or extra button layout.

These profiles define the virtual gamepad identity and generic evdev/HID button layout. They do not synthesize vendor-specific HIDAPI reports such as native DualSense gyro, adaptive trigger, or touchpad packet formats.

For dualsense-edge, the preset is an output identity and layout preset. It is useful when a client keys behavior from VID/PID and generic button layout, but it is not a complete DualSense Edge firmware clone. If a physical controller has higher-resolution sticks than the preset defaults, keep explicit [output.axes] entries in the device TOML; explicit axis ranges override the preset and prevent accidental precision loss.

Output profiles

Device configs may declare named output profiles under [output.profiles.<name>]. The default [output] remains active unless the user selects a profile in config.toml:

# ~/.config/padctl/config.toml
version = 1

[[device]]
name = "Flydigi Vader 5 Pro"
default_mapping = "vader5"
output_profile = "dualsense-edge"

Profiles overlay the default [output]: fields declared by the profile replace the default, and fields omitted by the profile inherit the default. When a profile sets emulate, the preset first fills that profile's missing VID/PID/name/buttons/axes, then the result overlays [output]. Unknown profile names are ignored with a warning and the default output stays active.

Vader 5 exposes two opt-in DualSense Edge choices. List or select them without hand-editing config.toml:

padctl output-profile list --device "Flydigi Vader 5 Pro"
padctl output-profile select dualsense-edge-native --device "Flydigi Vader 5 Pro"
ProfileOutput pathStick rangeNative features
dualsense-edgegeneric uinput identity/layout-32768..32767no native HID protocol; preserves 16-bit stick resolution
dualsense-edge-nativewired USB DualSense Edge UHID0..255SDL HIDAPI buttons, touch, accelerometer/gyro, and compatible rumble

The native profile uses one Edge UHID for gamepad input, two synthesized C/Z touch contacts and click, and motion sensors; it does not create a separate IMU companion. Vader's inherited auxiliary mouse may still coexist. The 8-bit axes are an intentional property of the native USB report. Use dualsense-edge when 16-bit stick resolution matters more than native HIDAPI recognition.

The native scope is deliberately narrow: USB only. Bluetooth, private configuration reports, audio/speaker endpoints, and adaptive triggers are not implemented.

[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".

For protocol = "dualsense-edge-usb", type = "rumble" also names the physical command used by the native compatible-rumble path. SDL HIDAPI report 0x02 output is decoded by the UHID pump and forwarded through the same [commands.rumble] formatter; it does not use uinput FF or HID PID effects.

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 for generic output protocols. 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). Native dualsense-edge-usb is different: its single gamepad report already carries motion sensors, and the validator rejects a companion [output.imu].

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.

Force feedback limitation: enabling [output.imu] backend="uhid" places the primary gamepad on the UHID backend. On current kernels, evdev force feedback is unavailable for UHID devices unless the kernel's hid-universal-pidff module recognises the device VID/PID — generic UHID nodes get capabilities/ff=0. Wine/Lutris evdev rumble will not work in this mode. Rumble via SDL or Steam (HIDAPI path) is unaffected.

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