diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml
index ece4ea4..d621639 100644
--- a/.github/workflows/ci-build.yml
+++ b/.github/workflows/ci-build.yml
@@ -181,9 +181,8 @@ jobs:
echo "::warning::${kernel_modules_package} is unavailable; relying on the runner image kernel modules."
fi
sudo tee /etc/udev/rules.d/99-libvirtualhid-ci.rules >/dev/null <<'EOF'
- SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{phys}=="libvirtualhid/uhid/*", MODE="0666", TAG+="uaccess"
+ SUBSYSTEM=="hidraw", KERNEL=="hidraw*", MODE="0666", TAG+="uaccess"
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{phys}=="libvirtualhid/uhid/*", MODE="0666", TAG+="uaccess"
- SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{name}=="(libvirtualhid)*", MODE="0666", TAG+="uaccess"
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="(libvirtualhid)*", MODE="0666", TAG+="uaccess"
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="libvirtualhid*", MODE="0666", TAG+="uaccess"
EOF
diff --git a/README.md b/README.md
index 0d4e7d2..01da443 100644
--- a/README.md
+++ b/README.md
@@ -45,14 +45,14 @@ behind backend implementations.
- Gamepad profiles for generic HID, Xbox 360, Xbox One, Xbox Series,
DualShock 4, DualSense, and Nintendo Switch Pro-style controllers.
-- Descriptor-driven PlayStation gamepads through Linux `uhid`; Generic, Xbox,
- and Switch Pro gamepads plus keyboard, mouse, touchscreen, trackpad, and pen
- tablet devices through `uinput`.
+- Descriptor-driven PlayStation and Switch Pro gamepads through Linux `uhid`;
+ Generic and Xbox gamepads plus keyboard, mouse, touchscreen, trackpad, and
+ pen tablet devices through `uinput`.
- Windows gamepads, keyboards, and Raw Input-visible mice through a user-mode
UMDF2 control driver backed by Virtual HID Framework, with Win32 keyboard and
mouse fallbacks when the licensed driver path is unavailable.
-- Output callbacks for profile-specific feedback such as rumble, LEDs,
- adaptive triggers, and raw HID output reports when available.
+- Output callbacks for profile-specific feedback such as rumble, RGB and player
+ LEDs, adaptive triggers, and raw HID output reports when available.
- An optional `virtualhid_control` native UI tool for creating, removing,
controlling, and inspecting test gamepads and mice through the public C++ API.
- CMake consumption through installed packages, vendored source,
diff --git a/docs/end-user-gamepad-guide.md b/docs/end-user-gamepad-guide.md
index 563412a..479de64 100644
--- a/docs/end-user-gamepad-guide.md
+++ b/docs/end-user-gamepad-guide.md
@@ -148,8 +148,9 @@ columns compare the existing Windows Virtual HID Driver results.
| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Digital trigger input (0 or 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ |
| Basic rumble | ✅ | ✅ | ✅ | ❌ | ❌ | ➖ |
-| Motion/gyro | ❌9 | ❌13 | ❌9 | ❌9 | ❌9 | ➖ |
-| Battery state | ❌ | ❌ | ❌ | ❌ | ❌ | ➖ |
+| Motion/gyro | ❌9 | ✅ | ✅ | ❌17 | ❌9 | ➖ |
+| Battery state | ❌ | ✅ | ✅ | ✅ | ❌ | ➖ |
+| HOME LED | ❌16 | ❌16 | ❌16 | ❌ | ❌ | ➖ |
| Player LED | ❌14 | ❌14 | ❌14 | ❌14 | ❌14 | ➖ |
| Capture button | ✅ | ✅ | ✅ | ❌10 | ✅15 | ➖ |
@@ -157,23 +158,25 @@ When a backend is marked ❌, that path cannot establish whether an additional
client-side limitation exists. The owner below identifies the first known layer
that prevents the feature from working end to end.
-| Note | Owner | Limitation or status | Tracker or reference |
-|-------------------------------------------------|----------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| 1 | Client platform | Moonlight Android exposes gamepad motion on Android 12 or later when motion is enabled and the Android device exposes the controller sensors. Available settings can differ between devices. | [Moonlight Android motion settings](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/preferences/StreamSettings.java#L296-L309) |
-| 2 | Client platform | Android may expose a PlayStation touchpad as a mouse instead of a native controller touchpad. Leave **Gamepad touchpad as mouse** disabled when native forwarding is available. DualSense support requires Android 12 or later, and Sony documents that adaptive triggers are unavailable on Android mobile devices. | [Sony Android requirements](https://www.playstation.com/en-us/support/hardware/pair-dualsense-controller-bluetooth/) and [Moonlight Android touchpad handling](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1680-L1778) |
-| 3 | Windows host backend | Steam does not expose the Xbox Series Share button through Virtual HID Driver on Windows. | [libvirtualhid issue #106](https://github.com/LizardByte/libvirtualhid/issues/106) |
-| 4 | Host profile | The Xbox One profile rejects battery updates independently of the client. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107) |
-| 5 | Client platform and external consumer | Android rumble depends on device vibration APIs and compatible motors. Steam may not dispatch PlayStation rumble until its controller settings or calibration page initializes the controller. | [Moonlight Android vibration handling](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3373-L3419), [libvirtualhid issue #80](https://github.com/LizardByte/libvirtualhid/issues/80), and [Steam for Linux issue #13435](https://github.com/ValveSoftware/steam-for-linux/issues/13435) |
-| 6 | Client platform | Moonlight Android uses the RGB lights API available on Android 12 or later. It worked on tested newer devices but was unavailable on NVIDIA Shield running Android 11. | [Moonlight Android RGB-light detection](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3454-L3470) |
-| 7 | Host output pipeline | DualSense player-indicator and MUTE-button LED forwarding is covered by open host pull requests. This note applies only to the LEDs; the MUTE button input works through both host backends with Moonlight Qt. When a DualSense is connected to Android, its physical MUTE-button LED works locally, but that LED state is not forwarded to the virtual controller on the host. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) |
-| 8 | Linux host backend | Steam exposes the Xbox Series Share button through the Linux uinput device, but pressing it does not change the button state. | [libvirtualhid issue #110](https://github.com/LizardByte/libvirtualhid/issues/110) |
-| 9 | Windows host backend and Android client | On Windows, Steam exposes Switch Pro gyro input but its values remain static. The host behavior is tracked in libvirtualhid; the Android client separately tracks exposing Switch Pro with gyro. | [libvirtualhid issue #111](https://github.com/LizardByte/libvirtualhid/issues/111) and [Moonlight Android issue #1497](https://github.com/moonlight-stream/moonlight-android/issues/1497) |
-| 10 | Client | Moonlight Android exposes the tested Switch Pro Capture input as A instead of Capture. A broader Android Switch Pro mapping issue exists, but the exact Capture symptom is not explicitly tracked. | [Moonlight Android issue #842](https://github.com/moonlight-stream/moonlight-android/issues/842) |
-| 11 | Host backend | The Linux uinput/evdev Xbox One and Xbox Series profiles support basic rumble but cannot deliver independent Impulse Triggers. | [libvirtualhid issue #109](https://github.com/LizardByte/libvirtualhid/issues/109) |
-| 12 | Client and protocol; resolved upstream, unreleased | Moonlight Qt adaptive-trigger support and its protocol and Sunshine dependencies are merged, but the latest published Moonlight Qt release predates them. The feature has not yet been validated with these backends. | [Moonlight Qt pull request #1561](https://github.com/moonlight-stream/moonlight-qt/pull/1561), [moonlight-common-c pull request #102](https://github.com/moonlight-stream/moonlight-common-c/pull/102), [Sunshine pull request #3738](https://github.com/LizardByte/Sunshine/pull/3738), and [Moonlight Qt v6.1.0](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0) |
-| 13 | Linux host backend | Steam does not expose Switch Pro gyro because the current Linux uinput route cannot carry the profile's native motion reports. The libvirtualhid issue supersedes the closed, pre-libvirtualhid Sunshine request. | [libvirtualhid issue #112](https://github.com/LizardByte/libvirtualhid/issues/112) and [closed Sunshine issue #3838](https://github.com/LizardByte/Sunshine/issues/3838) |
-| 14 | Host output pipeline | Switch Pro Player LED output is not decoded and forwarded by either host backend. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) |
-| 15 | Client platform and version | The marked features worked when tested with Moonlight on an iPhone running iOS 18.7.10, but did not work on an Apple TV 4K running tvOS 26.6. Moonlight enables these extended features only when Apple's Game Controller framework exposes the corresponding buttons, haptics localities, motion sensors, or light. | [Moonlight capability detection](https://github.com/moonlight-stream/moonlight-ios/blob/85af0f75622bb2636481afda8b0fc5cc33d5956e/Limelight/Input/ControllerSupport.m#L547-L608), [Apple controller-haptics capabilities](https://developer.apple.com/documentation/gamecontroller/gcdevicehaptics), and [Apple controller-motion capabilities](https://developer.apple.com/documentation/gamecontroller/gcmotion) |
+| Note | Owner | Limitation or status | Tracker or reference |
+|-------------------------------------------------|----------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| 1 | Client platform | Moonlight Android exposes gamepad motion on Android 12 or later when motion is enabled and the Android device exposes the controller sensors. Available settings can differ between devices. | [Moonlight Android motion settings](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/preferences/StreamSettings.java#L296-L309) |
+| 2 | Client platform | Android may expose a PlayStation touchpad as a mouse instead of a native controller touchpad. Leave **Gamepad touchpad as mouse** disabled when native forwarding is available. DualSense support requires Android 12 or later, and Sony documents that adaptive triggers are unavailable on Android mobile devices. | [Sony Android requirements](https://www.playstation.com/en-us/support/hardware/pair-dualsense-controller-bluetooth/) and [Moonlight Android touchpad handling](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1680-L1778) |
+| 3 | Windows host backend | Steam does not expose the Xbox Series Share button through Virtual HID Driver on Windows. | [libvirtualhid issue #106](https://github.com/LizardByte/libvirtualhid/issues/106) |
+| 4 | Host profile | The Xbox One profile rejects battery updates independently of the client. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107) |
+| 5 | Client platform and external consumer | Android rumble depends on device vibration APIs and compatible motors. Steam may not dispatch PlayStation rumble until its controller settings or calibration page initializes the controller. | [Moonlight Android vibration handling](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3373-L3419), [libvirtualhid issue #80](https://github.com/LizardByte/libvirtualhid/issues/80), and [Steam for Linux issue #13435](https://github.com/ValveSoftware/steam-for-linux/issues/13435) |
+| 6 | Client platform | Moonlight Android uses the RGB lights API available on Android 12 or later. It worked on tested newer devices but was unavailable on NVIDIA Shield running Android 11. | [Moonlight Android RGB-light detection](https://github.com/moonlight-stream/moonlight-android/blob/master/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L3454-L3470) |
+| 7 | Host output pipeline | DualSense player-indicator and MUTE-button LED forwarding is covered by open host pull requests. This note applies only to the LEDs; the MUTE button input works through both host backends with Moonlight Qt. When a DualSense is connected to Android, its physical MUTE-button LED works locally, but that LED state is not forwarded to the virtual controller on the host. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) |
+| 8 | Linux host backend | Steam exposes the Xbox Series Share button through the Linux uinput device, but pressing it does not change the button state. | [libvirtualhid issue #110](https://github.com/LizardByte/libvirtualhid/issues/110) |
+| 9 | Windows host validation | A physical Switch Pro connected to Moonlight Qt v6.1.0 on Windows delivered gyro through Sunshine on Linux, but the same client and controller did not deliver usable gyro through Sunshine on Windows. The Windows installed-driver SDL3 test receives live virtual Switch motion, so the failure is specific to the Windows VHF-to-Steam path rather than Moonlight's sensor transport. Neither advancing only the full-state counter nor unifying the counter across input and subcommand replies fixed Steam. The Windows client backend now caches separate motion updates and streams one complete native input report every 15 milliseconds; another Windows Steam retest is required. | [libvirtualhid issue #111](https://github.com/LizardByte/libvirtualhid/issues/111) |
+| 10 | Client | Moonlight Android exposes the tested Switch Pro Capture input as A instead of Capture. A broader Android Switch Pro mapping issue exists, but the exact Capture symptom is not explicitly tracked. | [Moonlight Android issue #842](https://github.com/moonlight-stream/moonlight-android/issues/842) |
+| 11 | Host backend | The Linux uinput/evdev Xbox One and Xbox Series profiles support basic rumble but cannot deliver independent Impulse Triggers. | [libvirtualhid issue #109](https://github.com/LizardByte/libvirtualhid/issues/109) |
+| 12 | Client and protocol; resolved upstream, unreleased | Moonlight Qt adaptive-trigger support and its protocol and Sunshine dependencies are merged, but the latest published Moonlight Qt release predates them. The feature has not yet been validated with these backends. | [Moonlight Qt pull request #1561](https://github.com/moonlight-stream/moonlight-qt/pull/1561), [moonlight-common-c pull request #102](https://github.com/moonlight-stream/moonlight-common-c/pull/102), [Sunshine pull request #3738](https://github.com/LizardByte/Sunshine/pull/3738), and [Moonlight Qt v6.1.0](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0) |
+| 13 | Linux host backend | The Linux backend uses descriptor-driven UHID for Switch Pro, advertises a backend-only Bluetooth transport identity that SDL2 HIDAPI accepts for virtual devices, answers its initialization protocol, and carries live native motion reports. Motion and battery were validated end to end from Moonlight Qt v6.1.0 on Windows through Sunshine on Linux into Steam. | [libvirtualhid issue #112](https://github.com/LizardByte/libvirtualhid/issues/112) and [closed Sunshine issue #3838](https://github.com/LizardByte/Sunshine/issues/3838) |
+| 14 | Streaming host and client output pipeline | Both host backends decode Switch Pro Set Player Lights output into solid and flashing player-indicator callbacks, and Sunshine can serialize those masks through its proposed protocol extension. Released moonlight-common-c and Moonlight clients do not consume that extension, so testing with Moonlight Qt v6.1.0 leaves the physical player LEDs unchanged on both host backends. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) |
+| 15 | Client platform and version | The marked features worked when tested with Moonlight on an iPhone running iOS 18.7.10, but did not work on an Apple TV 4K running tvOS 26.6. Moonlight enables these extended features only when Apple's Game Controller framework exposes the corresponding buttons, haptics localities, motion sensors, or light. | [Moonlight capability detection](https://github.com/moonlight-stream/moonlight-ios/blob/85af0f75622bb2636481afda8b0fc5cc33d5956e/Limelight/Input/ControllerSupport.m#L547-L608), [Apple controller-haptics capabilities](https://developer.apple.com/documentation/gamecontroller/gcdevicehaptics), and [Apple controller-motion capabilities](https://developer.apple.com/documentation/gamecontroller/gcmotion) |
+| 16 | Client capability and output pipeline | Both host backends decode Switch Pro Set HOME Light output as a grayscale LED callback. Moonlight Qt v6.1.0 uses SDL2's RGB-style LED capability check, and the tested controller reported no LED. Moonlight Qt master uses SDL3 through sdl2-compat; SDL3 identifies HOME as a mono LED, but the compatibility check maps only the RGB capability. Neither path advertises LED support to Sunshine, so it never sends the HOME-light command. | [Moonlight Qt LED capability check](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp), [SDL Switch HOME-light capability](https://github.com/libsdl-org/SDL/blob/147a8ee32dbf9ac02f3794964490687b6bbda1bc/src/joystick/hidapi/SDL_hidapi_switch.c), and [sdl2-compat LED mapping](https://github.com/libsdl-org/sdl2-compat/blob/a53b6ad90ecd2d0ccfe01d5cfd2059793acf8c12/src/sdl2_compat.c) |
+| 17 | Moonlight Android controller lifecycle | With Moonlight Android v12.1 on Android 14, a USB Switch Pro repeatedly disconnected and reconnected in Steam through Sunshine on Linux, and opening Steam's gyro-calibration page triggered an immediate disconnect. Enabling motion makes Moonlight register Android input-device sensor listeners, while Android device removal is forwarded immediately. An Android event log is still needed to prove whether sensor activation causes remove/add events; issue #1497 separately tracks incorrect type handling for sensor-capable Nintendo controllers. | [Moonlight Android v12.1 sensor handling](https://github.com/moonlight-stream/moonlight-android/blob/v12.1/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L763-L779), [device removal handling](https://github.com/moonlight-stream/moonlight-android/blob/v12.1/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L234-L265), and [Moonlight Android issue #1497](https://github.com/moonlight-stream/moonlight-android/issues/1497) |
Analog trigger input reports intermediate values between 0 and 1. Switch Pro
ZL/ZR input is digital and reports only 0 or 1. Trigger input is also separate
@@ -228,7 +231,7 @@ and verify that the Sunshine virtual-gamepad selection matches a controller the
game supports. Disconnect unused host-side controllers if the game always
opens the first controller slot.
-### PlayStation gyro, light bar, or rumble does not work in Steam
+### Gyro, LEDs, or rumble do not work in Steam
Steam may need a one-time gyro calibration before its controller tester or
Steam Input fully initializes a virtual DualShock 4 or DualSense controller's
@@ -239,6 +242,12 @@ be a Steam bug rather than a remaining controller-protocol failure. See
[ValveSoftware/steam-for-linux issue #13435](https://github.com/ValveSoftware/steam-for-linux/issues/13435)
for a related DualSense rumble report.
+For Switch Pro, first confirm that rumble is enabled in the game and complete
+Steam's controller setup or calibration once. A reported Linux test began
+receiving rumble after the game-side rumble option was enabled; that observation
+does not establish that every game or Steam configuration uses the same output
+path.
+
If Steam repeatedly treats the virtual controller as a new device, disabling
Sunshine's **Randomize virtual controller MAC** option may help it retain the
controller's calibration and settings. Restart Sunshine and reconnect the
diff --git a/docs/platform-support.md b/docs/platform-support.md
index 5ad3593..1d3e3fb 100644
--- a/docs/platform-support.md
+++ b/docs/platform-support.md
@@ -81,7 +81,15 @@ The VHF driver answers the calibration, pairing, and firmware feature reports
used to initialize DualShock 4 and DualSense HIDAPI output. It also answers the
Switch Pro USB and subcommand initialization sequence and accepts the native
`0x30` input layout, so descriptor-aware consumers can initialize those
-controllers before sending their native output reports.
+controllers before sending their native output reports. The Switch Pro profile
+uses the `0x0210` hardware revision reported by a physical Nintendo controller,
+and the Windows VHF device exposes that revision to HID consumers. Full-state
+and subcommand-reply reports use the same per-device packet counter on Windows,
+matching the counter that a native controller advances for every input report.
+The Windows client backend caches the newest complete Switch Pro state and
+streams native `0x30` reports every 15 milliseconds. This coalesces separate
+acceleration and gyroscope API updates into the three-sample report cadence used
+by a physical USB controller.
Windows VHF devices do not expose a Bluetooth transport identity to HIDAPI.
The Windows backend therefore reports DualShock 4 and DualSense requests as
@@ -101,26 +109,23 @@ and signing details.
The Linux backend uses standard user-space kernel interfaces:
-- `uhid` for descriptor-driven HID gamepads.
-- `uinput` for Generic, Xbox 360, Xbox One, Xbox Series, and Switch Pro
- gamepads, plus keyboard, mouse, touchscreen, trackpad, and pen tablet
- devices.
+- `uhid` for descriptor-driven PlayStation and Switch Pro gamepads.
+- `uinput` for Generic, Xbox 360, Xbox One, and Xbox Series gamepads, plus
+ keyboard, mouse, touchscreen, trackpad, and pen tablet devices.
- `libevdev` internally for uinput device construction.
- X11/XTest only as a keyboard and mouse fallback when `uinput` cannot be used
and an X11 session is available.
Gamepad support normally prefers `uhid` because descriptors, raw HID identity,
feature reports, and output reports matter for controller compatibility.
-Generic, Xbox-family, and Switch Pro profiles instead use `uinput` so SDL,
+Generic and Xbox-family profiles instead use `uinput` so SDL,
Steam, browser Gamepad API implementations, and other evdev consumers receive
canonical Linux gamepad events. Face buttons, shoulders, menu buttons, stick
clicks, and Guide use their native evdev codes; sticks use absolute axes. Every
uinput gamepad exposes its directional pad through `ABS_HAT0X` and `ABS_HAT0Y`.
Generic and Xbox triggers remain independent analog `ABS_Z` and `ABS_RZ` axes.
-Switch Pro uses the Nintendo face-button
-positions, button events for ZL/ZR, and `BTN_Z` for Capture. Profiles with rumble
-support normalize rumble, constant, periodic, and ramp uinput force-feedback
-effects back into the public callback. Each requested playback repetition
+Profiles with rumble support normalize rumble, constant, periodic, and ramp
+uinput force-feedback effects back into the public callback. Each requested playback repetition
restarts the effect's ramp and envelope timing. A zero-length effect remains
active until its explicit stop event, matching the infinite-effect contract used
by SDL and Steam. The Linux backend lets a new uinput device settle before
@@ -182,10 +187,16 @@ descriptor, and report framing remain unchanged. This transport-only name is
confined to the Linux backend; public profile names, Windows names, and VHF
behavior are unchanged.
-Switch Pro keeps its Nintendo identity on the Linux uinput path. This follows
-the evdev layout used by Linux-native virtual-controller implementations and
-allows standard `FF_RUMBLE` effects without emulating the physical controller's
-proprietary initialization handshake.
+Switch Pro uses Linux `uhid` with its native Nintendo descriptor and identity.
+Its backend-only UHID identity advertises Bluetooth transport because SDL2's
+Linux HIDAPI rejects virtual `BUS_USB` HIDRAW devices without a physical USB
+parent in sysfs. The public profile remains USB and its report framing is
+unchanged. The backend answers Nintendo subcommand initialization reports, and
+native `0x30` input reports carry buttons, sticks, battery state, and three live
+IMU samples.
+The public acceleration and gyroscope units remain meters per second squared
+and degrees per second; the packer converts them to Nintendo's coordinate
+system and sensor scales.
Linux touchscreen and trackpad contacts use the lowest available multitouch
slot while they are active. A newly placed contact receives a new tracking ID,
@@ -194,7 +205,11 @@ finger cannot overwrite another active finger in standard evdev consumers.
On descriptor-driven backends, native Switch Pro output reports `0x01` and
`0x10` are decoded into the normalized low- and high-frequency rumble callback.
-The original native report remains available in `GamepadOutput::raw_report`.
+Set Player Lights subcommand `0x30` additionally produces a `player_leds`
+callback with separate solid and flashing states for the four indicators. The
+Set HOME Light subcommand `0x38` produces a grayscale `rgb_led` callback whose
+equal channels preserve the requested monochrome intensity. The original native
+report remains available in `GamepadOutput::raw_report`.
The optional `virtualhid_control` diagnostic UI uses SDL3 and Dear ImGui through
the repository CPM lockfile. It is intended to stay on the same UI framework for
diff --git a/docs/streaming-host-integration.md b/docs/streaming-host-integration.md
index 16d8c32..d913bc6 100644
--- a/docs/streaming-host-integration.md
+++ b/docs/streaming-host-integration.md
@@ -13,8 +13,9 @@ A streaming host should be able to:
indexes.
- Submit incremental button, axis, trigger, touchpad, motion, and battery
updates without recreating a device.
-- Receive output callbacks for rumble, LEDs, adaptive triggers, trigger rumble,
- and raw output reports where the selected profile supports them.
+- Receive output callbacks for rumble, RGB and player LEDs, adaptive triggers,
+ trigger rumble, and raw output reports where the selected profile supports
+ them.
- Query profile and backend capabilities before warning users about unsupported
client features.
- Read device nodes and platform paths when a downstream consumer or diagnostic
@@ -51,8 +52,10 @@ The core API and adapter shape cover the major streaming-host requirements:
- Rich controller metadata.
- Gamepad output callbacks.
- Keyboard and mouse input paths.
-- Linux PlayStation gamepads through `uhid`, Generic/Xbox/Switch Pro gamepads
- through `uinput`, and `uinput` keyboard/pointer devices.
+- Linux PlayStation and Switch Pro gamepads through `uhid`, Generic/Xbox
+ gamepads through `uinput`, and `uinput` keyboard/pointer devices.
+- Native Switch Pro motion, initialization replies, rumble, HOME-light, and
+ player-light output handling on Linux and Windows descriptor-driven backends.
- Linux DualSense and DualShock 4 USB/Bluetooth report handling.
- Linux touchscreen, trackpad, and pen tablet device types.
- FreeBSD uinput gamepads and pointer devices, with basic PlayStation input and
diff --git a/docs/usage.md b/docs/usage.md
index b2ffdc1..6dcd69e 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -105,8 +105,8 @@ default when a static target is available.
The UI can create and remove gamepads from the built-in profiles, submit
buttons, sticks, triggers, and battery state, show backend and profile
capabilities, list device nodes reported for UI-created devices, and display
-normalized gamepad output such as rumble, RGB LED, adaptive trigger, trigger
-rumble, and raw report events delivered through the normal callback path. Button
+normalized gamepad output such as rumble, RGB LED, player LED, adaptive trigger,
+trigger rumble, and raw report events delivered through the normal callback path. Button
controls are momentary by default, so they behave like physical gamepad buttons;
on Windows, the UI also displays broker license status and can activate,
refresh, or deactivate a machine license without elevation. Windows UMDF
@@ -229,10 +229,11 @@ through 16 in the input report. Linux may still route that profile through
`uinput`, where the backend exposes those same logical directions through the
standard `ABS_HAT0X` and `ABS_HAT0Y` axes.
-Profiles advertise support for features such as rumble, trigger rumble, RGB
-LEDs, adaptive triggers, motion sensors, touchpads, battery state,
-profile-specific buttons, and raw output reports. Consumers should query profile and
-backend capabilities before warning users about unsupported client features.
+Profiles advertise support for features such as rumble, trigger rumble, RGB and
+player LEDs, adaptive triggers, motion sensors, touchpads, battery state,
+profile-specific buttons, and raw output reports. Consumers should query
+profile and backend capabilities before warning users about unsupported client
+features.
The `misc1` button represents Share/Capture/Mic Mute-style controls and is
available on the generic, Xbox Series, DualSense, and Switch Pro profiles; Xbox
360 and Xbox One do not advertise that extra button.
diff --git a/docs/windows-driver.md b/docs/windows-driver.md
index 3dedc2e..5420216 100644
--- a/docs/windows-driver.md
+++ b/docs/windows-driver.md
@@ -427,7 +427,14 @@ gamepad.
DualShock 4 and DualSense answer the calibration, pairing, and firmware feature
requests used by their Windows HIDAPI initialization paths. Switch Pro answers
-the native USB and subcommand handshake and submits native `0x30` input reports.
+the native USB and subcommand handshake and submits native `0x30` input reports
+with three live IMU samples. The client backend caches the newest complete
+Switch state and submits it every 15 milliseconds, matching a physical USB
+controller's report cadence while coalescing separate acceleration and
+gyroscope updates. Its Set Player Lights subcommand is normalized into solid
+and flashing player-indicator output states for the creating runtime, and its
+monochrome HOME light is normalized as equal RGB channels so existing streaming
+LED feedback paths can preserve its intensity.
The built-in Generic profile is presented to Windows as a DirectInput PID
Joystick with the complete output-report set required for DirectInput
enumeration. Constant Force and Sine output is normalized to the portable
diff --git a/src/core/gamepad_adapter.cpp b/src/core/gamepad_adapter.cpp
index d7360e5..3800876 100644
--- a/src/core/gamepad_adapter.cpp
+++ b/src/core/gamepad_adapter.cpp
@@ -83,6 +83,7 @@ namespace lvh {
support.supports_rumble = profile.capabilities.supports_rumble;
support.supports_rgb_led = profile.capabilities.supports_rgb_led;
support.supports_adaptive_triggers = profile.capabilities.supports_adaptive_triggers;
+ support.supports_player_leds = profile.capabilities.supports_player_leds;
support.supports_motion = profile.capabilities.supports_motion;
support.supports_touchpad = profile.capabilities.supports_touchpad;
support.supports_battery = profile.capabilities.supports_battery;
@@ -142,6 +143,8 @@ namespace lvh {
return support.supports_rgb_led;
case adaptive_triggers:
return support.supports_adaptive_triggers;
+ case player_leds:
+ return support.supports_player_leds;
case raw_report:
return profile.output_report_size > 0U;
}
diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp
index 67f843f..0790b6a 100644
--- a/src/core/profiles.cpp
+++ b/src/core/profiles.cpp
@@ -2025,13 +2025,19 @@ namespace lvh::profiles {
profile.bus_type = BusType::usb;
profile.vendor_id = 0x057E;
profile.product_id = 0x2009;
- profile.version = 0x8111;
+ profile.version = 0x0210;
profile.report_id = switch_pro_report_id;
profile.input_report_size = switch_pro_input_report_size;
profile.output_report_size = switch_pro_output_report_size;
profile.name = "(libvirtualhid) Nintendo Pro Controller";
profile.manufacturer = "Nintendo Co., Ltd.";
- profile.capabilities = {.supports_rumble = true, .supports_motion = true, .supports_battery = true};
+ profile.capabilities = {
+ .supports_rumble = true,
+ .supports_motion = true,
+ .supports_rgb_led = true,
+ .supports_battery = true,
+ .supports_player_leds = true,
+ };
profile.report_descriptor = make_switch_pro_report_descriptor();
return profile;
}
diff --git a/src/core/report.cpp b/src/core/report.cpp
index d0fa18b..2a41614 100644
--- a/src/core/report.cpp
+++ b/src/core/report.cpp
@@ -18,6 +18,8 @@
#include
// local includes
+#include "shared/switch_pro_protocol.hpp"
+
#include
namespace lvh::reports {
@@ -75,6 +77,30 @@ namespace lvh::reports {
constexpr std::size_t switch_rumble_output_report_size = 10;
+ constexpr std::uint8_t switch_set_player_lights_subcommand = 0x30;
+
+ constexpr std::uint8_t switch_set_home_light_subcommand = 0x38;
+
+ constexpr float switch_acceleration_scale = 4096.0F / 9.80665F;
+
+ constexpr float switch_gyroscope_scale = 14.2842F;
+
+ std::uint8_t decode_switch_home_light_intensity(std::byte encoded_intensity) {
+ const auto intensity = std::to_integer(encoded_intensity >> 4U);
+ if (intensity == 0U) {
+ return 0U;
+ }
+ if (intensity <= 6U) {
+ return static_cast(std::lround(static_cast(intensity) * 25.5F));
+ }
+ if (intensity == 15U) {
+ return 255U;
+ }
+
+ const auto normalized = (static_cast(intensity) - 0.5F) / 15.0F;
+ return static_cast(std::lround(std::pow(normalized, 1.0F / 2.13F) * 255.0F));
+ }
+
// SDL maps 16-bit rumble strengths to Nintendo's shared 101-step amplitude
// scale. This is the inverse table for the packed high- and low-band values:
// https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_switch.c
@@ -950,6 +976,47 @@ namespace lvh::reports {
}
}
+ void append_switch_pro_outputs(
+ const std::vector &report,
+ std::vector &outputs
+ ) {
+ if (const auto rumble = decode_switch_rumble_report(report); rumble.has_value()) {
+ GamepadOutput output;
+ output.kind = GamepadOutputKind::rumble;
+ output.low_frequency_rumble = rumble->low_frequency;
+ output.high_frequency_rumble = rumble->high_frequency;
+ output.raw_report = report;
+ outputs.push_back(std::move(output));
+ }
+ if (
+ report.size() >= 12U && report[0] == switch_rumble_and_subcommand_output_report_id &&
+ report[10] == switch_set_player_lights_subcommand
+ ) {
+ GamepadOutput output;
+ output.kind = GamepadOutputKind::player_leds;
+ const auto player_lights = std::byte {report[11]};
+ for (std::size_t index = 0; index < output.player_leds.size(); ++index) {
+ output.player_leds[index] = (player_lights & (std::byte {1} << index)) != zero_byte;
+ output.flashing_player_leds[index] =
+ (player_lights & (std::byte {1} << (index + 4U))) != zero_byte;
+ }
+ output.raw_report = report;
+ outputs.push_back(std::move(output));
+ }
+ if (
+ report.size() >= 15U && report[0] == switch_rumble_and_subcommand_output_report_id &&
+ report[10] == switch_set_home_light_subcommand
+ ) {
+ GamepadOutput output;
+ output.kind = GamepadOutputKind::rgb_led;
+ output.red = decode_switch_home_light_intensity(std::byte {report[12]});
+ output.green = output.red;
+ output.blue = output.red;
+ output.raw_report = report;
+ outputs.push_back(std::move(output));
+ }
+ }
+
} // namespace
float clamp_axis(float value) {
@@ -1061,6 +1128,22 @@ namespace lvh::reports {
report[offset + 2U] = to_byte((y >> 4U) & 0xFFU);
}
+ void write_switch_imu_sample(
+ ByteReport &report,
+ std::size_t offset,
+ const Vector3 &acceleration,
+ const Vector3 &gyroscope
+ ) {
+ // Nintendo's native coordinate system differs from the portable
+ // PlayStation-style coordinate system exposed by GamepadState.
+ write_i16(report, offset, scale_i16(-acceleration.z, switch_acceleration_scale));
+ write_i16(report, offset + 2U, scale_i16(-acceleration.x, switch_acceleration_scale));
+ write_i16(report, offset + 4U, scale_i16(acceleration.y, switch_acceleration_scale));
+ write_i16(report, offset + 6U, scale_i16(-gyroscope.z, switch_gyroscope_scale));
+ write_i16(report, offset + 8U, scale_i16(-gyroscope.x, switch_gyroscope_scale));
+ write_i16(report, offset + 10U, scale_i16(gyroscope.y, switch_gyroscope_scale));
+ }
+
std::byte switch_battery_and_connection(const std::optional &battery) {
constexpr auto usb_connection = std::byte {0x01};
if (!battery.has_value()) {
@@ -1151,6 +1234,7 @@ namespace lvh::reports {
ByteReport report(profile.input_report_size, zero_byte);
report[0] = to_byte(profile.report_id);
+ report[1] = to_byte(detail::switch_pro_protocol::next_switch_pro_packet_timer());
report[2] = switch_battery_and_connection(normalized.battery);
if (normalized.buttons.test(x)) {
@@ -1222,6 +1306,11 @@ namespace lvh::reports {
normalize_switch_stick_axis(normalized.right_stick.x),
normalize_switch_stick_axis(normalized.right_stick.y)
);
+ const auto acceleration = normalized.acceleration.value_or(Vector3 {.y = 9.80665F});
+ const auto gyroscope = normalized.gyroscope.value_or(Vector3 {});
+ for (const auto offset : {13U, 25U, 37U}) {
+ write_switch_imu_sample(report, offset, acceleration, gyroscope);
+ }
return to_uint8_report(report);
}
@@ -1302,13 +1391,8 @@ namespace lvh::reports {
}
if (profile.gamepad_kind == GamepadProfileKind::switch_pro) {
- if (const auto rumble = decode_switch_rumble_report(report); rumble.has_value()) {
- GamepadOutput output;
- output.kind = GamepadOutputKind::rumble;
- output.low_frequency_rumble = rumble->low_frequency;
- output.high_frequency_rumble = rumble->high_frequency;
- output.raw_report = report;
- outputs.push_back(std::move(output));
+ append_switch_pro_outputs(report, outputs);
+ if (!outputs.empty()) {
return outputs;
}
}
diff --git a/src/include/libvirtualhid/gamepad_adapter.hpp b/src/include/libvirtualhid/gamepad_adapter.hpp
index 1ce7344..0e886a6 100644
--- a/src/include/libvirtualhid/gamepad_adapter.hpp
+++ b/src/include/libvirtualhid/gamepad_adapter.hpp
@@ -69,6 +69,11 @@ namespace lvh {
* @brief Number of rear paddle buttons exposed by the profile.
*/
std::uint8_t supported_rear_paddle_count = 0;
+
+ /**
+ * @brief Whether the profile supports player indicator LED output.
+ */
+ bool supports_player_leds = false;
};
/**
diff --git a/src/include/libvirtualhid/types.hpp b/src/include/libvirtualhid/types.hpp
index 260fca5..b1d58de 100644
--- a/src/include/libvirtualhid/types.hpp
+++ b/src/include/libvirtualhid/types.hpp
@@ -270,6 +270,11 @@ namespace lvh {
* @brief Whether the profile supports adaptive trigger output.
*/
bool supports_adaptive_triggers = false;
+
+ /**
+ * @brief Whether the profile supports player indicator LED output.
+ */
+ bool supports_player_leds = false;
};
/**
@@ -998,6 +1003,7 @@ namespace lvh {
adaptive_triggers, ///< Adaptive trigger output.
raw_report, ///< Raw output report bytes.
trigger_rumble, ///< Independent trigger rumble output.
+ player_leds, ///< Player indicator LED output.
};
/**
@@ -1073,6 +1079,16 @@ namespace lvh {
* @brief Raw output report payload.
*/
std::vector raw_report;
+
+ /**
+ * @brief Solid player indicator LED states, ordered from player one through four.
+ */
+ std::array player_leds {};
+
+ /**
+ * @brief Flashing player indicator LED states, ordered from player one through four.
+ */
+ std::array flashing_player_leds {};
};
/**
diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp
index eaffb3e..de06311 100644
--- a/src/platform/linux/uhid_backend.cpp
+++ b/src/platform/linux/uhid_backend.cpp
@@ -65,6 +65,7 @@
#include "core/backend.hpp"
#if defined(__linux__)
#include "shared/playstation_feature_reports.hpp"
+ #include "shared/switch_pro_protocol.hpp"
#endif
#include
@@ -248,8 +249,8 @@ namespace lvh::detail {
case xbox_360:
case xbox_one:
case xbox_series:
- case switch_pro:
return true;
+ case switch_pro:
case dualshock4:
case dualsense:
#if defined(__FreeBSD__)
@@ -301,8 +302,11 @@ namespace lvh::detail {
#if defined(__linux__)
std::uint16_t to_uhid_bus(const DeviceProfile &profile) {
+ // Linux SDL2 HIDAPI requires BUS_USB hidraw devices to have a physical USB
+ // parent in sysfs. UHID devices do not, so expose Switch Pro through the
+ // Bluetooth HID path that accepts descriptor-driven virtual devices.
if (profile.gamepad_kind == GamepadProfileKind::switch_pro) {
- return BUS_VIRTUAL;
+ return BUS_BLUETOOTH;
}
return to_uhid_bus(profile.bus_type);
}
@@ -3057,9 +3061,20 @@ namespace lvh::detail {
void dispatch_output_report(const __u8 *data, std::size_t report_size) {
const auto size = std::min(report_size, UHID_DATA_MAX);
std::vector report(data, data + size);
+ send_switch_pro_reply(report);
dispatch_output_report(report);
}
+ void send_switch_pro_reply(const std::vector &report) {
+ if (profile_.gamepad_kind != GamepadProfileKind::switch_pro) {
+ return;
+ }
+
+ if (const auto reply = switch_pro_protocol::make_switch_pro_reply(report); reply.has_value()) {
+ static_cast(write_input_report({reply->begin(), reply->end()}));
+ }
+ }
+
void dispatch_set_report(std::uint8_t report_number, const __u8 *data, std::size_t report_size) {
const auto size = std::min(report_size, UHID_DATA_MAX);
std::vector report(data, data + size);
@@ -3199,6 +3214,7 @@ namespace lvh::detail {
effective_profile.capabilities.supports_rgb_led = false;
effective_profile.capabilities.supports_battery = false;
effective_profile.capabilities.supports_adaptive_triggers = false;
+ effective_profile.capabilities.supports_player_leds = false;
return effective_profile;
#else
static_cast(requested_profile);
diff --git a/src/platform/windows/driver/libvirtualhid_umdf.cpp b/src/platform/windows/driver/libvirtualhid_umdf.cpp
index 06a9bba..5c1fe8a 100644
--- a/src/platform/windows/driver/libvirtualhid_umdf.cpp
+++ b/src/platform/windows/driver/libvirtualhid_umdf.cpp
@@ -57,7 +57,7 @@
#include "mouse_protocol.hpp"
#include "playstation_feature_protocol.hpp"
#include "rotating_trace_log.hpp"
-#include "switch_pro_protocol.hpp"
+#include "shared/switch_pro_protocol.hpp"
#include "unique_win32_handle.hpp"
#include "vhf_input_report_queue.hpp"
#include "windows_device_identity.hpp"
@@ -122,6 +122,7 @@ namespace {
lvh::detail::windows::VhfInputReportQueue pending_input_reports;
std::shared_ptr> in_flight_input_report;
std::size_t active_input_submissions {};
+ std::uint8_t switch_pro_packet_timer {};
bool vhf_ready_for_input_report {};
bool shutting_down {};
};
@@ -254,6 +255,12 @@ namespace {
}
auto report = std::make_shared>(std::move(*pending));
+ if (
+ record.request.gamepad_kind == LVH_WINDOWS_GAMEPAD_SWITCH_PRO && report->size() > 1U &&
+ (report->at(0) == 0x21U || report->at(0) == 0x30U)
+ ) {
+ report->at(1) = record.switch_pro_packet_timer++;
+ }
const auto configured_report_id = record.request.hardware_ids.report_id;
const auto report_id = configured_report_id == 0U || report->empty() ? configured_report_id : report->front();
@@ -871,7 +878,7 @@ namespace {
return;
}
- auto reply = lvh::detail::windows::make_switch_pro_reply({event.report.data(), event.report_size});
+ auto reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply({event.report.data(), event.report_size});
if (!reply.has_value()) {
return;
}
diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp
index a6cb9b9..150c35a 100644
--- a/src/platform/windows/windows_backend.cpp
+++ b/src/platform/windows/windows_backend.cpp
@@ -88,6 +88,8 @@ namespace lvh::detail {
using SendInputFunction = std::function)>;
using SyncThreadDesktopFunction = std::function;
+ constexpr auto switch_pro_report_interval = std::chrono::milliseconds {15};
+
/**
* @brief Thread-local desktop identity used for SendInput retry decisions.
*/
@@ -956,13 +958,13 @@ namespace lvh::detail {
OutputCallback output_callback;
bool uses_generic_pid = false;
windows::GenericPidRumbleState generic_pid_rumble;
+ std::vector switch_pro_input_report;
};
class WindowsGamepad final: public BackendGamepad {
public:
- WindowsGamepad(std::shared_ptr context, std::shared_ptr state):
- context_ {std::move(context)},
- state_ {std::move(state)} {}
+ WindowsGamepad(std::shared_ptr context, std::shared_ptr state);
+ ~WindowsGamepad() override;
OperationStatus submit(
const GamepadState &state,
@@ -973,8 +975,14 @@ namespace lvh::detail {
OperationStatus close() override;
private:
+ void stream_switch_pro_reports(std::stop_token stop_token);
+ void stop_switch_pro_report_stream();
+
std::shared_ptr context_;
std::shared_ptr state_;
+ std::condition_variable switch_pro_report_ready_;
+ std::mutex switch_pro_report_mutex_;
+ std::jthread switch_pro_report_thread_;
};
class WindowsBackendContext: public std::enable_shared_from_this {
@@ -1294,6 +1302,66 @@ namespace lvh::detail {
std::map> devices_;
};
+ WindowsGamepad::WindowsGamepad(
+ std::shared_ptr context,
+ std::shared_ptr state
+ ):
+ context_ {std::move(context)},
+ state_ {std::move(state)} {
+ if (state_->profile.gamepad_kind == GamepadProfileKind::switch_pro) {
+ switch_pro_report_thread_ = std::jthread {[this](std::stop_token stop_token) {
+ stream_switch_pro_reports(stop_token);
+ }};
+ }
+ }
+
+ WindowsGamepad::~WindowsGamepad() {
+ stop_switch_pro_report_stream();
+ }
+
+ void WindowsGamepad::stream_switch_pro_reports(std::stop_token stop_token) {
+ auto next_report = std::chrono::steady_clock::now() + switch_pro_report_interval;
+ std::unique_lock report_lock {switch_pro_report_mutex_};
+ while (!stop_token.stop_requested()) {
+ static_cast(switch_pro_report_ready_.wait_until(report_lock, next_report, [&stop_token] {
+ return stop_token.stop_requested();
+ }));
+ if (stop_token.stop_requested()) {
+ break;
+ }
+
+ report_lock.unlock();
+ std::vector report;
+ {
+ std::lock_guard state_lock {state_->mutex_};
+ if (!state_->open) {
+ return;
+ }
+ report = state_->switch_pro_input_report;
+ }
+ if (!report.empty()) {
+ static_cast(context_->submit_device_report(state_, report));
+ }
+ report_lock.lock();
+
+ next_report += switch_pro_report_interval;
+ const auto now = std::chrono::steady_clock::now();
+ if (next_report <= now) {
+ next_report = now + switch_pro_report_interval;
+ }
+ }
+ }
+
+ void WindowsGamepad::stop_switch_pro_report_stream() {
+ if (!switch_pro_report_thread_.joinable()) {
+ return;
+ }
+
+ switch_pro_report_thread_.request_stop();
+ switch_pro_report_ready_.notify_all();
+ switch_pro_report_thread_.join();
+ }
+
OperationStatus WindowsGamepad::submit(
const GamepadState & /*state*/,
const std::vector &report
@@ -1310,6 +1378,11 @@ namespace lvh::detail {
return OperationStatus::failure(invalid_argument, "Windows gamepad input report exceeds protocol limit");
}
+ if (state_->profile.gamepad_kind == GamepadProfileKind::switch_pro) {
+ state_->switch_pro_input_report = report;
+ return OperationStatus::success();
+ }
+
if (state_->uses_generic_pid) {
return context_->submit_device_report(state_, windows::make_generic_windows_input_report(report));
}
@@ -1333,6 +1406,7 @@ namespace lvh::detail {
}
OperationStatus WindowsGamepad::close() {
+ stop_switch_pro_report_stream();
return context_->close_device(state_);
}
diff --git a/src/platform/windows/shared/switch_pro_protocol.hpp b/src/shared/switch_pro_protocol.hpp
similarity index 87%
rename from src/platform/windows/shared/switch_pro_protocol.hpp
rename to src/shared/switch_pro_protocol.hpp
index 6fb8f27..91e981c 100644
--- a/src/platform/windows/shared/switch_pro_protocol.hpp
+++ b/src/shared/switch_pro_protocol.hpp
@@ -1,22 +1,28 @@
/**
- * @file src/platform/windows/shared/switch_pro_protocol.hpp
- * @brief Nintendo Switch Pro initialization replies used by the Windows VHF driver.
+ * @file src/shared/switch_pro_protocol.hpp
+ * @brief Nintendo Switch Pro initialization replies shared by native HID backends.
*/
#pragma once
// standard includes
#include
#include
+#include
#include
#include
#include
#include
-namespace lvh::detail::windows {
+namespace lvh::detail::switch_pro_protocol {
inline constexpr std::size_t switch_pro_report_size = 64U;
using SwitchProReport = std::array;
+ inline std::uint8_t next_switch_pro_packet_timer() {
+ static std::atomic_uint32_t packet_timer {0};
+ return static_cast(packet_timer.fetch_add(1U, std::memory_order_relaxed));
+ }
+
namespace switch_pro_protocol_detail {
inline constexpr std::array controller_mac {0x02, 0x00, 0x00, 0x00, 0x00, 0x01};
@@ -149,7 +155,10 @@ namespace lvh::detail::windows {
} // namespace switch_pro_protocol_detail
- inline std::optional make_switch_pro_reply(std::span output_report) {
+ inline std::optional make_switch_pro_reply(
+ std::span output_report,
+ std::optional packet_timer = std::nullopt
+ ) {
if (output_report.empty()) {
return std::nullopt;
}
@@ -184,7 +193,10 @@ namespace lvh::detail::windows {
SwitchProReport reply {};
reply[0] = 0x21;
- switch_pro_protocol_detail::set_neutral_controller_state(reply, output_report[1]);
+ switch_pro_protocol_detail::set_neutral_controller_state(
+ reply,
+ packet_timer.has_value() ? *packet_timer : next_switch_pro_packet_timer()
+ );
auto acknowledgement = std::uint8_t {0x80};
if (subcommand == 0x10U) {
acknowledgement = 0x90;
@@ -201,4 +213,4 @@ namespace lvh::detail::windows {
return reply;
}
-} // namespace lvh::detail::windows
+} // namespace lvh::detail::switch_pro_protocol
diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
index 6742901..044e700 100644
--- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
+++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp
@@ -138,93 +138,138 @@ namespace lvh::detail::test {
};
/**
- * @brief Result from a socketpair-backed UHID lifecycle test.
+ * @brief DualSense observations from a socketpair-backed UHID test.
*/
- struct LinuxUhidRoundTripResult {
+ struct LinuxUhidDualSenseObservation {
/**
- * @brief Create operation status.
+ * @brief Whether the peer observed a calibration reply.
*/
- OperationStatus create_status;
+ bool saw_calibration = false;
/**
- * @brief Submit operation status.
+ * @brief Whether the peer observed a pairing reply.
*/
- OperationStatus submit_status;
+ bool saw_pairing = false;
/**
- * @brief Close operation status.
+ * @brief Whether the peer observed a firmware reply.
*/
- OperationStatus close_status;
+ bool saw_firmware = false;
/**
- * @brief Device-creation observations.
+ * @brief Whether the peer observed a signed Bluetooth feature reply.
*/
- LinuxUhidCreationObservation creation;
+ bool saw_feature_crc = false;
/**
- * @brief Whether the peer observed an input report event.
+ * @brief Whether periodic Bluetooth reports preserved motion and advanced sensor metadata.
*/
- bool saw_input = false;
+ bool saw_bluetooth_input_with_live_sensor_metadata = false;
+ };
+ /**
+ * @brief DualShock 4 observations from a socketpair-backed UHID test.
+ */
+ struct LinuxUhidDualShock4Observation {
/**
- * @brief Whether the peer observed a get-report reply.
+ * @brief Whether the peer observed a calibration reply.
*/
- bool saw_get_report_reply = false;
+ bool saw_calibration = false;
/**
- * @brief Whether the peer observed a DualSense calibration reply.
+ * @brief Whether the peer observed a pairing reply.
*/
- bool saw_dualsense_calibration = false;
+ bool saw_pairing = false;
/**
- * @brief Whether the peer observed a DualShock 4 calibration reply.
+ * @brief Whether the peer observed a firmware reply.
*/
- bool saw_dualshock4_calibration = false;
+ bool saw_firmware = false;
/**
- * @brief Whether the peer observed a DualSense pairing reply.
+ * @brief Whether the peer observed a signed Bluetooth feature reply.
*/
- bool saw_dualsense_pairing = false;
+ bool saw_feature_crc = false;
/**
- * @brief Whether the peer observed a DualShock 4 pairing reply.
+ * @brief Whether the peer observed a Bluetooth-framed input report.
*/
- bool saw_dualshock4_pairing = false;
+ bool saw_bluetooth_input = false;
/**
- * @brief Whether the peer observed a DualSense firmware reply.
+ * @brief Whether the peer observed a USB-framed input report.
*/
- bool saw_dualsense_firmware = false;
+ bool saw_usb_input = false;
+ };
+ /**
+ * @brief Switch Pro observations from a socketpair-backed UHID test.
+ */
+ struct LinuxUhidSwitchProObservation {
/**
- * @brief Whether the peer observed a DualShock 4 firmware reply.
+ * @brief Whether the peer observed a subcommand acknowledgement input report.
*/
- bool saw_dualshock4_firmware = false;
+ bool saw_subcommand_reply = false;
/**
- * @brief Whether the peer observed a signed Bluetooth DualSense feature reply.
+ * @brief Whether the peer observed live IMU samples in a submitted input report.
*/
- bool saw_dualsense_feature_crc = false;
+ bool saw_motion_input = false;
/**
- * @brief Whether the peer observed a signed Bluetooth DualShock 4 feature reply.
+ * @brief Whether the output callback exposed player indicator LEDs.
*/
- bool saw_dualshock4_feature_crc = false;
+ bool saw_player_leds = false;
+ };
+ /**
+ * @brief Result from a socketpair-backed UHID lifecycle test.
+ */
+ struct LinuxUhidRoundTripResult {
/**
- * @brief Whether periodic Bluetooth DualSense reports preserved motion and advanced sensor metadata.
+ * @brief Create operation status.
*/
- bool saw_dualsense_bluetooth_input_with_live_sensor_metadata = false;
+ OperationStatus create_status;
/**
- * @brief Whether the peer observed a Bluetooth-framed DualShock 4 input report.
+ * @brief Submit operation status.
*/
- bool saw_dualshock4_bluetooth_input = false;
+ OperationStatus submit_status;
/**
- * @brief Whether the peer observed a USB-framed DualShock 4 input report.
+ * @brief Close operation status.
*/
- bool saw_dualshock4_usb_input = false;
+ OperationStatus close_status;
+
+ /**
+ * @brief Device-creation observations.
+ */
+ LinuxUhidCreationObservation creation;
+
+ /**
+ * @brief Whether the peer observed an input report event.
+ */
+ bool saw_input = false;
+
+ /**
+ * @brief Whether the peer observed a get-report reply.
+ */
+ bool saw_get_report_reply = false;
+
+ /**
+ * @brief DualSense protocol observations.
+ */
+ LinuxUhidDualSenseObservation dualsense;
+
+ /**
+ * @brief DualShock 4 protocol observations.
+ */
+ LinuxUhidDualShock4Observation dualshock4;
+
+ /**
+ * @brief Switch Pro protocol observations.
+ */
+ LinuxUhidSwitchProObservation switch_pro;
/**
* @brief Whether the peer observed a set-report reply.
@@ -824,6 +869,13 @@ namespace lvh::detail::test {
*/
LinuxUhidRoundTripResult linux_uhid_socketpair_roundtrip();
+ /**
+ * @brief Exercise Switch Pro UHID input, output, and subcommand replies over a socketpair.
+ *
+ * @return Round-trip result with motion and player-light observations.
+ */
+ LinuxUhidRoundTripResult linux_switch_pro_uhid_socketpair_reports();
+
/**
* @brief Exercise DualSense UHID feature-report replies over a socketpair.
*
diff --git a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp
index 232a4db..12f06c7 100644
--- a/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp
+++ b/tests/fixtures/include/fixtures/windows_backend_test_hooks.hpp
@@ -30,6 +30,15 @@ namespace lvh::detail::test {
std::size_t destroy_requests = 0;
};
+ struct WindowsSwitchReportStreamResult {
+ OperationStatus create_status;
+ OperationStatus submit_status;
+ OperationStatus close_status;
+ bool repeated_report = false;
+ std::vector expected_report;
+ std::vector> submitted_reports;
+ };
+
struct WindowsBackendFailureResult {
OperationStatus invalid_argument_status;
OperationStatus unsupported_profile_status;
@@ -239,6 +248,7 @@ namespace lvh::detail::test {
};
WindowsBackendLifecycleResult windows_backend_fake_channel_lifecycle();
+ WindowsSwitchReportStreamResult windows_backend_switch_report_stream();
WindowsPlayStationTransportResult windows_backend_playstation_transport();
WindowsGenericPidOrderingResult windows_backend_generic_pid_callback_ordering();
WindowsHidKeyboardResult windows_backend_hid_keyboard();
diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp
index 91345e3..8c80eb0 100644
--- a/tests/fixtures/linux_backend_test_hooks.cpp
+++ b/tests/fixtures/linux_backend_test_hooks.cpp
@@ -1586,6 +1586,74 @@ namespace lvh::detail::test {
return result;
}
+ LinuxUhidRoundTripResult linux_switch_pro_uhid_socketpair_reports() {
+ LinuxUhidRoundTripResult result;
+ std::array descriptors {-1, -1};
+ if (::socketpair(AF_UNIX, SOCK_STREAM, 0, descriptors.data()) != 0) {
+ result.create_status = system_error_status(ErrorCode::backend_failure, "failed to create socketpair", errno);
+ result.submit_status = result.create_status;
+ result.close_status = result.create_status;
+ return result;
+ }
+
+ CreateGamepadOptions options;
+ options.profile = profiles::switch_pro();
+ options.metadata.stable_id = "libvirtualhid-switch-pro-roundtrip";
+
+ UhidGamepad gamepad {descriptors[0]};
+ auto event = create_started_profile_uhid_gamepad(gamepad, 12, options, descriptors[1], BUS_BLUETOOTH, result);
+ result.creation.saw_create = result.creation.saw_create &&
+ event.u.create2.rd_size == options.profile.report_descriptor.size();
+
+ gamepad.set_output_callback([&result](const GamepadOutput &output) {
+ ++result.output.callback_count;
+ result.output.last = output;
+ if (
+ output.kind == GamepadOutputKind::player_leds &&
+ output.player_leds == std::array {true, false, true, false} &&
+ output.flashing_player_leds == std::array {false, true, false, true}
+ ) {
+ result.switch_pro.saw_player_leds = true;
+ }
+ });
+
+ event = {};
+ event.type = UHID_OUTPUT;
+ event.u.output.rtype = UHID_OUTPUT_REPORT;
+ event.u.output.size = static_cast<__u16>(options.profile.output_report_size);
+ event.u.output.data[0] = 0x01;
+ event.u.output.data[1] = 0x07;
+ const std::array neutral_rumble {0x00, 0x01, 0x40, 0x40, 0x00, 0x01, 0x40, 0x40};
+ std::ranges::copy(neutral_rumble, event.u.output.data + 2U);
+ event.u.output.data[10] = 0x30;
+ event.u.output.data[11] = 0xA5;
+ static_cast(write_uhid_event(descriptors[1], event));
+ if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) {
+ result.switch_pro.saw_subcommand_reply = event.u.input2.size == options.profile.input_report_size &&
+ event.u.input2.data[0] == 0x21 &&
+ event.u.input2.data[1] == 0x07 &&
+ event.u.input2.data[13] == 0x80 &&
+ event.u.input2.data[14] == 0x30;
+ }
+
+ GamepadState state;
+ state.buttons.set(GamepadButton::a);
+ state.acceleration = Vector3 {.x = 9.80665F, .y = 19.6133F, .z = -9.80665F};
+ state.gyroscope = Vector3 {.x = 1.0F, .y = 2.0F, .z = -3.0F};
+ const auto report = reports::pack_input_report(options.profile, state);
+ result.submit_status = gamepad.submit(state, report);
+ if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) {
+ result.switch_pro.saw_motion_input = event.u.input2.size == report.size() &&
+ event.u.input2.data[0] == 0x30 &&
+ (event.u.input2.data[3] & 0x08U) != 0U &&
+ std::equal(report.begin() + 13, report.begin() + 49, event.u.input2.data + 13U);
+ }
+
+ result.close_status = gamepad.close();
+ static_cast(::close(descriptors[1]));
+ return result;
+ }
+
LinuxUhidRoundTripResult linux_dualsense_uhid_socketpair_reports() {
LinuxUhidRoundTripResult result;
std::array descriptors {-1, -1};
@@ -1641,7 +1709,7 @@ namespace lvh::detail::test {
event.u.get_report.rnum = 0x05;
static_cast(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
- result.saw_dualsense_calibration = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 41U &&
+ result.dualsense.saw_calibration = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 41U &&
event.u.get_report_reply.data[0] == 0x05;
}
@@ -1651,7 +1719,7 @@ namespace lvh::detail::test {
event.u.get_report.rnum = 0x09;
static_cast(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
- result.saw_dualsense_pairing = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size > 7 &&
+ result.dualsense.saw_pairing = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size > 7 &&
event.u.get_report_reply.data[0] == 0x09 &&
event.u.get_report_reply.data[1] == 0x07 &&
event.u.get_report_reply.data[6] == 0x02;
@@ -1663,7 +1731,7 @@ namespace lvh::detail::test {
event.u.get_report.rnum = 0x20;
static_cast(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
- result.saw_dualsense_firmware = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size > 0 &&
+ result.dualsense.saw_firmware = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size > 0 &&
event.u.get_report_reply.data[0] == 0x20;
}
@@ -1731,7 +1799,7 @@ namespace lvh::detail::test {
const auto crc_offset = report_size - 4U;
const auto expected_crc = crc32(std::span {event.u.input2.data, crc_offset}, playstation_crc_seed(0xA1));
const auto actual_crc = read_u32_le(event.u.input2.data + crc_offset);
- result.saw_dualsense_bluetooth_input_with_live_sensor_metadata =
+ result.dualsense.saw_bluetooth_input_with_live_sensor_metadata =
first_input_report_valid && expected_crc == actual_crc && event.u.input2.data[8] != first_sequence &&
read_u32_le(event.u.input2.data + 29U) != first_sensor_timestamp && std::equal(first_sensor_values.begin(), first_sensor_values.end(), event.u.input2.data + 17U);
}
@@ -1744,7 +1812,7 @@ namespace lvh::detail::test {
static_cast(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
const auto report_size = static_cast(event.u.get_report_reply.size);
- result.saw_dualsense_pairing = event.u.get_report_reply.err == 0 && report_size > 7U &&
+ result.dualsense.saw_pairing = event.u.get_report_reply.err == 0 && report_size > 7U &&
event.u.get_report_reply.data[0] == 0x09 &&
event.u.get_report_reply.data[1] == 0x07 &&
event.u.get_report_reply.data[6] == 0x02;
@@ -1755,7 +1823,7 @@ namespace lvh::detail::test {
playstation_crc_seed(playstation_feature_reports::playstation_feature_crc_seed)
);
const auto actual_crc = read_u32_le(event.u.get_report_reply.data + crc_offset);
- result.saw_dualsense_feature_crc = expected_crc == actual_crc;
+ result.dualsense.saw_feature_crc = expected_crc == actual_crc;
}
}
@@ -1785,7 +1853,7 @@ namespace lvh::detail::test {
event.u.create2.rd_size == options.profile.report_descriptor.size();
if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) {
- result.saw_dualshock4_usb_input =
+ result.dualshock4.saw_usb_input =
event.u.input2.size == options.profile.input_report_size && event.u.input2.data[0] == options.profile.report_id;
}
@@ -1816,7 +1884,7 @@ namespace lvh::detail::test {
event.u.get_report.rnum = 0x02;
static_cast(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
- result.saw_dualshock4_calibration = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 37 &&
+ result.dualshock4.saw_calibration = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 37 &&
event.u.get_report_reply.data[0] == 0x02;
}
@@ -1826,7 +1894,7 @@ namespace lvh::detail::test {
event.u.get_report.rnum = 0x12;
static_cast(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
- result.saw_dualshock4_pairing = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 16 &&
+ result.dualshock4.saw_pairing = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 16 &&
event.u.get_report_reply.data[0] == 0x12 &&
event.u.get_report_reply.data[1] == 0x07 &&
event.u.get_report_reply.data[6] == 0x02;
@@ -1838,7 +1906,7 @@ namespace lvh::detail::test {
event.u.get_report.rnum = 0xA3;
static_cast(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
- result.saw_dualshock4_firmware = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 49 &&
+ result.dualshock4.saw_firmware = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 49 &&
event.u.get_report_reply.data[0] == 0xA3;
}
@@ -1876,7 +1944,7 @@ namespace lvh::detail::test {
const auto crc_offset = report_size - 4U;
const auto expected_crc = crc32(std::span {event.u.input2.data, crc_offset}, playstation_crc_seed(0xA1));
const auto actual_crc = read_u32_le(event.u.input2.data + crc_offset);
- result.saw_dualshock4_bluetooth_input = expected_crc == actual_crc;
+ result.dualshock4.saw_bluetooth_input = expected_crc == actual_crc;
}
}
@@ -1887,7 +1955,7 @@ namespace lvh::detail::test {
static_cast(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
const auto report_size = static_cast(event.u.get_report_reply.size);
- result.saw_dualshock4_calibration = event.u.get_report_reply.err == 0 && report_size == 41U &&
+ result.dualshock4.saw_calibration = event.u.get_report_reply.err == 0 && report_size == 41U &&
event.u.get_report_reply.data[0] == 0x05;
if (report_size >= 4U) {
const auto crc_offset = report_size - 4U;
@@ -1896,7 +1964,7 @@ namespace lvh::detail::test {
playstation_crc_seed(playstation_feature_reports::playstation_feature_crc_seed)
);
const auto actual_crc = read_u32_le(event.u.get_report_reply.data + crc_offset);
- result.saw_dualshock4_feature_crc = expected_crc == actual_crc;
+ result.dualshock4.saw_feature_crc = expected_crc == actual_crc;
}
}
@@ -1906,7 +1974,7 @@ namespace lvh::detail::test {
event.u.get_report.rnum = 0x12;
static_cast(write_uhid_event(descriptors[1], event));
if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) {
- result.saw_dualshock4_pairing = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 16 &&
+ result.dualshock4.saw_pairing = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size == 16 &&
event.u.get_report_reply.data[0] == 0x12 &&
event.u.get_report_reply.data[1] == 0x07 &&
event.u.get_report_reply.data[6] == 0x02;
diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp
index 6a135e3..ad1048c 100644
--- a/tests/fixtures/windows_backend_test_hooks.cpp
+++ b/tests/fixtures/windows_backend_test_hooks.cpp
@@ -528,6 +528,29 @@ namespace lvh::detail {
return result;
}
+ WindowsSwitchReportStreamResult windows_backend_switch_report_stream() {
+ WindowsSwitchReportStreamResult result;
+ auto command_state = std::make_shared();
+ auto backend = make_fake_windows_backend(command_state, std::make_shared());
+
+ CreateGamepadOptions options;
+ options.profile = profiles::switch_pro();
+ auto created = backend->create_gamepad(8, options);
+ result.create_status = created.status;
+ if (created) {
+ result.expected_report.assign(options.profile.input_report_size, 0x5AU);
+ result.expected_report[0] = 0x30U;
+ result.submit_status = created.gamepad->submit({}, result.expected_report);
+ result.repeated_report = wait_until([&command_state] {
+ return command_state->submit_report_count() >= 2U;
+ });
+ result.close_status = created.gamepad->close();
+ result.submitted_reports = command_state->submit_reports();
+ }
+
+ return result;
+ }
+
WindowsHidMouseResult windows_backend_hid_mouse() {
using enum MouseEventKind;
diff --git a/tests/unit/test_freebsd_backend.cpp b/tests/unit/test_freebsd_backend.cpp
index 37391d3..ac34b77 100644
--- a/tests/unit/test_freebsd_backend.cpp
+++ b/tests/unit/test_freebsd_backend.cpp
@@ -79,7 +79,9 @@ TEST(FreeBsdBackendTest, CreatesEveryGamepadWithTheExpectedPlayStationSubset) {
EXPECT_FALSE(support.supports_battery);
EXPECT_FALSE(support.supports_rgb_led);
EXPECT_FALSE(support.supports_adaptive_triggers);
+ EXPECT_FALSE(support.supports_player_leds);
EXPECT_EQ(effective_profile.output_report_size, 0U);
+ EXPECT_FALSE(lvh::supports_gamepad_output(effective_profile, lvh::GamepadOutputKind::player_leds));
EXPECT_FALSE(lvh::supports_gamepad_output(effective_profile, lvh::GamepadOutputKind::raw_report));
if (
diff --git a/tests/unit/test_gamepad_adapter.cpp b/tests/unit/test_gamepad_adapter.cpp
index b94d1fe..d737a0c 100644
--- a/tests/unit/test_gamepad_adapter.cpp
+++ b/tests/unit/test_gamepad_adapter.cpp
@@ -52,8 +52,10 @@ TEST(GamepadAdapterTest, ReportsProfileSupport) {
const auto switch_pro_support = lvh::gamepad_profile_support(switch_pro);
EXPECT_TRUE(switch_pro_support.supports_rumble);
+ EXPECT_TRUE(switch_pro_support.supports_rgb_led);
EXPECT_TRUE(switch_pro_support.supports_motion);
EXPECT_TRUE(switch_pro_support.supports_battery);
+ EXPECT_TRUE(switch_pro_support.supports_player_leds);
EXPECT_TRUE(switch_pro_support.supports_misc1_button);
const auto keyboard_support = lvh::gamepad_profile_support(keyboard);
@@ -97,7 +99,10 @@ TEST(GamepadAdapterTest, ChecksButtonsAndOutputsByProfile) {
EXPECT_TRUE(lvh::supports_gamepad_output(dualshock4, lvh::GamepadOutputKind::raw_report));
EXPECT_TRUE(lvh::supports_gamepad_output(dualsense, lvh::GamepadOutputKind::adaptive_triggers));
EXPECT_TRUE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::rumble));
+ EXPECT_TRUE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::rgb_led));
+ EXPECT_TRUE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::player_leds));
EXPECT_TRUE(lvh::supports_gamepad_output(switch_pro, lvh::GamepadOutputKind::raw_report));
+ EXPECT_FALSE(lvh::supports_gamepad_output(generic, lvh::GamepadOutputKind::player_leds));
EXPECT_TRUE(lvh::supports_gamepad_output(generic, lvh::GamepadOutputKind::raw_report));
EXPECT_FALSE(lvh::supports_gamepad_output(keyboard, lvh::GamepadOutputKind::rumble));
EXPECT_FALSE(lvh::supports_gamepad_output(generic, static_cast(255)));
diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp
index 70214eb..78d8c46 100644
--- a/tests/unit/test_linux_backend.cpp
+++ b/tests/unit/test_linux_backend.cpp
@@ -123,7 +123,7 @@ TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) {
EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::usb), BUS_USB);
EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::bluetooth), BUS_BLUETOOTH);
EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::xbox_series), BUS_USB);
- EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::switch_pro), BUS_VIRTUAL);
+ EXPECT_EQ(lvh::detail::test::linux_gamepad_uhid_bus(lvh::GamepadProfileKind::switch_pro), BUS_BLUETOOTH);
EXPECT_EQ(lvh::detail::test::linux_uinput_bus(lvh::BusType::bluetooth), BUS_BLUETOOTH);
EXPECT_EQ(lvh::detail::test::linux_pen_tool(lvh::PenToolType::pen), BTN_TOOL_PEN);
@@ -365,26 +365,25 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) {
struct ButtonCase {
lvh::GamepadButton button;
std::uint16_t linux_code;
- std::uint16_t switch_code;
};
constexpr std::array button_cases {
- ButtonCase {a, BTN_SOUTH, BTN_EAST},
- ButtonCase {b, BTN_EAST, BTN_SOUTH},
- ButtonCase {x, BTN_NORTH, BTN_NORTH},
- ButtonCase {y, BTN_WEST, BTN_WEST},
- ButtonCase {left_shoulder, BTN_TL, BTN_TL},
- ButtonCase {right_shoulder, BTN_TR, BTN_TR},
- ButtonCase {back, BTN_SELECT, BTN_SELECT},
- ButtonCase {start, BTN_START, BTN_START},
- ButtonCase {guide, BTN_MODE, BTN_MODE},
- ButtonCase {left_stick, BTN_THUMBL, BTN_THUMBL},
- ButtonCase {right_stick, BTN_THUMBR, BTN_THUMBR},
+ ButtonCase {a, BTN_SOUTH},
+ ButtonCase {b, BTN_EAST},
+ ButtonCase {x, BTN_NORTH},
+ ButtonCase {y, BTN_WEST},
+ ButtonCase {left_shoulder, BTN_TL},
+ ButtonCase {right_shoulder, BTN_TR},
+ ButtonCase {back, BTN_SELECT},
+ ButtonCase {start, BTN_START},
+ ButtonCase {guide, BTN_MODE},
+ ButtonCase {left_stick, BTN_THUMBL},
+ ButtonCase {right_stick, BTN_THUMBR},
};
- constexpr std::array profile_kinds {generic, xbox_360, xbox_one, xbox_series, switch_pro};
+ constexpr std::array profile_kinds {generic, xbox_360, xbox_one, xbox_series};
for (const auto kind : profile_kinds) {
- for (const auto &[button, linux_code, switch_code] : button_cases) {
+ for (const auto &[button, linux_code] : button_cases) {
lvh::GamepadState state;
state.buttons.set(button);
const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(kind, state);
@@ -394,7 +393,7 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) {
ASSERT_EQ(pressed_codes.size(), 1U)
<< "profile " << static_cast(std::to_underlying(kind)) << " logical button "
<< static_cast(std::to_underlying(button));
- EXPECT_EQ(pressed_codes.front(), kind == switch_pro ? switch_code : linux_code)
+ EXPECT_EQ(pressed_codes.front(), linux_code)
<< "profile " << static_cast(std::to_underlying(kind)) << " logical button "
<< static_cast(std::to_underlying(button));
}
@@ -411,17 +410,6 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) {
EXPECT_NE(pressed, result.events.end());
}
- {
- lvh::GamepadState state;
- state.buttons.set(misc1);
- const auto result = lvh::detail::test::linux_uinput_gamepad_submit_pipe(switch_pro, state);
- ASSERT_TRUE(result.status.ok()) << result.status.message();
- const auto pressed = std::ranges::find_if(result.events, [](const auto &event) {
- return event.type == EV_KEY && event.code == BTN_Z && event.value == 1;
- });
- EXPECT_NE(pressed, result.events.end());
- }
-
for (const auto kind : profile_kinds) {
lvh::GamepadState state;
state.buttons.set(dpad_up);
@@ -451,15 +439,8 @@ TEST_F(LinuxBackendTest, PipeBackedUinputGamepadsUseCanonicalLinuxEvents) {
EXPECT_EQ(event_value(EV_ABS, ABS_Y), lvh::reports::normalize_axis(-0.25F));
EXPECT_EQ(event_value(EV_ABS, ABS_RX), lvh::reports::normalize_axis(0.75F));
EXPECT_EQ(event_value(EV_ABS, ABS_RY), lvh::reports::normalize_axis(1.0F));
- if (kind == switch_pro) {
- EXPECT_EQ(event_value(EV_ABS, ABS_Z), std::nullopt);
- EXPECT_EQ(event_value(EV_ABS, ABS_RZ), std::nullopt);
- EXPECT_EQ(event_value(EV_KEY, BTN_TL2), 1);
- EXPECT_EQ(event_value(EV_KEY, BTN_TR2), 1);
- } else {
- EXPECT_EQ(event_value(EV_ABS, ABS_Z), lvh::reports::normalize_trigger(0.25F));
- EXPECT_EQ(event_value(EV_ABS, ABS_RZ), lvh::reports::normalize_trigger(0.75F));
- }
+ EXPECT_EQ(event_value(EV_ABS, ABS_Z), lvh::reports::normalize_trigger(0.25F));
+ EXPECT_EQ(event_value(EV_ABS, ABS_RZ), lvh::reports::normalize_trigger(0.75F));
}
}
@@ -471,7 +452,6 @@ TEST_F(LinuxBackendTest, UinputGamepadsNormalizeForceFeedback) {
xbox_360,
xbox_one,
xbox_series,
- switch_pro,
}) {
for (const auto effect_type : {FF_RUMBLE, FF_CONSTANT, FF_PERIODIC, FF_RAMP}) {
const auto result = lvh::detail::test::linux_uinput_gamepad_fake_rumble(kind, effect_type);
@@ -492,7 +472,7 @@ TEST_F(LinuxBackendTest, UinputGamepadsNormalizeForceFeedback) {
TEST_F(LinuxBackendTest, UinputGamepadKeepsInfiniteRumbleActiveUntilExplicitStop) {
const auto result = lvh::detail::test::linux_uinput_gamepad_fake_rumble(
- lvh::GamepadProfileKind::switch_pro,
+ lvh::GamepadProfileKind::xbox_series,
FF_RUMBLE,
0,
true
@@ -509,7 +489,7 @@ TEST_F(LinuxBackendTest, UinputGamepadKeepsInfiniteRumbleActiveUntilExplicitStop
TEST_F(LinuxBackendTest, UinputGamepadRecalculatesActiveRumbleEndAfterReupload) {
const auto result = lvh::detail::test::linux_uinput_gamepad_fake_rumble(
- lvh::GamepadProfileKind::switch_pro,
+ lvh::GamepadProfileKind::xbox_series,
FF_RUMBLE,
0,
false,
@@ -802,6 +782,21 @@ TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) {
EXPECT_EQ(result.output.last.high_frequency_rumble, 0x1234);
}
+TEST_F(LinuxBackendTest, SocketpairBackedSwitchProUsesNativeUhidProtocol) {
+ const auto result = lvh::detail::test::linux_switch_pro_uhid_socketpair_reports();
+ EXPECT_TRUE(result.create_status.ok()) << result.create_status.message();
+ EXPECT_TRUE(result.submit_status.ok()) << result.submit_status.message();
+ EXPECT_TRUE(result.close_status.ok()) << result.close_status.message();
+ EXPECT_TRUE(result.creation.saw_create);
+ EXPECT_TRUE(result.creation.waited_for_start);
+ EXPECT_EQ(result.creation.name, lvh::profiles::switch_pro().name);
+ EXPECT_TRUE(result.switch_pro.saw_subcommand_reply);
+ EXPECT_TRUE(result.switch_pro.saw_motion_input);
+ EXPECT_TRUE(result.switch_pro.saw_player_leds);
+ ASSERT_EQ(result.output.callback_count, 2U);
+ EXPECT_EQ(result.output.last.kind, lvh::GamepadOutputKind::player_leds);
+}
+
TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) {
const auto result = lvh::detail::test::linux_dualsense_uhid_socketpair_reports();
EXPECT_TRUE(result.create_status.ok()) << result.create_status.message();
@@ -809,9 +804,9 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) {
EXPECT_TRUE(result.creation.saw_create);
EXPECT_TRUE(result.creation.waited_for_start);
EXPECT_EQ(result.creation.name, "Wireless Controller");
- EXPECT_TRUE(result.saw_dualsense_calibration);
- EXPECT_TRUE(result.saw_dualsense_pairing);
- EXPECT_TRUE(result.saw_dualsense_firmware);
+ EXPECT_TRUE(result.dualsense.saw_calibration);
+ EXPECT_TRUE(result.dualsense.saw_pairing);
+ EXPECT_TRUE(result.dualsense.saw_firmware);
EXPECT_TRUE(result.saw_set_report_reply);
ASSERT_GE(result.output.callback_count, 1U);
EXPECT_EQ(result.output.last.kind, lvh::GamepadOutputKind::rumble);
@@ -827,9 +822,9 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualSenseBluetoothFramesReports) {
EXPECT_TRUE(result.creation.saw_create);
EXPECT_TRUE(result.creation.waited_for_start);
EXPECT_EQ(result.creation.name, "Wireless Controller");
- EXPECT_TRUE(result.saw_dualsense_bluetooth_input_with_live_sensor_metadata);
- EXPECT_TRUE(result.saw_dualsense_pairing);
- EXPECT_TRUE(result.saw_dualsense_feature_crc);
+ EXPECT_TRUE(result.dualsense.saw_bluetooth_input_with_live_sensor_metadata);
+ EXPECT_TRUE(result.dualsense.saw_pairing);
+ EXPECT_TRUE(result.dualsense.saw_feature_crc);
}
TEST_F(LinuxBackendTest, SocketpairBackedDualShock4RepliesToFeatureReports) {
@@ -839,10 +834,10 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4RepliesToFeatureReports) {
EXPECT_TRUE(result.creation.saw_create);
EXPECT_TRUE(result.creation.waited_for_start);
EXPECT_EQ(result.creation.name, "Wireless Controller");
- EXPECT_TRUE(result.saw_dualshock4_usb_input);
- EXPECT_TRUE(result.saw_dualshock4_calibration);
- EXPECT_TRUE(result.saw_dualshock4_pairing);
- EXPECT_TRUE(result.saw_dualshock4_firmware);
+ EXPECT_TRUE(result.dualshock4.saw_usb_input);
+ EXPECT_TRUE(result.dualshock4.saw_calibration);
+ EXPECT_TRUE(result.dualshock4.saw_pairing);
+ EXPECT_TRUE(result.dualshock4.saw_firmware);
EXPECT_TRUE(result.saw_set_report_reply);
ASSERT_GE(result.output.callback_count, 1U);
EXPECT_EQ(result.output.last.kind, lvh::GamepadOutputKind::rumble);
@@ -857,10 +852,10 @@ TEST_F(LinuxBackendTest, SocketpairBackedDualShock4BluetoothFramesReports) {
EXPECT_TRUE(result.creation.saw_create);
EXPECT_TRUE(result.creation.waited_for_start);
EXPECT_EQ(result.creation.name, "Wireless Controller");
- EXPECT_TRUE(result.saw_dualshock4_bluetooth_input);
- EXPECT_TRUE(result.saw_dualshock4_calibration);
- EXPECT_TRUE(result.saw_dualshock4_pairing);
- EXPECT_TRUE(result.saw_dualshock4_feature_crc);
+ EXPECT_TRUE(result.dualshock4.saw_bluetooth_input);
+ EXPECT_TRUE(result.dualshock4.saw_calibration);
+ EXPECT_TRUE(result.dualshock4.saw_pairing);
+ EXPECT_TRUE(result.dualshock4.saw_feature_crc);
}
TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) {
@@ -984,15 +979,13 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc
std::uint16_t product_id;
bool key_record;
bool sparse_button_slots;
- bool switch_controls;
};
constexpr std::array gamepad_cases {
- GamepadCase {generic, BUS_USB, 0x1209, 0x0001, true, false, false},
- GamepadCase {xbox_360, BUS_BLUETOOTH, 0x045E, 0x028E, false, true, false},
- GamepadCase {xbox_one, BUS_BLUETOOTH, 0x045E, 0x0B20, false, true, false},
- GamepadCase {xbox_series, BUS_BLUETOOTH, 0x045E, 0x0B13, true, true, false},
- GamepadCase {switch_pro, BUS_USB, 0x057E, 0x2009, false, false, true},
+ GamepadCase {generic, BUS_USB, 0x1209, 0x0001, true, false},
+ GamepadCase {xbox_360, BUS_BLUETOOTH, 0x045E, 0x028E, false, true},
+ GamepadCase {xbox_one, BUS_BLUETOOTH, 0x045E, 0x0B20, false, true},
+ GamepadCase {xbox_series, BUS_BLUETOOTH, 0x045E, 0x0B13, true, true},
};
constexpr std::array active_buttons {
BTN_SOUTH,
@@ -1012,7 +1005,7 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc
constexpr std::array feedback_codes {FF_RUMBLE, FF_CONSTANT, FF_PERIODIC, FF_SINE, FF_RAMP, FF_GAIN};
- for (const auto &[kind, bustype, vendor_id, product_id, key_record, sparse_button_slots, switch_controls] : gamepad_cases) {
+ for (const auto &[kind, bustype, vendor_id, product_id, key_record, sparse_button_slots] : gamepad_cases) {
const auto expected_profile = lvh::profiles::gamepad_profile(kind);
ASSERT_TRUE(expected_profile.has_value());
const auto gamepad = lvh::detail::test::linux_uinput_create_fake_gamepad(kind);
@@ -1029,7 +1022,7 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc
EXPECT_NE(find_code(gamepad, EV_KEY, button), nullptr) << "missing canonical gamepad button " << button;
}
for (const auto button : reserved_buttons) {
- const auto expected = sparse_button_slots || (switch_controls && button != BTN_C);
+ const auto expected = sparse_button_slots;
EXPECT_EQ(find_code(gamepad, EV_KEY, button) != nullptr, expected)
<< "unexpected reserved gamepad button slot state for " << button;
}
@@ -1041,16 +1034,9 @@ TEST_F(LinuxBackendTest, FakeUinputConstructionCoversCapabilitiesAndFailureBranc
EXPECT_NE(find_code(gamepad, EV_ABS, ABS_HAT0Y), nullptr);
EXPECT_EQ(find_code(gamepad, EV_KEY, KEY_RECORD) != nullptr, key_record);
const auto *left_trigger = find_code(gamepad, EV_ABS, ABS_Z);
- if (switch_controls) {
- EXPECT_EQ(left_trigger, nullptr);
- EXPECT_NE(find_code(gamepad, EV_KEY, BTN_TL2), nullptr);
- EXPECT_NE(find_code(gamepad, EV_KEY, BTN_TR2), nullptr);
- EXPECT_NE(find_code(gamepad, EV_KEY, BTN_Z), nullptr);
- } else {
- ASSERT_NE(left_trigger, nullptr);
- EXPECT_EQ(left_trigger->minimum, 0);
- EXPECT_EQ(left_trigger->maximum, 255);
- }
+ ASSERT_NE(left_trigger, nullptr);
+ EXPECT_EQ(left_trigger->minimum, 0);
+ EXPECT_EQ(left_trigger->maximum, 255);
for (const auto code : feedback_codes) {
EXPECT_EQ(find_code(gamepad, EV_FF, code) != nullptr, expected_profile->capabilities.supports_rumble);
}
diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp
index b733de7..5ac5de7 100644
--- a/tests/unit/test_linux_consumers.cpp
+++ b/tests/unit/test_linux_consumers.cpp
@@ -67,6 +67,7 @@ namespace {
int minimum_buttons = 1;
int minimum_axes = 2;
bool require_sdl_rumble = false;
+ bool require_motion = false;
bool expect_live_input = true;
};
@@ -335,7 +336,7 @@ namespace {
}
}
- int wait_for_sdl_joystick(const lvh::DeviceProfile &profile, bool require_rumble) {
+ int wait_for_sdl_game_controller(const lvh::DeviceProfile &profile, bool require_rumble) {
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3};
while (std::chrono::steady_clock::now() < deadline) {
@@ -345,6 +346,7 @@ namespace {
for (int index = 0; index < joystick_count; ++index) {
if (
sdl_joystick_matches_profile(index, profile) &&
+ SDL_IsGameController(index) == SDL_TRUE &&
(!require_rumble || sdl_joystick_supports_rumble(index))
) {
return index;
@@ -479,6 +481,7 @@ namespace {
SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS4_RUMBLE", "1");
SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5", "1");
SDL_SetHint("SDL_JOYSTICK_HIDAPI_PS5_RUMBLE", "1");
+ SDL_SetHint("SDL_JOYSTICK_HIDAPI_SWITCH", "1");
}
lvh::GamepadCreationResult create_sdl_gamepad(lvh::Runtime &runtime, const SdlGamepadConsumerCase &test_case) {
@@ -520,7 +523,25 @@ namespace {
auto created = create_sdl_gamepad(*runtime, test_case);
ASSERT_TRUE(created) << created.status.message();
- const auto joystick_index = wait_for_sdl_joystick(expected_profile, test_case.require_sdl_rumble);
+ if (test_case.require_sdl_rumble) {
+ const auto hidraw_node = wait_for_hidraw_node(*created.gamepad);
+ ASSERT_TRUE(hidraw_node.has_value())
+ << "No hidraw node was discovered. Reported device nodes: "
+ << describe_device_nodes(created.gamepad->device_nodes());
+
+ const auto access_error = wait_for_write_access(*hidraw_node);
+ ASSERT_EQ(access_error, 0)
+ << "hidraw node is not writable: " << errno_message(access_error)
+ << " (errno=" << access_error << "); " << describe_node_permissions(*hidraw_node);
+ errno = 0;
+ const auto read_result = ::access(hidraw_node->c_str(), R_OK);
+ const auto read_error = errno;
+ ASSERT_EQ(read_result, 0)
+ << "hidraw node is not readable: " << errno_message(read_error)
+ << " (errno=" << read_error << "); " << describe_node_permissions(*hidraw_node);
+ }
+
+ const auto joystick_index = wait_for_sdl_game_controller(expected_profile, test_case.require_sdl_rumble);
ASSERT_GE(joystick_index, 0);
test_body(expected_profile, joystick_index, *created.gamepad);
@@ -550,6 +571,41 @@ namespace {
EXPECT_GT(rumble->high_frequency.load(), 0);
}
+ void expect_sdl_motion_input(SDL_GameController *controller, lvh::Gamepad &gamepad) {
+ ASSERT_EQ(SDL_GameControllerHasSensor(controller, SDL_SENSOR_ACCEL), SDL_TRUE);
+ ASSERT_EQ(SDL_GameControllerHasSensor(controller, SDL_SENSOR_GYRO), SDL_TRUE);
+ ASSERT_EQ(SDL_GameControllerSetSensorEnabled(controller, SDL_SENSOR_ACCEL, SDL_TRUE), 0) << SDL_GetError();
+ ASSERT_EQ(SDL_GameControllerSetSensorEnabled(controller, SDL_SENSOR_GYRO, SDL_TRUE), 0) << SDL_GetError();
+
+ lvh::GamepadState state;
+ state.acceleration = lvh::Vector3 {.x = 9.80665F, .y = 19.6133F, .z = -9.80665F};
+ state.gyroscope = lvh::Vector3 {.x = 10.0F, .y = 20.0F, .z = -30.0F};
+ ASSERT_TRUE(gamepad.submit(state).ok());
+
+ std::array acceleration {};
+ std::array gyroscope {};
+ const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3};
+ while (std::chrono::steady_clock::now() < deadline) {
+ SDL_GameControllerUpdate();
+ pump_sdl_events();
+ ASSERT_EQ(
+ SDL_GameControllerGetSensorData(controller, SDL_SENSOR_ACCEL, acceleration.data(), acceleration.size()),
+ 0
+ ) << SDL_GetError();
+ ASSERT_EQ(
+ SDL_GameControllerGetSensorData(controller, SDL_SENSOR_GYRO, gyroscope.data(), gyroscope.size()),
+ 0
+ ) << SDL_GetError();
+ if (std::abs(acceleration[1]) > 15.0F && std::abs(gyroscope[2]) > 0.25F) {
+ return;
+ }
+ std::this_thread::sleep_for(std::chrono::milliseconds {20});
+ }
+
+ ADD_FAILURE() << "SDL motion remained static: accel=" << acceleration[0] << "," << acceleration[1] << ","
+ << acceleration[2] << " gyro=" << gyroscope[0] << "," << gyroscope[1] << "," << gyroscope[2];
+ }
+
void expect_hidraw_rumble_callback(const lvh::DeviceProfile &profile, lvh::Gamepad &gamepad) {
const auto rumble = observe_rumble(gamepad);
const auto hidraw_node = wait_for_hidraw_node(gamepad);
@@ -713,6 +769,9 @@ namespace {
}
EXPECT_GT(SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERLEFT), 0);
EXPECT_GT(SDL_GameControllerGetAxis(controller.get(), SDL_CONTROLLER_AXIS_TRIGGERRIGHT), 16000);
+ if (test_case.require_motion) {
+ expect_sdl_motion_input(controller.get(), gamepad);
+ }
expect_sdl_rumble_callback(controller.get(), gamepad);
}
@@ -851,14 +910,16 @@ TEST_F(LinuxConsumerTest, SdlSeesXboxSeriesCanonicalButtons) {
}
TEST_F(LinuxConsumerTest, SdlSeesSwitchProCanonicalButtons) {
- ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput"));
+ ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid"));
run_sdl_canonical_gamepad_test({
.profile = lvh::profiles::switch_pro(),
.name_suffix = "SDL Switch Pro",
- .stable_id = "libvirtualhid-sdl-switch-pro-test",
+ .stable_id = "02:00:00:00:00:05",
.minimum_buttons = 14,
.minimum_axes = 4,
+ .require_sdl_rumble = true,
+ .require_motion = true,
});
}
diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp
index 4b603bf..ea5bbe2 100644
--- a/tests/unit/test_profiles.cpp
+++ b/tests/unit/test_profiles.cpp
@@ -234,12 +234,14 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) {
EXPECT_EQ(switch_pro.vendor_id, 0x057E);
EXPECT_EQ(switch_pro.product_id, 0x2009);
+ EXPECT_EQ(switch_pro.version, 0x0210);
EXPECT_EQ(switch_pro.manufacturer, "Nintendo Co., Ltd.");
EXPECT_EQ(switch_pro.report_id, 0x30);
EXPECT_EQ(switch_pro.input_report_size, 64U);
EXPECT_EQ(switch_pro.output_report_size, 64U);
EXPECT_TRUE(switch_pro.capabilities.supports_rumble);
EXPECT_TRUE(switch_pro.capabilities.supports_motion);
+ EXPECT_TRUE(switch_pro.capabilities.supports_rgb_led);
EXPECT_TRUE(switch_pro.capabilities.supports_battery);
const auto generic = lvh::profiles::generic_gamepad();
diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp
index 06a73a4..55cc015 100644
--- a/tests/unit/test_report.cpp
+++ b/tests/unit/test_report.cpp
@@ -180,7 +180,6 @@ TEST(ReportTest, PacksSwitchProReport) {
ASSERT_EQ(report.size(), profile.input_report_size);
EXPECT_EQ(report[0], 0x30U);
- EXPECT_EQ(report[1], 0x00U); // Packet timer.
EXPECT_EQ(report[2], 0x81U); // Full battery and USB connection when battery state is unknown.
EXPECT_EQ(report[3], 0x0CU); // B and A.
EXPECT_EQ(report[4], 0x32U); // Plus, Home, and Capture.
@@ -193,6 +192,48 @@ TEST(ReportTest, PacksSwitchProReport) {
EXPECT_EQ(report[9], 0xFFU);
EXPECT_EQ(report[10], 0x0BU);
EXPECT_EQ(report[11], 0x40U);
+
+ // Each full report carries three native IMU samples. A missing sensor value
+ // represents a stationary controller under one g of gravity.
+ for (const auto offset : {13U, 25U, 37U}) {
+ EXPECT_EQ(read_i16_le(report, offset), 0);
+ EXPECT_EQ(read_i16_le(report, offset + 2U), 0);
+ EXPECT_EQ(read_i16_le(report, offset + 4U), 4096);
+ EXPECT_EQ(read_i16_le(report, offset + 6U), 0);
+ EXPECT_EQ(read_i16_le(report, offset + 8U), 0);
+ EXPECT_EQ(read_i16_le(report, offset + 10U), 0);
+ }
+}
+
+TEST(ReportTest, AdvancesSwitchProPacketTimerForEachInputReport) {
+ const auto profile = lvh::profiles::switch_pro();
+
+ const auto first = lvh::reports::pack_input_report(profile, {});
+ const auto second = lvh::reports::pack_input_report(profile, {});
+
+ ASSERT_EQ(first.size(), profile.input_report_size);
+ ASSERT_EQ(second.size(), profile.input_report_size);
+ EXPECT_EQ(second[1], static_cast(first[1] + 1U));
+}
+
+TEST(ReportTest, PacksSwitchProMotionInEveryImuSample) {
+ const auto profile = lvh::profiles::switch_pro();
+
+ lvh::GamepadState state;
+ state.acceleration = lvh::Vector3 {.x = 9.80665F, .y = 19.6133F, .z = -9.80665F};
+ state.gyroscope = lvh::Vector3 {.x = 1.0F, .y = 2.0F, .z = -3.0F};
+
+ const auto report = lvh::reports::pack_input_report(profile, state);
+
+ ASSERT_EQ(report.size(), profile.input_report_size);
+ for (const auto offset : {13U, 25U, 37U}) {
+ EXPECT_EQ(read_i16_le(report, offset), 4096);
+ EXPECT_EQ(read_i16_le(report, offset + 2U), -4096);
+ EXPECT_EQ(read_i16_le(report, offset + 4U), 8192);
+ EXPECT_EQ(read_i16_le(report, offset + 6U), 43);
+ EXPECT_EQ(read_i16_le(report, offset + 8U), -14);
+ EXPECT_EQ(read_i16_le(report, offset + 10U), 29);
+ }
}
TEST(ReportTest, PacksXboxGipNeutralReport) {
@@ -665,6 +706,158 @@ TEST(ReportTest, ParsesSwitchProRumbleFromSubcommandReport) {
EXPECT_EQ(output.raw_report, report);
}
+TEST(ReportTest, ParsesSwitchProPlayerLightsAlongsideRumble) {
+ const auto profile = lvh::profiles::switch_pro();
+ const std::vector report {
+ 0x01,
+ 0x07,
+ 0x00,
+ 0x01,
+ 0x40,
+ 0x40,
+ 0x00,
+ 0x01,
+ 0x40,
+ 0x40,
+ 0x30,
+ 0xA5,
+ };
+
+ const auto outputs = lvh::reports::parse_output_reports(profile, report);
+
+ ASSERT_EQ(outputs.size(), 2U);
+ EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::rumble);
+ EXPECT_EQ(outputs[0].low_frequency_rumble, 0U);
+ EXPECT_EQ(outputs[0].high_frequency_rumble, 0U);
+ EXPECT_EQ(outputs[1].kind, lvh::GamepadOutputKind::player_leds);
+ EXPECT_EQ(outputs[1].player_leds, (std::array {true, false, true, false}));
+ EXPECT_EQ(outputs[1].flashing_player_leds, (std::array {false, true, false, true}));
+ EXPECT_EQ(outputs[1].raw_report, report);
+}
+
+TEST(ReportTest, ParsesSwitchProPlayerLightsWithMalformedRumbleData) {
+ const auto profile = lvh::profiles::switch_pro();
+ const std::vector report {
+ 0x01,
+ 0x00,
+ 0x74,
+ 0x1A,
+ 0x3D,
+ 0x20,
+ 0x74,
+ 0x1A,
+ 0x3D,
+ 0x59,
+ 0x30,
+ 0x03,
+ };
+
+ const auto outputs = lvh::reports::parse_output_reports(profile, report);
+
+ ASSERT_EQ(outputs.size(), 1U);
+ EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::player_leds);
+ EXPECT_EQ(outputs[0].player_leds, (std::array {true, true, false, false}));
+ EXPECT_EQ(outputs[0].flashing_player_leds, (std::array {false, false, false, false}));
+}
+
+TEST(ReportTest, ParsesSwitchProHomeLightAlongsideRumble) {
+ const auto profile = lvh::profiles::switch_pro();
+ const std::vector report {
+ 0x01,
+ 0x09,
+ 0x00,
+ 0x01,
+ 0x40,
+ 0x40,
+ 0x00,
+ 0x01,
+ 0x40,
+ 0x40,
+ 0x38,
+ 0x01,
+ 0xF0,
+ 0xF0,
+ 0x00,
+ };
+
+ const auto outputs = lvh::reports::parse_output_reports(profile, report);
+
+ ASSERT_EQ(outputs.size(), 2U);
+ EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::rumble);
+ EXPECT_EQ(outputs[1].kind, lvh::GamepadOutputKind::rgb_led);
+ EXPECT_EQ(outputs[1].red, 255U);
+ EXPECT_EQ(outputs[1].green, 255U);
+ EXPECT_EQ(outputs[1].blue, 255U);
+ EXPECT_EQ(outputs[1].raw_report, report);
+}
+
+TEST(ReportTest, ParsesSwitchProHomeLightWithMalformedRumbleData) {
+ const auto profile = lvh::profiles::switch_pro();
+ const std::vector report {
+ 0x01,
+ 0x00,
+ 0x74,
+ 0x1A,
+ 0x3D,
+ 0x20,
+ 0x74,
+ 0x1A,
+ 0x3D,
+ 0x59,
+ 0x38,
+ 0x01,
+ 0x30,
+ 0x30,
+ 0x00,
+ };
+
+ const auto outputs = lvh::reports::parse_output_reports(profile, report);
+
+ ASSERT_EQ(outputs.size(), 1U);
+ EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::rgb_led);
+ EXPECT_EQ(outputs[0].red, 77U);
+ EXPECT_EQ(outputs[0].green, 77U);
+ EXPECT_EQ(outputs[0].blue, 77U);
+}
+
+TEST(ReportTest, ParsesSwitchProHomeLightIntensityCurve) {
+ const auto profile = lvh::profiles::switch_pro();
+ std::vector report {
+ 0x01,
+ 0x00,
+ 0x74,
+ 0x1A,
+ 0x3D,
+ 0x20,
+ 0x74,
+ 0x1A,
+ 0x3D,
+ 0x59,
+ 0x38,
+ 0x01,
+ 0x00,
+ 0x00,
+ 0x00,
+ };
+ const std::array intensity_cases {
+ std::array {0x00, 0},
+ std::array {0x80, 184},
+ };
+
+ for (const auto &[encoded_intensity, expected_intensity] : intensity_cases) {
+ report[12] = encoded_intensity;
+ report[13] = encoded_intensity;
+
+ const auto outputs = lvh::reports::parse_output_reports(profile, report);
+
+ ASSERT_EQ(outputs.size(), 1U);
+ EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::rgb_led);
+ EXPECT_EQ(outputs[0].red, expected_intensity);
+ EXPECT_EQ(outputs[0].green, expected_intensity);
+ EXPECT_EQ(outputs[0].blue, expected_intensity);
+ }
+}
+
TEST(ReportTest, ParsesSwitchProNeutralRumbleReport) {
const auto profile = lvh::profiles::switch_pro();
const std::vector report {
diff --git a/tests/unit/test_virtualhid_control_model.cpp b/tests/unit/test_virtualhid_control_model.cpp
index 720124d..a5f1d2c 100644
--- a/tests/unit/test_virtualhid_control_model.cpp
+++ b/tests/unit/test_virtualhid_control_model.cpp
@@ -58,6 +58,7 @@ TEST(VirtualHidControlModelTest, NamesKnownAndFallbackEnumValues) {
EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::adaptive_triggers), L"adaptive triggers");
EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::raw_report), L"raw report");
EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::trigger_rumble), L"trigger rumble");
+ EXPECT_EQ(control::output_kind_name(lvh::GamepadOutputKind::player_leds), L"player leds");
EXPECT_EQ(control::output_kind_name(static_cast(255)), L"raw report");
EXPECT_EQ(control::battery_state_name(lvh::GamepadBatteryState::unknown), L"unknown");
@@ -191,11 +192,11 @@ TEST(VirtualHidControlModelTest, SummarizesProfileFeatures) {
EXPECT_EQ(
control::profile_feature_summary(generic),
- L"Features: battery no | rumble yes | trigger rumble no | RGB LED no | adaptive triggers no | raw output yes"
+ L"Features: battery no | rumble yes | trigger rumble no | RGB LED no | player LEDs no | adaptive triggers no | raw output yes"
);
EXPECT_EQ(
control::profile_feature_summary(dualsense),
- L"Features: battery yes | rumble yes | trigger rumble no | RGB LED yes | adaptive triggers yes | raw output yes"
+ L"Features: battery yes | rumble yes | trigger rumble no | RGB LED yes | player LEDs no | adaptive triggers yes | raw output yes"
);
EXPECT_EQ(
control::device_feature_summary(mouse),
@@ -256,10 +257,13 @@ TEST(VirtualHidControlModelTest, SummarizesOutputState) {
state.latest_rgb_led->blue = 3;
state.latest_adaptive_triggers = output(adaptive_triggers);
state.latest_adaptive_triggers->adaptive_trigger_flags = 4;
+ state.latest_player_leds = output(player_leds);
+ state.latest_player_leds->player_leds = {true, false, true, false};
+ state.latest_player_leds->flashing_player_leds = {false, true, false, true};
EXPECT_EQ(
control::output_summary(state, dualsense),
- L"Output: rumble low=10 high=20 | trigger rumble L=30 R=40 | RGB 1,2,3 | adaptive flags=4"
+ L"Output: rumble low=10 high=20 | trigger rumble L=30 R=40 | RGB 1,2,3 | adaptive flags=4 | player LEDs solid=1010 flashing=0101"
);
}
@@ -287,14 +291,18 @@ TEST(VirtualHidControlModelTest, RecordsOutputsAndMaintainsLatestSummaryFields)
adaptive.adaptive_trigger_flags = 8;
control::record_output(state, adaptive, next_sequence, 3);
+ auto player_leds = output(lvh::GamepadOutputKind::player_leds);
+ player_leds.player_leds = {true, false, true, false};
+ control::record_output(state, player_leds, next_sequence, 3);
+
auto raw = output(lvh::GamepadOutputKind::raw_report);
raw.raw_report = {0x12, 0x34};
control::record_output(state, raw, next_sequence, 3);
ASSERT_EQ(state.outputs.size(), 3U);
- EXPECT_EQ(state.outputs.front().sequence, 9U);
- EXPECT_EQ(state.outputs.back().sequence, 11U);
- EXPECT_EQ(next_sequence, 12U);
+ EXPECT_EQ(state.outputs.front().sequence, 10U);
+ EXPECT_EQ(state.outputs.back().sequence, 12U);
+ EXPECT_EQ(next_sequence, 13U);
ASSERT_TRUE(state.latest_rumble.has_value());
EXPECT_EQ(state.latest_rumble->low_frequency_rumble, 100);
@@ -304,6 +312,8 @@ TEST(VirtualHidControlModelTest, RecordsOutputsAndMaintainsLatestSummaryFields)
EXPECT_EQ(state.latest_rgb_led->blue, 7);
ASSERT_TRUE(state.latest_adaptive_triggers.has_value());
EXPECT_EQ(state.latest_adaptive_triggers->adaptive_trigger_flags, 8);
+ ASSERT_TRUE(state.latest_player_leds.has_value());
+ EXPECT_EQ(state.latest_player_leds->player_leds, (std::array {true, false, true, false}));
ASSERT_TRUE(state.latest_raw_report.has_value());
EXPECT_EQ(state.latest_raw_report->raw_report, (std::vector {0x12, 0x34}));
}
diff --git a/tests/unit/test_windows_backend.cpp b/tests/unit/test_windows_backend.cpp
index a695e47..a2e5bce 100644
--- a/tests/unit/test_windows_backend.cpp
+++ b/tests/unit/test_windows_backend.cpp
@@ -105,6 +105,19 @@ TEST_F(WindowsBackendTest, FakeChannelExercisesLifecycleSubmitCloseAndOutput) {
EXPECT_EQ(result.last_output.raw_report[0], 0x03U);
}
+TEST_F(WindowsBackendTest, SwitchReportsStreamAtTheNativeCadence) {
+ const auto result = lvh::detail::test::windows_backend_switch_report_stream();
+
+ expect_ok(result.create_status);
+ expect_ok(result.submit_status);
+ ASSERT_TRUE(result.repeated_report);
+ expect_ok(result.close_status);
+ ASSERT_GE(result.submitted_reports.size(), 2U);
+ EXPECT_TRUE(std::ranges::all_of(result.submitted_reports, [&result](const auto &report) {
+ return report == result.expected_report;
+ }));
+}
+
TEST_F(WindowsBackendTest, PlayStationDefaultsUseEffectiveUsbProfiles) {
const auto result = lvh::detail::test::windows_backend_playstation_transport();
const auto dualshock4_usb = lvh::profiles::dualshock4_usb();
diff --git a/tests/unit/test_windows_consumers.cpp b/tests/unit/test_windows_consumers.cpp
index 4ba26aa..8704109 100644
--- a/tests/unit/test_windows_consumers.cpp
+++ b/tests/unit/test_windows_consumers.cpp
@@ -29,6 +29,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -59,6 +60,7 @@ namespace {
std::wstring path;
std::uint16_t vendor_id = 0;
std::uint16_t product_id = 0;
+ std::uint16_t version_number = 0;
std::uint16_t usage = 0;
std::uint16_t input_report_size = 0;
std::uint16_t output_report_size = 0;
@@ -256,6 +258,7 @@ namespace {
.path = detail->DevicePath,
.vendor_id = attributes.VendorID,
.product_id = attributes.ProductID,
+ .version_number = attributes.VersionNumber,
.usage = capabilities.Usage,
.input_report_size = capabilities.InputReportByteLength,
.output_report_size = capabilities.OutputReportByteLength,
@@ -317,6 +320,30 @@ namespace {
return report;
}
+ template
+ std::optional> read_hid_report_matching(
+ HANDLE hid,
+ std::size_t report_size,
+ std::chrono::milliseconds timeout,
+ Predicate predicate
+ ) {
+ const auto deadline = std::chrono::steady_clock::now() + timeout;
+ while (std::chrono::steady_clock::now() < deadline) {
+ const auto remaining = std::chrono::duration_cast(
+ deadline - std::chrono::steady_clock::now()
+ );
+ auto report = read_hid_report_with_timeout(hid, report_size, std::max(remaining, 1ms));
+ if (!report.has_value()) {
+ return std::nullopt;
+ }
+ if (predicate(*report)) {
+ return report;
+ }
+ }
+
+ return std::nullopt;
+ }
+
HidInterfacePaths current_gamepad_interface_paths() {
HidInterfacePaths paths;
for (const auto &hid_interface : enumerate_gamepad_interfaces()) {
@@ -355,6 +382,16 @@ namespace {
});
}
+ std::optional wait_for_player_leds(
+ const std::array &solid,
+ const std::array &flashing
+ ) {
+ return wait_for([&solid, &flashing](const lvh::GamepadOutput &output) {
+ return output.kind == lvh::GamepadOutputKind::player_leds && output.player_leds == solid &&
+ output.flashing_player_leds == flashing;
+ });
+ }
+
private:
template
std::optional wait_for(Predicate matches) {
@@ -450,6 +487,25 @@ namespace {
}
return {nullptr, &SDL_CloseGamepad};
}
+
+ bool wait_for_switch_pro_motion(SDL_Gamepad *gamepad) {
+ std::array acceleration {};
+ std::array gyroscope {};
+ const auto deadline = std::chrono::steady_clock::now() + 5s;
+ while (std::chrono::steady_clock::now() < deadline) {
+ SDL_UpdateGamepads();
+ SDL_PumpEvents();
+ if (
+ SDL_GetGamepadSensorData(gamepad, SDL_SENSOR_ACCEL, acceleration.data(), acceleration.size()) &&
+ SDL_GetGamepadSensorData(gamepad, SDL_SENSOR_GYRO, gyroscope.data(), gyroscope.size()) &&
+ std::abs(acceleration[1]) > 15.0F && std::abs(gyroscope[2]) > 0.25F
+ ) {
+ return true;
+ }
+ std::this_thread::sleep_for(20ms);
+ }
+ return false;
+ }
#endif
} // namespace
@@ -505,6 +561,30 @@ TEST_F(WindowsConsumerTest, SdlHidapiOutputReachesDefaultPlayStationAndSwitchCal
const auto rgb_led = output_capture.wait_for_rgb_led(0x12U, 0x34U, 0x56U);
ASSERT_TRUE(rgb_led.has_value()) << "No normalized RGB LED callback followed SDL HIDAPI LED output";
}
+ if (profile.gamepad_kind == lvh::GamepadProfileKind::switch_pro) {
+ ASSERT_TRUE(SDL_GetBooleanProperty(properties, SDL_PROP_GAMEPAD_CAP_MONO_LED_BOOLEAN, false)) << SDL_GetError();
+ ASSERT_TRUE(SDL_SetGamepadLED(gamepad.get(), 0xFFU, 0xFFU, 0xFFU)) << SDL_GetError();
+ const auto home_led = output_capture.wait_for_rgb_led(0xFFU, 0xFFU, 0xFFU);
+ ASSERT_TRUE(home_led.has_value()) << "No normalized HOME LED callback followed SDL HIDAPI LED output";
+
+ ASSERT_TRUE(SDL_GamepadHasSensor(gamepad.get(), SDL_SENSOR_ACCEL)) << SDL_GetError();
+ ASSERT_TRUE(SDL_GamepadHasSensor(gamepad.get(), SDL_SENSOR_GYRO)) << SDL_GetError();
+ ASSERT_TRUE(SDL_SetGamepadSensorEnabled(gamepad.get(), SDL_SENSOR_ACCEL, true)) << SDL_GetError();
+ ASSERT_TRUE(SDL_SetGamepadSensorEnabled(gamepad.get(), SDL_SENSOR_GYRO, true)) << SDL_GetError();
+ ASSERT_TRUE(created.adapter->set_motion(
+ lvh::Vector3 {.x = 9.80665F, .y = 19.6133F, .z = -9.80665F},
+ lvh::Vector3 {.x = 10.0F, .y = 20.0F, .z = -30.0F}
+ )
+ .ok());
+ ASSERT_TRUE(wait_for_switch_pro_motion(gamepad.get())) << "SDL HIDAPI did not receive live Switch Pro motion";
+
+ ASSERT_TRUE(SDL_SetGamepadPlayerIndex(gamepad.get(), 2)) << SDL_GetError();
+ const auto player_leds = output_capture.wait_for_player_leds(
+ std::array {true, true, true, false},
+ std::array {false, false, false, false}
+ );
+ ASSERT_TRUE(player_leds.has_value()) << "No normalized player LED callback followed SDL HIDAPI player-index output";
+ }
ASSERT_TRUE(SDL_GetBooleanProperty(properties, SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, false)) << SDL_GetError();
ASSERT_TRUE(SDL_RumbleGamepad(gamepad.get(), 0x5678U, 0x1234U, 1000U)) << SDL_GetError();
@@ -677,12 +757,14 @@ TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
lvh::CreateGamepadOptions options;
options.profile = profile;
+ options.metadata.stable_id = "02:00:00:00:00:05";
auto created = lvh::GamepadStateAdapter::create(*runtime, options);
ASSERT_TRUE(created) << created.status.message();
GamepadOutputCapture output_capture;
output_capture.attach(*created.adapter);
const auto hid_interface = wait_for_new_interface(previous_paths, profile.vendor_id, profile.product_id);
ASSERT_TRUE(hid_interface.has_value()) << "The VHF Switch Pro HID interface was not enumerated";
+ ASSERT_EQ(hid_interface->version_number, profile.version);
ASSERT_EQ(hid_interface->input_report_size, profile.input_report_size);
ASSERT_EQ(hid_interface->output_report_size, profile.output_report_size);
@@ -722,7 +804,14 @@ TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
)) << "Switch proprietary WriteFile failed: "
<< GetLastError();
EXPECT_EQ(bytes_written, output.size());
- return read_hid_report_with_timeout(reader.get(), hid_interface->input_report_size, 5s);
+ return read_hid_report_matching(
+ reader.get(),
+ hid_interface->input_report_size,
+ 5s,
+ [command](const auto &report) {
+ return report.size() >= 2U && report[0] == 0x81U && report[1] == command;
+ }
+ );
};
const auto status_reply = send_proprietary_command(0x01);
@@ -769,6 +858,55 @@ TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
EXPECT_EQ(rumble->high_frequency_rumble, 5213U);
EXPECT_EQ(rumble->raw_report, rumble_report);
+ std::vector player_lights_report(hid_interface->output_report_size, 0);
+ player_lights_report[0] = 0x01;
+ player_lights_report[1] = 0x08;
+ constexpr std::array neutral_rumble {
+ std::uint8_t {0x00},
+ std::uint8_t {0x01},
+ std::uint8_t {0x40},
+ std::uint8_t {0x40},
+ std::uint8_t {0x00},
+ std::uint8_t {0x01},
+ std::uint8_t {0x40},
+ std::uint8_t {0x40},
+ };
+ std::ranges::copy(neutral_rumble, player_lights_report.begin() + 2);
+ player_lights_report[10] = 0x30;
+ player_lights_report[11] = 0xA5;
+ bytes_written = 0;
+ ASSERT_TRUE(WriteFile(
+ writer.get(),
+ player_lights_report.data(),
+ static_cast(player_lights_report.size()),
+ &bytes_written,
+ nullptr
+ )) << "Switch player-light WriteFile failed: "
+ << GetLastError();
+ ASSERT_EQ(bytes_written, player_lights_report.size());
+
+ const auto player_lights_reply = read_hid_report_matching(
+ reader.get(),
+ hid_interface->input_report_size,
+ 5s,
+ [](const auto &report) {
+ return report.size() >= 15U && report[0] == 0x21U && report[14] == 0x30U;
+ }
+ );
+ ASSERT_TRUE(player_lights_reply.has_value()) << "No Switch player-light acknowledgement reached the HID client";
+ ASSERT_GE(player_lights_reply->size(), 15U);
+ EXPECT_EQ(player_lights_reply->at(0), 0x21U);
+ EXPECT_EQ(player_lights_reply->at(13), 0x80U);
+ EXPECT_EQ(player_lights_reply->at(14), 0x30U);
+
+ const auto player_lights = output_capture.wait_for_player_leds(
+ {true, false, true, false},
+ {false, true, false, true}
+ );
+ ASSERT_TRUE(player_lights.has_value())
+ << "No normalized player-light callback followed the native Switch 0x30 output write";
+ EXPECT_EQ(player_lights->raw_report, player_lights_report);
+
lvh::GamepadState state;
state.buttons.set(lvh::GamepadButton::a);
state.buttons.set(lvh::GamepadButton::guide);
@@ -776,9 +914,18 @@ TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
state.buttons.set(lvh::GamepadButton::right_stick);
state.left_stick = {.x = 1.0F, .y = 0.0F};
state.right_stick = {.x = 0.0F, .y = -1.0F};
+ state.acceleration = lvh::Vector3 {.x = 9.80665F, .y = 19.6133F, .z = -9.80665F};
+ state.gyroscope = lvh::Vector3 {.x = 1.0F, .y = 2.0F, .z = -3.0F};
ASSERT_TRUE(created.adapter->set_state(state).ok());
- const auto input = read_hid_report_with_timeout(reader.get(), hid_interface->input_report_size, 5s);
+ const auto input = read_hid_report_matching(
+ reader.get(),
+ hid_interface->input_report_size,
+ 5s,
+ [](const auto &report) {
+ return report.size() >= 6U && report[0] == 0x30U && report[3] == 0x08U && report[4] == 0x14U && report[5] == 0x08U;
+ }
+ );
ASSERT_TRUE(input.has_value()) << "No native Switch 0x30 input report reached the HID client";
ASSERT_EQ(input->size(), profile.input_report_size);
EXPECT_EQ(input->at(0), 0x30U);
@@ -791,6 +938,35 @@ TEST_F(WindowsConsumerTest, NativeSwitchHandshakeAndInputReportReachHidClient) {
EXPECT_EQ(input->at(9), 0x00U);
EXPECT_EQ(input->at(10), 0x08U);
EXPECT_EQ(input->at(11), 0x00U);
+ const auto read_i16 = [&input](std::size_t offset) {
+ return static_cast(
+ static_cast(input->at(offset)) |
+ (static_cast(input->at(offset + 1U)) << 8U)
+ );
+ };
+ for (const auto offset : {13U, 25U, 37U}) {
+ EXPECT_EQ(read_i16(offset), 4096);
+ EXPECT_EQ(read_i16(offset + 2U), -4096);
+ EXPECT_EQ(read_i16(offset + 4U), 8192);
+ EXPECT_EQ(read_i16(offset + 6U), 43);
+ EXPECT_EQ(read_i16(offset + 8U), -14);
+ EXPECT_EQ(read_i16(offset + 10U), 29);
+ }
+
+ state.gyroscope = lvh::Vector3 {.x = -4.0F, .y = 5.0F, .z = 6.0F};
+ ASSERT_TRUE(created.adapter->set_state(state).ok());
+ const auto next_input = read_hid_report_matching(
+ reader.get(),
+ hid_interface->input_report_size,
+ 5s,
+ [&input](const auto &report) {
+ return report.size() > 19U && report[0] == 0x30U && report[19] != input->at(19);
+ }
+ );
+ ASSERT_TRUE(next_input.has_value()) << "No second native Switch motion report reached the HID client";
+ ASSERT_EQ(next_input->size(), profile.input_report_size);
+ EXPECT_NE(next_input->at(1), input->at(1));
+ EXPECT_NE(next_input->at(19), input->at(19));
ASSERT_TRUE(created.adapter->close().ok());
}
diff --git a/tests/unit/test_windows_driver_protocol.cpp b/tests/unit/test_windows_driver_protocol.cpp
index 587a564..50a72d8 100644
--- a/tests/unit/test_windows_driver_protocol.cpp
+++ b/tests/unit/test_windows_driver_protocol.cpp
@@ -6,7 +6,7 @@
// local includes
#include "fixtures/fixtures.hpp"
#include "playstation_feature_protocol.hpp"
-#include "switch_pro_protocol.hpp"
+#include "shared/switch_pro_protocol.hpp"
#include "windows_device_identity.hpp"
// standard includes
@@ -58,11 +58,11 @@ namespace {
return request;
}
- std::array switch_output_report(
+ std::array switch_output_report(
std::uint8_t report_id,
std::uint8_t command
) {
- std::array report {};
+ std::array report {};
report[0] = report_id;
if (report_id == 0x80U) {
report[1] = command;
@@ -116,7 +116,7 @@ TEST_F(WindowsDriverProtocolTest, NonSeriesXboxUsesItsPublicIdentityForMatching)
TEST_F(WindowsDriverProtocolTest, SwitchProRepliesToUsbStatusAndHandshakeCommands) {
auto status_report = switch_output_report(0x80, 0x01);
- const auto status_reply = lvh::detail::windows::make_switch_pro_reply(status_report);
+ const auto status_reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply(status_report);
ASSERT_TRUE(status_reply.has_value());
EXPECT_EQ(status_reply->at(0), 0x81);
EXPECT_EQ(status_reply->at(1), 0x01);
@@ -127,23 +127,23 @@ TEST_F(WindowsDriverProtocolTest, SwitchProRepliesToUsbStatusAndHandshakeCommand
for (const auto command : {0x02U, 0x03U}) {
const auto report = switch_output_report(0x80, static_cast(command));
- const auto reply = lvh::detail::windows::make_switch_pro_reply(report);
+ const auto reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply(report);
ASSERT_TRUE(reply.has_value());
EXPECT_EQ(reply->at(0), 0x81);
EXPECT_EQ(reply->at(1), command);
}
const auto force_usb = switch_output_report(0x80, 0x04);
- EXPECT_FALSE(lvh::detail::windows::make_switch_pro_reply(force_usb).has_value());
+ EXPECT_FALSE(lvh::detail::switch_pro_protocol::make_switch_pro_reply(force_usb).has_value());
}
TEST_F(WindowsDriverProtocolTest, SwitchProAcknowledgesInitializationSubcommands) {
for (const auto subcommand : {0x03U, 0x30U, 0x38U, 0x40U, 0x41U, 0x48U}) {
const auto report = switch_output_report(0x01, static_cast(subcommand));
- const auto reply = lvh::detail::windows::make_switch_pro_reply(report);
+ const auto reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply(report, 0x42U);
ASSERT_TRUE(reply.has_value());
EXPECT_EQ(reply->at(0), 0x21);
- EXPECT_EQ(reply->at(1), 0x07);
+ EXPECT_EQ(reply->at(1), 0x42);
EXPECT_EQ(reply->at(2), 0x81);
EXPECT_EQ(reply->at(13), 0x80);
EXPECT_EQ(reply->at(14), subcommand);
@@ -156,12 +156,12 @@ TEST_F(WindowsDriverProtocolTest, SwitchProAcknowledgesInitializationSubcommands
}
const auto rumble_only = switch_output_report(0x10, 0x00);
- EXPECT_FALSE(lvh::detail::windows::make_switch_pro_reply(rumble_only).has_value());
+ EXPECT_FALSE(lvh::detail::switch_pro_protocol::make_switch_pro_reply(rumble_only).has_value());
}
TEST_F(WindowsDriverProtocolTest, SwitchProReturnsDeviceInfoAndFactoryCalibration) {
auto device_info = switch_output_report(0x01, 0x02);
- const auto device_reply = lvh::detail::windows::make_switch_pro_reply(device_info);
+ const auto device_reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply(device_info);
ASSERT_TRUE(device_reply.has_value());
EXPECT_EQ(device_reply->at(13), 0x82);
EXPECT_EQ(device_reply->at(14), 0x02);
@@ -170,7 +170,7 @@ TEST_F(WindowsDriverProtocolTest, SwitchProReturnsDeviceInfoAndFactoryCalibratio
auto stick_read = switch_output_report(0x01, 0x10);
write_u32(stick_read, 11U, 0x603D);
stick_read[15] = 18U;
- const auto stick_reply = lvh::detail::windows::make_switch_pro_reply(stick_read);
+ const auto stick_reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply(stick_read);
ASSERT_TRUE(stick_reply.has_value());
EXPECT_EQ(stick_reply->at(13), 0x90);
EXPECT_EQ(stick_reply->at(14), 0x10);
@@ -200,7 +200,7 @@ TEST_F(WindowsDriverProtocolTest, SwitchProReturnsDeviceInfoAndFactoryCalibratio
auto imu_read = switch_output_report(0x01, 0x10);
write_u32(imu_read, 11U, 0x6020);
imu_read[15] = 24U;
- const auto imu_reply = lvh::detail::windows::make_switch_pro_reply(imu_read);
+ const auto imu_reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply(imu_read);
ASSERT_TRUE(imu_reply.has_value());
EXPECT_TRUE(std::ranges::any_of(imu_reply->begin() + 20, imu_reply->begin() + 44, [](auto value) {
return value != 0U;
@@ -209,7 +209,7 @@ TEST_F(WindowsDriverProtocolTest, SwitchProReturnsDeviceInfoAndFactoryCalibratio
auto user_read = switch_output_report(0x01, 0x10);
write_u32(user_read, 11U, 0x8010);
user_read[15] = 22U;
- const auto user_reply = lvh::detail::windows::make_switch_pro_reply(user_read);
+ const auto user_reply = lvh::detail::switch_pro_protocol::make_switch_pro_reply(user_read);
ASSERT_TRUE(user_reply.has_value());
EXPECT_TRUE(std::ranges::all_of(user_reply->begin() + 20, user_reply->begin() + 42, [](auto value) {
return value == 0U;
diff --git a/tools/virtualhid_control_model.cpp b/tools/virtualhid_control_model.cpp
index 4b57259..8311da3 100644
--- a/tools/virtualhid_control_model.cpp
+++ b/tools/virtualhid_control_model.cpp
@@ -77,6 +77,8 @@ namespace lvh::tools::virtualhid_control {
return L"raw report";
case trigger_rumble:
return L"trigger rumble";
+ case player_leds:
+ return L"player leds";
}
return L"raw report";
}
@@ -223,7 +225,8 @@ namespace lvh::tools::virtualhid_control {
return supports_gamepad_output(profile, rumble) ||
supports_gamepad_output(profile, rgb_led) ||
supports_gamepad_output(profile, adaptive_triggers) ||
- supports_gamepad_output(profile, trigger_rumble);
+ supports_gamepad_output(profile, trigger_rumble) ||
+ supports_gamepad_output(profile, player_leds);
}
std::wstring profile_feature_summary(const DeviceProfile &profile) {
@@ -235,6 +238,7 @@ namespace lvh::tools::virtualhid_control {
stream << L" | rumble " << yes_no(supports_gamepad_output(profile, rumble));
stream << L" | trigger rumble " << yes_no(supports_gamepad_output(profile, trigger_rumble));
stream << L" | RGB LED " << yes_no(supports_gamepad_output(profile, rgb_led));
+ stream << L" | player LEDs " << yes_no(supports_gamepad_output(profile, player_leds));
stream << L" | adaptive triggers " << yes_no(supports_gamepad_output(profile, adaptive_triggers));
stream << L" | raw output " << yes_no(supports_gamepad_output(profile, raw_report));
return stream.str();
@@ -272,6 +276,18 @@ namespace lvh::tools::virtualhid_control {
stream << L"adaptive flags=" << static_cast(state.latest_adaptive_triggers->adaptive_trigger_flags);
wrote = true;
}
+ if (state.latest_player_leds) {
+ append_summary_separator(stream, wrote);
+ stream << L"player LEDs solid=";
+ for (const auto active : state.latest_player_leds->player_leds) {
+ stream << (active ? L'1' : L'0');
+ }
+ stream << L" flashing=";
+ for (const auto active : state.latest_player_leds->flashing_player_leds) {
+ stream << (active ? L'1' : L'0');
+ }
+ wrote = true;
+ }
if (!wrote && state.latest_raw_report) {
stream << L"raw report";
wrote = true;
@@ -339,6 +355,9 @@ namespace lvh::tools::virtualhid_control {
case adaptive_triggers:
state.latest_adaptive_triggers = output;
break;
+ case player_leds:
+ state.latest_player_leds = output;
+ break;
case raw_report:
state.latest_raw_report = output;
break;
diff --git a/tools/virtualhid_control_model.hpp b/tools/virtualhid_control_model.hpp
index 9c97651..ab47824 100644
--- a/tools/virtualhid_control_model.hpp
+++ b/tools/virtualhid_control_model.hpp
@@ -77,6 +77,7 @@ namespace lvh::tools::virtualhid_control {
std::optional latest_trigger_rumble;
std::optional latest_rgb_led;
std::optional latest_adaptive_triggers;
+ std::optional latest_player_leds;
std::optional latest_raw_report;
};