feat(gamepad): Switch Pro backend — Linux UHID via hid-nintendo (N2)

A virtual Pro Controller (057E:2009, BUS_USB, verbatim 203-byte USB
descriptor triple-cross-checked from real-device captures) bound by
hid-nintendo (>= 5.16): Nintendo-family client pads get correct glyphs +
POSITIONAL layout (wire south/east/west/north -> Switch B/A/Y/X, so the
physical-position <-> glyph relationship survives), live gyro/accel, and
HD-rumble feedback — instead of folding to Xbox360 (mirrored A/B + X/Y,
no motion).

- switch_proto: report-0x30/0x21/0x81 codec + the entire canned probe
  conversation, pinned line-by-line against hid-nintendo.c: 0x80-family
  USB acks, device info (type 0x03 + per-pad MAC), SPI-flash calibration
  blobs (user magics ABSENT -> factory path; sticks 2048 +/- 1400 with
  the left/right byte-order difference; IMU offsets 0 + the driver's own
  default scales so raw units pass 1:1), rumble amplitude decode through
  the driver's inverted joycon_rumble_amplitudes table, player lights ->
  0xCD PlayerLeds. 11 new pin tests.
- switch_pro: UHID backend answering the probe from the manager's
  service pass; SwitchProManager = UhidManager<SwitchProProto> (the 8 ms
  heartbeat doubles as the steady 0x30 stream the driver's post-probe
  rate limiter wants). switchpro-test CLI smoke.
- Router/fold: SwitchPro arms; pick_gamepad SwitchPro -> itself on Linux;
  degrade_if_no_uhid covers it. SDL picker: NintendoSwitchPro + JoyconPair
  declare SwitchPro.

Headless-validated on .21 (hid-nintendo 7.1): probe completes ('using
factory cal' for sticks + IMU, player-1 LED round-trips to the 0xCD
plane), gamepad + IMU input devices created, and an evdev capture pins
the positional swap (wire A/B -> BTN_SOUTH/BTN_EAST) + full-range stick
scaling. .21 clippy -D warnings + 303/0 tests; .133 clippy -D warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 11:05:28 +02:00
parent 1830e095f8
commit 70a74b0d7c
7 changed files with 1067 additions and 5 deletions
+30 -4
View File
@@ -1777,6 +1777,8 @@ struct Pads {
dualshock4: Option<crate::inject::dualshock4::DualShock4Manager>,
#[cfg(target_os = "linux")]
steamdeck: Option<crate::inject::steam_controller::SteamControllerManager>,
#[cfg(target_os = "linux")]
switchpro: Option<crate::inject::switch_pro::SwitchProManager>,
#[cfg(target_os = "windows")]
dualsense_win: Option<crate::inject::dualsense_windows::DualSenseWindowsManager>,
#[cfg(target_os = "windows")]
@@ -1808,6 +1810,8 @@ impl Pads {
dualshock4: None,
#[cfg(target_os = "linux")]
steamdeck: None,
#[cfg(target_os = "linux")]
switchpro: None,
#[cfg(target_os = "windows")]
dualsense_win: None,
#[cfg(target_os = "windows")]
@@ -1879,6 +1883,11 @@ impl Pads {
.get_or_insert_with(crate::inject::steam_controller::SteamControllerManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::SwitchPro => self
.switchpro
.get_or_insert_with(crate::inject::switch_pro::SwitchProManager::new)
.handle(ev),
#[cfg(target_os = "linux")]
GamepadPref::XboxOne => self
.xboxone
.get_or_insert_with(|| {
@@ -1958,6 +1967,12 @@ impl Pads {
m.apply_rich(rich)
}
}
#[cfg(target_os = "linux")]
GamepadPref::SwitchPro => {
if let Some(m) = &mut self.switchpro {
m.apply_rich(rich)
}
}
#[cfg(target_os = "windows")]
GamepadPref::DualSense => {
if let Some(m) = &mut self.dualsense_win {
@@ -2009,6 +2024,9 @@ impl Pads {
if let Some(m) = &mut self.steamdeck {
m.pump(&mut rumble, &mut hidout);
}
if let Some(m) = &mut self.switchpro {
m.pump(&mut rumble, &mut hidout);
}
}
#[cfg(target_os = "windows")]
{
@@ -2044,6 +2062,9 @@ impl Pads {
if let Some(m) = &mut self.steamdeck {
m.heartbeat(gap);
}
if let Some(m) = &mut self.switchpro {
m.heartbeat(gap);
}
}
#[cfg(target_os = "windows")]
{
@@ -2749,7 +2770,9 @@ fn pick_gamepad(pref: GamepadPref, env: Option<&str>, linux: bool, windows: bool
// DualSense plus native back/Fn buttons, so the wire paddles stop hitting the fold/drop
// policy. Degrades to Xbox360 elsewhere like its siblings.
GamepadPref::DualSenseEdge if linux || windows => GamepadPref::DualSenseEdge,
// Switch Pro: no backend yet (N2) — falls through to Xbox360 below.
// Switch Pro: Linux UHID hid-nintendo (≥ 5.16) — correct Nintendo glyphs + positional
// layout + gyro + HD rumble. No Windows backend; folds to Xbox360 there.
GamepadPref::SwitchPro if linux => GamepadPref::SwitchPro,
_ => GamepadPref::Xbox360,
}
}
@@ -2766,6 +2789,7 @@ fn degrade_if_no_uhid(chosen: GamepadPref) -> GamepadPref {
| GamepadPref::DualSenseEdge
| GamepadPref::DualShock4
| GamepadPref::SteamDeck
| GamepadPref::SwitchPro
);
if needs_uhid
&& std::fs::OpenOptions::new()
@@ -5348,10 +5372,12 @@ mod tests {
DualSenseEdge
);
assert_eq!(pick_gamepad(DualSenseEdge, None, false, false), Xbox360);
// Switch Pro: no backend yet (gamepad-new-types N2) — folds to Xbox360 everywhere.
assert_eq!(pick_gamepad(SwitchPro, None, true, false), Xbox360);
assert_eq!(pick_gamepad(Auto, Some("switchpro"), true, false), Xbox360);
// Switch Pro: native on Linux (UHID hid-nintendo); Xbox360 on Windows and elsewhere.
assert_eq!(pick_gamepad(SwitchPro, None, true, false), SwitchPro);
assert_eq!(pick_gamepad(Auto, Some("switchpro"), true, false), SwitchPro);
assert_eq!(pick_gamepad(Auto, Some("switch"), true, false), SwitchPro);
assert_eq!(pick_gamepad(SwitchPro, None, false, true), Xbox360);
assert_eq!(pick_gamepad(SwitchPro, None, false, false), Xbox360);
}
#[test]