feat: Steam Controller over USB Direct (boot-interface ranking, device restore on release) - #154
Merged
Merged
Conversation
The pad ships emulating a keyboard and mouse, so Android never saw it as a gamepad and picking Direct claimed its keyboard interface and streamed decoded key bytes as stick and button state. Interface ranking now deprioritises boot-protocol HID interfaces instead of treating every HID interface alike, so a composite pad that leads with a keyboard and mouse is claimed on its controller interface. The rule is generic rather than keyed on Valve's vendor id. A new parser decodes the state packet: buttons, dpad, analog triggers, the left stick (which shares its bytes with the left trackpad, selected by the finger-down bit), the right trackpad as a right stick that recentres on lift, and the IMU. Attach stops the stand-alone keyboard/mouse emulation and enables the IMU; teardown puts both back on all three exit paths, including the two attach bail-outs where a partly-applied init has already changed the device. Feature-report writes retry on EPIPE the way SDL and hid-steam do, and a persistent failure is reported as InitFailed rather than quietly streaming a pad that is also typing into the phone. Listed as unverified, so Direct stays opt-in and is never auto-claimed. No rumble: the pad has no motors, and the simple rumble command is Deck-only. Protocol facts come from SDL and hid-steam; attribution in THIRD_PARTY.md and the unverified items in docs/usb-direct-mode-followups.md.
org.gradle.tooling.parallel lets the IDE build project models concurrently during sync. It overrides org.gradle.parallel in that context only, so task execution is unaffected.
ID_LOAD_DEFAULT_SETTINGS does not by itself restore the right trackpad to mouse mode. SDL's CloseSteamController follows it with an explicit right trackpad mode = absolute mouse; hid-steam does not, and that gap is the one remaining way this teardown could return a pad its owner cannot use. Append the same packet as a third restore step, with a per-sequence length so the quiet sequence still stops at two. Raise the feature-report EPIPE retry budget from 10 tries to 25. hid-steam allows 50 because the wireless dongle stalls intermittently under load; 25 keeps a wholly unresponsive device's init and teardown inside UsbGamepadManager's 4s path-transition timeout. Record two limitations in the followups doc: the dongle enumerates four pad interfaces and the rank tiebreak only ever reaches the first, and the interface pick assumes the emulated keyboard and mouse declare the HID boot subclass, which hid-steam deliberately does not rely on.
Three gaps around the quiet-mode settings a Direct claim flips on the pad: - A release waited for a framework gamepad that never re-enumerates (the restored pad settles as keyboard and mouse), so every switch back to Standard timed out into a false RestoreStuck banner, and a stolen-interface claim failure into a false NeedsReplug. The FSM now carries frameworkExpected, fed from the native model table, and settles Routed immediately on both paths for models without a framework gamepad identity. - The dongle's ID_CONTROLLER_WIRELESS events were rejected by the decoder, so a pad powering off mid-session left its last decoded input latched on the wire (plausibly the held Steam button of the power-off gesture), and a pad reconnecting after its reboot streamed without motion while its lizard keyboard leaked into the phone. checkWirelessEvent now classifies the events: disconnect publishes a neutral state, connect re-runs the attach init. - A physical unplug removed the synthetic without detaching the native device, stranding the exited poller thread, its dup'd fd, and its dispatch state in g_devices for every Direct family. The RemoveSynthetic effect now detaches first; the call is idempotent after a Release. Also carries directClaimCount and releaseAllDirect, the manager hooks the follow-up foreground-service commit wires up.
WakeState zeroes the streaming slot count when the app leaves the foreground, which stopped the connectedDevice service while a claimed pad still needed the process: the pad has been reconfigured at the device level, and only a live process can run the restore a release performs, so a background kill left it mute until a power cycle. The service and its controller now count held Direct claims alongside streaming slots, keep running in the background while any remain, and exit when the last one goes. A foreground return re-asserts the start so a service that stopped itself while collection was down comes back for work still held. The notification's Stop action also releases held claims, restore included, instead of leaving a captured pad behind, and its body says what is being held when nothing is streaming.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds Steam Controller support (wired
28DE:1102and the wireless dongle28DE:1142) to USB Direct mode, along with the two pieces of shared machinery that supporting it needed: generic boot-interface ranking, and a device-restore hook that runs when a claim is released.Why this pad only works on Direct
The Steam Controller ships in stand-alone ("lizard") mode, emulating a keyboard and a mouse. Its actual game interface is vendor-defined HID with no gamepad usages, so Android never enumerates it as a gamepad and
PhysicalGamepadRegistryhas nothing to bind.That makes this different from every other model in the catalog. For DualSense or Switch Pro, Direct is an upgrade over the framework path. Here it is the difference between the controller working and not existing on the platform at all.
Commits
28ea71cfeat: Steam Controller over USB Direct6e53f32build: parallel Tooling Model Builders for faster IDE sync39ea725fix: hand the Steam Controller's right pad back as a mouse on teardownb92dfe4fix: Steam Controller release, unplug, and dongle-drop lifecyclea3b7957feat: keep the foreground service up while a Direct claim is heldWhat is included
1. Steam Controller decode
Parser::STEAM_CONTROLLERdecodes the 48-byte state packet: buttons, dpad, analog triggers (Valve's 26000 full scale, so the throw saturates before the raw rail), the left stick, the right trackpad as a right stick, and the IMU.Two decode details worth review:
STEAM_LEFTPAD_FINGERDOWNselects which, andSTEAM_LEFTPAD_AND_JOYSTICKmeans frames alternate, so the stick value is held inParserStateacross pad frames. MatchesFormatStatePacketUntilGyro, including the firmware quirk where a stick click arrives as a left-pad click while the pad is idle.+1000offset, which is fine for a touch surface but would park a stick off centre. It recentres on lift, since pad coordinates are meaningless with no finger down.The IMU block self-gates: an all-zero accel and gyro read means the sensor enable never took, and publishing it would stream a dead sensor, so
motionValidstays false.2. Stand-alone mode off at attach, restored on release
Switched off at attach (
ID_CLEAR_DIGITAL_MAPPINGS, then oneID_SET_SETTINGS_VALUEScarrying both trackpad modes and the IMU enable) through a newrunTeardownhook that runs on all three exit paths, including bothattachDevicebail-outs where a partly-applied init has already changed the device.This is the first parser in the codebase whose init persistently reconfigures the device rather than kicking it into a mode, so the restore path carries more weight than usual: skipping it hands a user back a controller that no longer works as a desktop mouse, from an operation that visibly did nothing. Restore therefore mirrors SDL's
CloseSteamControllerrather thanhid-steam, which stops one packet earlier: afterID_LOAD_DEFAULT_SETTINGSit re-sets the right trackpad to absolute mouse by name, because loading the defaults does not reliably bring mouse mode back on its own.Feature-report writes retry on
EPIPEthe way SDL andhid-steamboth do, since that is the wireless dongle under load rather than a real failure. The retry budget is capped at 25 tries rather thanhid-steam's 50: at 20ms apart, 50 would let a wholly unresponsive device spend 2s in init plus 3s in teardown and overrunUsbGamepadManager's 4sTRANSITION_TIMEOUT_MS, turning a clean fallback into a spurious needs-replug. A persistent failure is reported as the existingDirectClaimFailure.InitFailedand the claim is released. It deliberately does not degrade to "streaming works but the pad is also typing into your phone", which nothing in the model represents and the user could not diagnose.The IMU enable shares a packet with the trackpad-mode writes on purpose: there is no path where the pad goes quiet but motion stays off, which would advertise a sensor that never reports.
runTeardownis a general hook, not a Steam special case. It is a no-op for every family that never changed the device, and it is where a Switch Pro mode restore would go if that is ever wanted.3. Interface ranking, generalised
gameInterfaceRankscored every HID-class interface alike, which is correct for every model shipped so far because none of them lead with emulated human-interface devices. A pad that does needs the picker to tell them apart: without it,findInterruptInPairkeeps the first candidate and the claim lands on interface 0, the keyboard. Classification then falls through toGENERIC_HID_GAMEPAD, whose descriptor parse fails (keyboard input items sit on usage pages 0x07/0x08) and whose fallback decoder gates only onlen >= 7, so an 8-byte boot keyboard report would decode "successfully", pinning the sticks to the rail and firing keycode bytes as buttons.Boot-protocol HID interfaces now sort below everything else. The rule is keyed on
bInterfaceSubClass/bInterfaceProtocol, not on Valve's vendor id, so any composite pad benefits. They are deprioritised, not disqualified, so a device offering nothing else is still claimable. Rank constants renumbered accordingly; relative order of XInput, GIP, HID and vendor-fallback is unchanged.4. Build config (unrelated, bundled here)
org.gradle.tooling.parallel=truelets the IDE build project models concurrently during sync. It overridesorg.gradle.parallelin that context only, so task execution is unaffected. No relation to the rest of the PR, carried here rather than spending a separate round trip on a three-line properties change.What the user sees
Valve Steam Controller, or(dongle)) in the guided USB setup flow, which lists raw USB devices rather than registry gamepads.Capability surface, all derived from the existing native model queries rather than new plumbing:
parserHasImu→modelHasImu→PathCapabilities.motionparserHasRumblefalse, nothing advertisedparserHasTouchpadfalse →TouchpadRouting→TouchpadSource.PHONEkImported, soisVerifiedFastLaneis falseChanged surface
app/src/main/cpp/usb_parsers.hParser::STEAM_CONTROLLER,InitKind::STEAM_QUIET,SteamConfig, held-stick state,runTeardowndeclarationapp/src/main/cpp/usb_parsers.cppdecodeSteamController,buildSteamConfigPacket,sendFeatureReport,runTeardown, twokImportedrows, capability predicatesapp/src/main/cpp/usb_host.cpprunTeardownon all three exit paths;runInitgains the interface number.../source/usb/UsbGamepadManager.ktgameInterfaceRankapp/src/test/cpp/usb_parsers_test.cpp.../source/usb/UsbGamepadManagerTest.ktTHIRD_PARTY.mdhidapi/steamandhid-steamattributionCHANGELOG.md[Unreleased] / Addeddocs/usb-direct-mode-followups.mdgradle.propertiesNot touched: no wire-protocol change (nothing in
wire_encoders, noprotocolVersionbump, not[wire-coordinated]), no UI layouts, no satellite or host-side work. This is a client-only change and needs no fleet coordination. (The follow-up commits add one notification string per locale and one DI edge,StreamingServiceController/StreamingService→UsbGamepadManager; the graph stays acyclic and Hilt compiles it.)Scope: what is deliberately not here
ID_TRIGGER_HAPTIC_PULSEpulse trains; the simple rumble command is Steam Deck firmware only andhid-steamgates force feedback on the Deck quirk.MSG_TOUCHPAD, which would double-actuate against the right stick.Not verified on hardware
I have no Steam Controller. Protocol facts come from SDL's
hidapi/steamheaders andhid-steam, attributed inTHIRD_PARTY.md. IMU axis signs, right-pad feel, and dongle behaviour are unverified and written up indocs/usb-direct-mode-followups.mditem 8, along with two known limitations:probeDecodableand falls back to routed, which is safe but reads as "Direct failed".hid-steamdeliberately does not rely on that, distinguishing the real pad by its report descriptor instead. If the assumption is wrong the config packets stall and the claim falls back to routed after roughly a second.One residual risk that cannot be closed from inside the process: a controller left paired to the dongle stays powered when the dongle is pulled, so an app kill between attach and teardown could leave it mute as a desktop mouse until it sleeps or the Steam button is held. A wired pad self-heals because unplugging powers it down. The follow-up service hold (
a3b7957) shrinks the exposure, since a claimed pad no longer rides in an unprotected cached process, but nothing can send a restore at kill time.Review follow-ups
A scenario audit of what happens to the flipped device-side settings across releases, disconnects, backgrounding, and process death turned up four gaps;
b92dfe4anda3b7957close them.PhysicalGamepadRegistryrightly refuses to track as a gamepad, soFrameworkUpnever fired and the FSM's wait timed out into RestoreStuck ("Standard isn't responding") on every single switch back, with NeedsReplug on the stolen-interface claim-failure path.UsbController.frameworkExpected(fed frommodelExpectsFrameworkGamepadin the native model table) now settles both paths on Standard immediately for models with no framework gamepad identity.ID_CONTROLLER_WIRELESSevents were rejected by the decoder, so a pad powering off mid-session kept its last decoded state on the wire, plausibly the held Steam button of the power-off gesture, popping overlays host-side. And a pad powering back on rebooted into stand-alone defaults with no re-init, streaming without motion while its lizard keyboard leaked into the phone.checkWirelessEventnow classifies the events; the poll loop publishes neutral state on disconnect and re-runs the attach init on connect, mirroringhid-steam's reconnect handling.RemoveSyntheticwithout ever calling the native detach, stranding the exited poller thread, its dup'd fd, and dispatch state ing_devices, for every Direct family, not just this one. TheRemoveSyntheticeffect now detaches first; the call is idempotent after aRelease.Tests and verification
frameworkExpectedvalues; manager-level release flows for both identities, the double-detach the unplug path relies on, andreleaseAllDirect; service-controller coverage of claim-alone start, background hold with claims, background stop without, and the foreground re-assert.Every protocol constant was cross-checked against current upstream (SDL
main, Linuxmaster): the 19 button masks, byte offsets for buttons, triggers, axes and IMU, trigger expansion and full scale, pad rotation direction, IMU axis order and both scale factors against this project's wire convention, message and setting ids, trackpad and gyro mode values, and the SET_REPORT framing.Local gates green: clang-format (22.1.4, matching the CI pin), play-metadata lint, ktlint, detekt, Android lint, JVM unit tests, 318 native tests, assembleDebug, and the JNI library build. Instrumented suite runs in CI.