Add Bluetooth LE support for the Steam Controller

The controller now works wired, through the dongle, or paired over
Bluetooth, with the full feature set: game mode, rumble, trackpad haptics,
battery, PlayStation-mode gyro, and taking the controller back from a
running Steam.

Windows exposes the entire vendor protocol over HID-over-GATT under PID
0x1303 — feature reports, output reports, and the 0x45 state report (54
bytes, ~157 Hz) all work through the existing HidDevice stack, so support
is a thin layer rather than a new transport backend.

- Add SC2026_BT_PID to enumeration and a per-slot Transport tag, detected
  from the interface path (HOGP GUID / bthenum markers win over PID
  tokens) and logged on CONNECT/DISCONNECT
- Swap the battery report 0x43 payload bytes on Bluetooth slots — the
  firmware sends [chargeState, percent], reversed from USB/dongle
- Bluetooth-specific disconnect alert copy, and a notification when
  acquisition gives up rather than a silently dead pad

Device cycling had to be reworked for reclaiming the controller from
Steam. DIF_PROPERTYCHANGE reports success while leaving the device and
Steam's handle untouched; CM_Query_And_Remove_SubTree is vetoed by open
handles; pnputil /restart-device works once, then leaves the devnode
reboot-pending and refuses thereafter. Disable+enable works repeatedly.

Two traps found against live hardware. SetupDiCallClassInstaller returned
ERROR_INVALID_DATA for a disable Windows had genuinely performed — the app
logged the device removal a tenth of a second before the call reported
failure — so return codes cannot gate the re-enable, or the controller is
stranded disabled and needs Device Manager to recover. CM_Get_DevNode_Status
is now the source of truth: whether the node went down decides if a real
cycle happened, the re-enable always runs and retries until the node
confirms it is enabled, and only a stranded device counts as failure. Some
devices also reject a global scope change, so DICS_FLAG_CONFIGSPECIFIC is
tried as well.

SteamProbe becomes a transport discovery tool: detailed enumeration with
transport detection, interface selection, non-fatal lizard-off with a
raw-dump fallback, an output-report test, report-rate measurement, and
--enum / --imu modes. The windowless cycle helper gains its own cycle.log,
since it cannot share the tray app's events.log (held open _SH_DENYWR) and
was previously impossible to diagnose.

Verified on hardware: game mode, rumble, haptics, battery, IMU streaming,
clean removal on power-off/sleep/range loss with automatic reconnect,
yielding to Steam on game launch, and reclaiming afterwards (~1.2s). Also
confirmed the firmware enforces one host transport at a time, so no
cross-transport deduplication is needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dylan DeverillandClaude Fable 5 authored and GitHub committed 2026-08-01 17:41:43 -07:00
1 parent 5c3f4aacf0
commit 50a8ae246d
9 files changed
+483 -98

No files matched your search

+34 -12
View File
@@ -11,6 +11,7 @@
#include <cstring>
#include <memory>
#include <thread>
#include <utility>
// ---------------------------------------------------------------------------
// Slot
@@ -18,6 +19,7 @@
struct ControllerManager::Slot {
std::wstring path;
SteamController::Transport transport = SteamController::Transport::Unknown;
std::unique_ptr<SteamController> sc;
std::unique_ptr<VirtualController> vc;
TrackpadMouse trackpad;
@@ -295,17 +297,25 @@ void ControllerManager::SyncDevices() {
bool alive = std::any_of(livePaths.begin(), livePaths.end(),
[&](const auto& p) { return p == (*it)->path; });
if (!alive) {
EventLog::Write("DISCONNECT: OS removed device (gameMode=%d, lastBattery=%d%%) %ls",
EventLog::Write("DISCONNECT: OS removed device (transport=%s, gameMode=%d, lastBattery=%d%%) %ls",
SteamController::TransportName((*it)->transport),
(*it)->gameModeActive ? 1 : 0,
(*it)->lastBatteryPercent, (*it)->path.c_str());
// Alert only when the controller was in active use; deliberate
// unplugs while idle (and our own device cycles, which release
// slots before cycling) stay quiet.
if ((*it)->gameModeActive && m_alertFn)
m_alertFn(L"Controller disconnected",
L"The USB device was removed. Check the cable or dongle — "
L"if this happens randomly, Windows USB power saving may be "
L"suspending the port.");
if ((*it)->gameModeActive && m_alertFn) {
if ((*it)->transport == SteamController::Transport::Bluetooth)
m_alertFn(L"Controller disconnected",
L"The Bluetooth connection dropped — the controller "
L"powered off, went to sleep, or is out of range. "
L"Press the Steam button to reconnect.");
else
m_alertFn(L"Controller disconnected",
L"The USB device was removed. Check the cable or dongle — "
L"if this happens randomly, Windows USB power saving may be "
L"suspending the port.");
}
DisableGameModeSlot(**it);
it = m_slots.erase(it);
} else {
@@ -325,10 +335,14 @@ void ControllerManager::SyncDevices() {
void ControllerManager::OpenSlot(const std::wstring& path) {
auto slot = std::make_unique<Slot>();
slot->path = path;
slot->sc = std::make_unique<SteamController>();
slot->path = path;
slot->transport = SteamController::TransportFromPath(path);
slot->sc = std::make_unique<SteamController>();
if (!slot->sc->Open(path)) return;
EventLog::Write("CONNECT: transport=%s %ls",
SteamController::TransportName(slot->transport), path.c_str());
m_slots.push_back(std::move(slot));
if (IsGameModeActive()) {
@@ -479,11 +493,19 @@ void ControllerManager::ReadLoop(Slot* slot) {
// Battery status — update DS4 battery level; no further processing needed.
if (buf[0] == SteamController::REPORT_BATTERY_STATUS) {
if (n >= 3) {
// USB/dongle payload order is [percent, chargeState]; over
// Bluetooth the firmware sends the same two bytes swapped —
// observed BT reports held a constant 1 (CHARGE_STATE_DISCHARGING)
// in buf[1] while buf[2] tracked the real charge level.
uint8_t percent = buf[1];
uint8_t chargeState = buf[2];
if (slot->transport == SteamController::Transport::Bluetooth)
std::swap(percent, chargeState);
if (slot->vc)
slot->vc->SetBatteryState(buf[1], buf[2]);
if (slot->lastBatteryPercent != static_cast<int>(buf[1])) {
EventLog::Write("BATTERY: %u%% (chargeState=%u)", buf[1], buf[2]);
slot->lastBatteryPercent = static_cast<int>(buf[1]);
slot->vc->SetBatteryState(percent, chargeState);
if (slot->lastBatteryPercent != static_cast<int>(percent)) {
EventLog::Write("BATTERY: %u%% (chargeState=%u)", percent, chargeState);
slot->lastBatteryPercent = static_cast<int>(percent);
}
}
continue;