From 50a8ae246dd5bab5caad4613367525802d9de2db Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Sat, 1 Aug 2026 17:41:43 -0700 Subject: [PATCH] Add Bluetooth LE support for the Steam Controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CMakeLists.txt | 3 +- README.md | 3 +- src/app/ControllerManager.cpp | 46 +++-- src/app/DeviceRestart.cpp | 90 ++++++-- src/app/TrayApp.cpp | 10 +- src/helper/DeviceCycleHelper.cpp | 48 ++++- src/main.cpp | 343 +++++++++++++++++++++++++------ src/steam/SteamController.cpp | 31 ++- src/steam/SteamController.h | 7 + 9 files changed, 483 insertions(+), 98 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5ae0eda..7ee2f2c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,7 +54,7 @@ add_executable(SteamlessDeviceCycle WIN32 src/helper/DeviceCycleHelper.cpp src/app/DeviceRestart.cpp ) -target_link_libraries(SteamlessDeviceCycle PRIVATE SteamController) +target_link_libraries(SteamlessDeviceCycle PRIVATE SteamController cfgmgr32) if (MSVC) target_compile_options(SteamlessDeviceCycle PRIVATE /W4 /WX /utf-8) endif() @@ -107,6 +107,7 @@ target_link_libraries(SteamlessController PRIVATE version ole32 shlwapi + cfgmgr32 ) if (MSVC) target_compile_options(SteamlessController PRIVATE /W4 /WX /utf-8 /wd4828) diff --git a/README.md b/README.md index 73b7365..895bf53 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ When **Steamless Mode** is active, the app disables the controller's built-in ke ## Features +- **NEW in 1.9** - Bluetooth! Use the controller wired, through the dongle, or paired over Bluetooth LE - **NEW in 1.8** - Automatically enable/disable based on if Steam is running, or a Steam game is running! - **NEW in 1.8** - Advanced debugging and local logging to help diagnose issues - Rumble Support v2.0 @@ -31,7 +32,7 @@ When **Steamless Mode** is active, the app disables the controller's built-in ke ### To run - Windows 10 or later (64-bit) - [ViGEmBus](https://github.com/nefarius/ViGEmBus/releases/latest) driver installed -- Steam Controller (VID `0x28DE` / PID `0x1302`) +- Steam Controller — wired USB (PID `0x1302`), wireless dongle (PID `0x1304`), or Bluetooth LE (PID `0x1303`, pair via Windows Bluetooth settings) - Steam **closed** (Steam claims the controller when running) ### To build diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index ed00e15..65bcf9d 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -11,6 +11,7 @@ #include #include #include +#include // --------------------------------------------------------------------------- // Slot @@ -18,6 +19,7 @@ struct ControllerManager::Slot { std::wstring path; + SteamController::Transport transport = SteamController::Transport::Unknown; std::unique_ptr sc; std::unique_ptr 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->path = path; - slot->sc = std::make_unique(); + slot->path = path; + slot->transport = SteamController::TransportFromPath(path); + slot->sc = std::make_unique(); 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(buf[1])) { - EventLog::Write("BATTERY: %u%% (chargeState=%u)", buf[1], buf[2]); - slot->lastBatteryPercent = static_cast(buf[1]); + slot->vc->SetBatteryState(percent, chargeState); + if (slot->lastBatteryPercent != static_cast(percent)) { + EventLog::Write("BATTERY: %u%% (chargeState=%u)", percent, chargeState); + slot->lastBatteryPercent = static_cast(percent); } } continue; diff --git a/src/app/DeviceRestart.cpp b/src/app/DeviceRestart.cpp index 9213f44..768e3d4 100644 --- a/src/app/DeviceRestart.cpp +++ b/src/app/DeviceRestart.cpp @@ -1,8 +1,83 @@ #include "DeviceRestart.h" #include +#include namespace DeviceRestart { +static bool SetDeviceState(HDEVINFO devs, SP_DEVINFO_DATA& devInfo, + DWORD stateChange, DWORD scope) { + SP_PROPCHANGE_PARAMS pc{}; + pc.ClassInstallHeader.cbSize = sizeof(SP_CLASSINSTALL_HEADER); + pc.ClassInstallHeader.InstallFunction = DIF_PROPERTYCHANGE; + pc.StateChange = stateChange; + pc.Scope = scope; + pc.HwProfile = 0; + return SetupDiSetClassInstallParamsW(devs, &devInfo, &pc.ClassInstallHeader, sizeof(pc)) + && SetupDiCallClassInstaller(DIF_PROPERTYCHANGE, devs, &devInfo); +} + +static bool IsDevNodeDisabled(DEVINST devInst) { + ULONG status = 0, problem = 0; + if (CM_Get_DevNode_Status(&status, &problem, devInst, 0) != CR_SUCCESS) + return false; // can't tell — don't report a disabled state we didn't see + return (status & DN_HAS_PROBLEM) != 0 && problem == CM_PROB_DISABLED; +} + +// Take the devnode down and bring it back, invalidating every open handle to +// it — including the one Steam holds, which is the whole point of a cycle. +// +// Disable/enable rather than the politer DICS_PROPCHANGE "restart", because +// on a Bluetooth HID child that restart reports success while leaving the +// device and Steam's handle untouched. A disable asks nobody's permission, +// so it is the only thing that reliably breaks the handle. +// +// SetupDiCallClassInstaller's return value cannot be trusted here: a disable +// that Windows genuinely performed still came back as ERROR_INVALID_DATA on +// this Bluetooth HID child. So the devnode's own status is the source of +// truth, and the re-enable is never gated on what the disable claimed — +// getting that wrong strands the controller switched off, needing Device +// Manager to recover, which is far worse than failing to cycle at all. +static bool CycleDevNode(HDEVINFO devs, SP_DEVINFO_DATA& devInfo, DWORD* errorOut) { + // Some devices reject a global scope change and only accept a + // config-specific one, so try both before believing a disable was refused. + if (!SetDeviceState(devs, devInfo, DICS_DISABLE, DICS_FLAG_GLOBAL)) + SetDeviceState(devs, devInfo, DICS_DISABLE, DICS_FLAG_CONFIGSPECIFIC); + + // Did it actually go down? This, not the return code above, decides + // whether a real cycle happened. + const bool wentDown = IsDevNodeDisabled(devInfo.DevInst); + + if (wentDown) + Sleep(1000); // let the removal propagate before bringing it back + + // Always re-enable, whatever the disable reported, and keep going until + // the devnode confirms it is no longer disabled. + bool enabled = !IsDevNodeDisabled(devInfo.DevInst); + for (int attempt = 0; attempt < 3 && !enabled; ++attempt) { + if (attempt) Sleep(500); + SetDeviceState(devs, devInfo, DICS_ENABLE, DICS_FLAG_GLOBAL); + if (IsDevNodeDisabled(devInfo.DevInst)) + SetDeviceState(devs, devInfo, DICS_ENABLE, DICS_FLAG_CONFIGSPECIFIC); + enabled = !IsDevNodeDisabled(devInfo.DevInst); + } + + if (!enabled) { + // The controller is stranded disabled — the caller must surface this, + // it is not a quiet failure. + if (errorOut) *errorOut = GetLastError(); + return false; + } + + if (!wentDown) { + // Nothing was taken down, so no handle was broken. Fall back to + // asking for a plain restart, which is enough on USB. + if (errorOut) *errorOut = ERROR_NOT_SUPPORTED; + return SetDeviceState(devs, devInfo, DICS_PROPCHANGE, DICS_FLAG_GLOBAL); + } + + return true; +} + bool RestartInterfaceDevice(const std::wstring& interfacePath, DWORD* errorOut) { if (errorOut) *errorOut = ERROR_SUCCESS; @@ -23,20 +98,7 @@ bool RestartInterfaceDevice(const std::wstring& interfacePath, DWORD* errorOut) SetupDiGetDeviceInterfaceDetailW(devs, &ifData, nullptr, 0, nullptr, &devInfo); if (devInfo.DevInst != 0) { - SP_PROPCHANGE_PARAMS pc{}; - pc.ClassInstallHeader.cbSize = sizeof(SP_CLASSINSTALL_HEADER); - pc.ClassInstallHeader.InstallFunction = DIF_PROPERTYCHANGE; - pc.StateChange = DICS_PROPCHANGE; // restart - pc.Scope = DICS_FLAG_GLOBAL; - pc.HwProfile = 0; - - if (SetupDiSetClassInstallParamsW(devs, &devInfo, - &pc.ClassInstallHeader, sizeof(pc)) - && SetupDiCallClassInstaller(DIF_PROPERTYCHANGE, devs, &devInfo)) { - ok = true; - } else if (errorOut) { - *errorOut = GetLastError(); - } + ok = CycleDevNode(devs, devInfo, errorOut); } else if (errorOut) { *errorOut = GetLastError(); } diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index f47aabe..8d5ea86 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -328,6 +328,11 @@ void TrayApp::TryAcquireController() { // re-arrival (the timer is a fallback in case the arrival event is missed). if (m_acquireRetries >= MAX_ACQUIRE_CYCLES) { EventLog::Write("AUTO: giving up acquiring after %d device cycles", MAX_ACQUIRE_CYCLES); + // Don't leave the user with a silently dead pad and no explanation. + if (m_notificationsEnabled) + ShowAlertBalloon(L"Could not take the controller", + L"Steam is holding the controller and did not release it. " + L"Closing Steam will hand it back."); return; } ++m_acquireRetries; @@ -340,7 +345,10 @@ void TrayApp::TryAcquireController() { } bool TrayApp::RestartControllerDevices() { - // On the rare elevated run, cycle directly. + // On the rare elevated run, cycle directly. This works on every transport + // including Bluetooth: the devnode being restarted is the HID child, not + // the pairing (which lives up at the BTHLEDEVICE layer), and it + // re-enumerates as fast as a USB replug. if (IsProcessElevated()) { bool anyRestarted = false; for (const auto& path : SteamController::EnumerateAll()) diff --git a/src/helper/DeviceCycleHelper.cpp b/src/helper/DeviceCycleHelper.cpp index 98a1322..4f2804b 100644 --- a/src/helper/DeviceCycleHelper.cpp +++ b/src/helper/DeviceCycleHelper.cpp @@ -18,10 +18,39 @@ #define WIN32_LEAN_AND_MEAN #include +#include +#include #include #include "app/DeviceRestart.h" #include "steam/SteamController.h" +// The helper runs windowless from Task Scheduler, so a failed cycle is +// otherwise completely silent. It cannot share the tray app's events.log — +// that is held open with _SH_DENYWR — so it keeps its own next to it. +static void CycleLog(const char* fmt, ...) { + wchar_t local[MAX_PATH]; + if (!GetEnvironmentVariableW(L"LOCALAPPDATA", local, MAX_PATH)) return; + const std::wstring dir = std::wstring(local) + L"\\SteamlessController"; + CreateDirectoryW(dir.c_str(), nullptr); + + FILE* f = nullptr; + if (_wfopen_s(&f, (dir + L"\\cycle.log").c_str(), L"a") != 0 || !f) return; + + SYSTEMTIME st; + GetLocalTime(&st); + fprintf(f, "%04u-%02u-%02u %02u:%02u:%02u.%03u ", + st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, + st.wMilliseconds); + + va_list ap; + va_start(ap, fmt); + vfprintf(f, fmt, ap); + va_end(ap); + + fputc('\n', f); + fclose(f); +} + static constexpr wchar_t CYCLE_TASK_NAME[] = L"SteamlessControllerDeviceCycle"; // Startup task name used briefly by a pre-release build that ran the tray app // elevated at logon; removed during --register so it can't resurrect the bug. @@ -46,9 +75,22 @@ static bool RunToolHidden(std::wstring cmdline) { static int CycleDevices() { bool any = false; - for (const auto& path : SteamController::EnumerateAll()) - if (DeviceRestart::RestartInterfaceDevice(path)) - any = true; + const auto paths = SteamController::EnumerateAll(); + CycleLog("START: %zu interface(s) to cycle", paths.size()); + + for (const auto& path : paths) { + DWORD err = ERROR_SUCCESS; + const bool ok = DeviceRestart::RestartInterfaceDevice(path, &err); + // err is only non-zero on the disable-refused fallback path, so + // ok=true with err set means the cycle silently did nothing. + CycleLog("%s (transport=%s err=%lu) %ls", + ok ? (err == ERROR_SUCCESS ? "CYCLED" : "FELL BACK TO RESTART") : "FAILED", + SteamController::TransportName(SteamController::TransportFromPath(path)), + err, path.c_str()); + if (ok) any = true; + } + + CycleLog("DONE: exit=%d", any ? 0 : 1); return any ? 0 : 1; } diff --git a/src/main.cpp b/src/main.cpp index 8422d21..cb88c38 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,10 +1,14 @@ #include #include #include +#include #include #include +#include #include -#include +#include +#include +#include #include "hid/HidDevice.h" #include "steam/SteamController.h" @@ -19,11 +23,97 @@ static void OnSignal(int) { exit(0); } +// --------------------------------------------------------------------------- +// Transport identification — shared with the app (path markers + PID tokens; +// wired HID paths don't contain "usb", so a plain substring test mislabels them). +// --------------------------------------------------------------------------- + +static const char* GuessTransport(const std::wstring& rawPath) { + return SteamController::TransportName(SteamController::TransportFromPath(rawPath)); +} + +// --------------------------------------------------------------------------- +// Enumeration — dump every Valve HID interface with full detail so a new +// transport (Bluetooth) is visible even before we know its PID. +// --------------------------------------------------------------------------- + +struct IfaceInfo { + std::wstring path; + uint16_t pid = 0; + uint16_t usagePage = 0; + uint16_t usage = 0; +}; + +static std::vector EnumerateAllValveDevices() { + printf("=== All Valve HID interfaces (VID=28DE, any PID) ===\n"); + auto paths = HidDevice::Enumerate(SteamController::VALVE_VID, 0); + std::vector out; + if (paths.empty()) { + printf(" None found. Plug in the controller/dongle, or pair it in\n" + " Windows Bluetooth settings, then re-run.\n\n"); + return out; + } + + for (auto const& path : paths) { + IfaceInfo info; + info.path = path; + + HANDLE h = CreateFileW(path.c_str(), 0, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, OPEN_EXISTING, 0, nullptr); + if (h == INVALID_HANDLE_VALUE) { + printf(" [%zu] (open failed, error %lu) %s\n path: %ls\n", + out.size(), GetLastError(), GuessTransport(path), path.c_str()); + out.push_back(std::move(info)); + continue; + } + + HIDD_ATTRIBUTES attrs{}; + attrs.Size = sizeof(attrs); + HidD_GetAttributes(h, &attrs); + info.pid = attrs.ProductID; + + wchar_t productBuf[128] = L"(unknown)"; + HidD_GetProductString(h, productBuf, sizeof(productBuf)); + wchar_t serialBuf[128] = L"(none)"; + HidD_GetSerialNumberString(h, serialBuf, sizeof(serialBuf)); + + USHORT inLen = 0, outLen = 0, featLen = 0; + PHIDP_PREPARSED_DATA preparsed; + if (HidD_GetPreparsedData(h, &preparsed)) { + HIDP_CAPS caps{}; + if (HidP_GetCaps(preparsed, &caps) == HIDP_STATUS_SUCCESS) { + info.usagePage = caps.UsagePage; + info.usage = caps.Usage; + inLen = caps.InputReportByteLength; + outLen = caps.OutputReportByteLength; + featLen = caps.FeatureReportByteLength; + } + HidD_FreePreparsedData(preparsed); + } + + const bool vendor = (info.usagePage == SteamController::VENDOR_USAGE_PAGE); + printf(" [%zu]%s PID=%04X Usage=%04X:%04X In=%u Out=%u Feat=%u fw=0x%04X %s\n" + " \"%ls\" serial=%ls\n" + " path: %ls\n", + out.size(), vendor ? "*" : " ", attrs.ProductID, + info.usagePage, info.usage, inLen, outLen, featLen, + attrs.VersionNumber, GuessTransport(path), + productBuf, serialBuf, path.c_str()); + + CloseHandle(h); + out.push_back(std::move(info)); + } + printf(" (* = vendor collection 0x%04X — the game-input interface)\n\n", + SteamController::VENDOR_USAGE_PAGE); + return out; +} + // --------------------------------------------------------------------------- // Calibration — collect idle frames to identify analog axis byte positions // --------------------------------------------------------------------------- -static constexpr int CALIB_FRAMES = 200; +static constexpr int CALIB_FRAMES = 200; static constexpr uint8_t AXIS_THRESHOLD = 0; // any idle movement → treat as axis // Force specific byte pairs through calibration suppression even if they move at idle. @@ -48,17 +138,32 @@ struct ByteStats { static ByteStats s_stats[64]; static bool s_isAxis[64] = {}; // true → suppress in diff output static size_t s_reportLen = 0; +static uint8_t s_stateId = 0; // which state report ID this transport uses -static void RunCalibration(SteamController& ctrl) { +// Returns false when no state reports arrived in time (e.g. a transport that +// frames input differently) — the caller falls back to a raw dump. +static bool RunCalibration(SteamController& ctrl) { printf("--- Calibration: leave controller idle, collecting %d frames ---\n", CALIB_FRAMES); + const auto start = std::chrono::steady_clock::now(); + const auto deadline = start + std::chrono::seconds(10); + uint8_t buf[64]; int collected = 0; while (collected < CALIB_FRAMES) { + if (std::chrono::steady_clock::now() > deadline) { + printf(" TIMEOUT: no state reports (0x42/0x45) after 10s — %d collected.\n" + " Falling back to raw report dump.\n\n", collected); + return false; + } size_t n = ctrl.ReadReport(buf, sizeof(buf), 200); - if (n == 0 || buf[0] != SteamController::REPORT_STATE) + if (n == 0 || !SteamController::IsStateReportId(buf[0])) continue; + if (s_stateId == 0) { + s_stateId = buf[0]; + printf(" State report ID: 0x%02X, %zu bytes\n", s_stateId, n); + } if (s_reportLen == 0) s_reportLen = n; for (size_t i = 1; i < n; ++i) s_stats[i].feed(buf[i]); @@ -67,6 +172,10 @@ static void RunCalibration(SteamController& ctrl) { printf(" %d / %d\n", collected, CALIB_FRAMES); } + const double secs = std::chrono::duration( + std::chrono::steady_clock::now() - start).count(); + printf(" Measured state-report rate: ~%.0f Hz\n", collected / secs); + // Apply force-show overrides before marking axes. auto forceShow = [](size_t i) -> bool { if (FORCE_SHOW_BYTES_10_11 && (i == 10 || i == 11)) return true; @@ -95,113 +204,217 @@ static void RunCalibration(SteamController& ctrl) { i, s_stats[i].lo, s_stats[i].hi, s_stats[i].range(), cls); } printf("\nAxes suppressed. Press buttons one at a time — only stable bytes shown.\n\n"); + return true; } // --------------------------------------------------------------------------- // Differential display — axes masked, only changes shown // --------------------------------------------------------------------------- -static uint8_t s_prev42[64]; -static bool s_hasPrev42 = false; +static uint8_t s_prevState[64]; +static bool s_hasPrevState = false; -static void PrintState42Diff(const uint8_t* buf, size_t n) { +static void PrintStateDiff(const uint8_t* buf, size_t n) { if (n < 2) return; bool anyDiff = false; for (size_t i = 1; i < n; ++i) { if (s_isAxis[i]) continue; - bool changed = !s_hasPrev42 || (buf[i] != s_prev42[i]); + bool changed = !s_hasPrevState || (buf[i] != s_prevState[i]); if (changed) { if (!anyDiff) { printf(" [idx] new old\n"); anyDiff = true; } - printf(" [%02zu] %02X (%02X)\n", i, buf[i], s_hasPrev42 ? s_prev42[i] : 0); + printf(" [%02zu] %02X (%02X)\n", i, buf[i], s_hasPrevState ? s_prevState[i] : 0); } } - memcpy(s_prev42, buf, n); - s_hasPrev42 = true; + memcpy(s_prevState, buf, n); + s_hasPrevState = true; +} + +static void PrintRawReport(const uint8_t* buf, size_t n) { + printf("[0x%02X %zu bytes] ", buf[0], n); + for (size_t i = 0; i < n; ++i) printf("%02X ", buf[i]); + printf("\n"); +} + +// --------------------------------------------------------------------------- +// IMU test mode (--imu) — enable raw accel+gyro and print parsed samples. +// Answers whether the IMU streams on this transport: a live IMU shows an +// incrementing timestamp, gravity (~±0x4000 region) on one accel axis, and +// gyro spikes when the controller rotates. Frozen constants = not streaming. +// --------------------------------------------------------------------------- + +static void RunImuTest(SteamController& controller, bool lizardOff) { + printf("--- IMU test: enabling raw accel + gyro ---\n"); + if (!controller.SetImuEnabled(true)) + printf("SetImuEnabled FAILED — watching reports anyway.\n"); + printf("Rotate and tilt the controller. Printing ~3 samples/sec; Ctrl+C to quit.\n\n"); + + uint8_t buf[64]; + size_t lastLen = 0; + auto lastPrint = std::chrono::steady_clock::now(); + auto lastKeepalive = std::chrono::steady_clock::now(); + + while (true) { + if (lizardOff) { + const auto now = std::chrono::steady_clock::now(); + if (now - lastKeepalive >= std::chrono::seconds(2)) { + lastKeepalive = now; + controller.SendKeepalive(); + } + } + + size_t n = controller.ReadReport(buf, sizeof(buf), 100); + if (n == 0) continue; + if (!SteamController::IsStateReportId(buf[0])) { + PrintRawReport(buf, n); + continue; + } + if (n != lastLen) { + printf("state report: id=0x%02X length=%zu bytes\n", buf[0], n); + lastLen = n; + } + + const auto now = std::chrono::steady_clock::now(); + if (now - lastPrint < std::chrono::milliseconds(300)) continue; + lastPrint = now; + + if (n < 46) { + printf("(report too short for IMU data: %zu bytes)\n", n); + continue; + } + uint32_t ts; + int16_t ax, ay, az, gx, gy, gz; + memcpy(&ts, buf + 30, 4); + memcpy(&ax, buf + 34, 2); + memcpy(&ay, buf + 36, 2); + memcpy(&az, buf + 38, 2); + memcpy(&gx, buf + 40, 2); + memcpy(&gy, buf + 42, 2); + memcpy(&gz, buf + 44, 2); + printf("ts=%10u accel=(%6d, %6d, %6d) gyro=(%6d, %6d, %6d)\n", + ts, ax, ay, az, gx, gy, gz); + } } // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- -static void EnumerateAllValveDevices() { - printf("=== All Valve HID interfaces (VID=28DE) ===\n"); - auto paths = HidDevice::Enumerate(SteamController::VALVE_VID, 0); - if (paths.empty()) { - printf(" None found.\n\n"); - return; - } - - for (auto const& path : paths) { - HANDLE h = CreateFileW(path.c_str(), 0, - FILE_SHARE_READ | FILE_SHARE_WRITE, - nullptr, OPEN_EXISTING, 0, nullptr); - if (h == INVALID_HANDLE_VALUE) continue; - - HIDD_ATTRIBUTES attrs{}; - attrs.Size = sizeof(attrs); - HidD_GetAttributes(h, &attrs); - - wchar_t productBuf[128] = L"(unknown)"; - HidD_GetProductString(h, productBuf, sizeof(productBuf)); - - uint16_t usagePage = 0, usage = 0; - PHIDP_PREPARSED_DATA preparsed; - if (HidD_GetPreparsedData(h, &preparsed)) { - HIDP_CAPS caps{}; - if (HidP_GetCaps(preparsed, &caps) == HIDP_STATUS_SUCCESS) { - usagePage = caps.UsagePage; - usage = caps.Usage; - } - HidD_FreePreparsedData(preparsed); - } - - printf(" PID=%04X UsagePage=%04X Usage=%04X \"%ls\"\n", - attrs.ProductID, usagePage, usage, productBuf); - CloseHandle(h); - } - printf("\n"); +static size_t PromptIndex(size_t maxIdx, size_t defaultIdx) { + printf("Select interface [0-%zu] (Enter = %zu): ", maxIdx, defaultIdx); + char line[32]; + if (!fgets(line, sizeof(line), stdin)) return defaultIdx; + if (line[0] == '\n' || line[0] == '\0') return defaultIdx; + char* end = nullptr; + long v = strtol(line, &end, 10); + if (end == line || v < 0 || static_cast(v) > maxIdx) return defaultIdx; + return static_cast(v); } -int main() { +int main(int argc, char** argv) { signal(SIGINT, OnSignal); signal(SIGTERM, OnSignal); - printf("=== SteamProbe — 2026 Steam Controller (VID=28DE PID=1302) ===\n\n"); + const bool enumOnly = (argc > 1 && strcmp(argv[1], "--enum") == 0); + const bool imuMode = (argc > 1 && strcmp(argv[1], "--imu") == 0); + + printf("=== SteamProbe — 2026 Steam Controller discovery tool ===\n\n"); printf("NOTE: Close Steam before running this tool.\n\n"); - EnumerateAllValveDevices(); + auto ifaces = EnumerateAllValveDevices(); + if (enumOnly) return ifaces.empty() ? 1 : 0; + if (ifaces.empty()) return 1; + + // Default to the first vendor-page interface; anything is selectable so a + // Bluetooth transport that frames things differently can still be probed. + size_t defaultIdx = 0; + for (size_t i = 0; i < ifaces.size(); ++i) { + if (ifaces[i].usagePage == SteamController::VENDOR_USAGE_PAGE) { + defaultIdx = i; + break; + } + } + size_t chosen = ifaces.size() > 1 + ? PromptIndex(ifaces.size() - 1, defaultIdx) + : defaultIdx; + printf("Opening [%zu] (%s)...\n\n", chosen, GuessTransport(ifaces[chosen].path)); SteamController controller; g_controller = &controller; - if (!controller.Open()) { + if (!controller.Open(ifaces[chosen].path)) { fprintf(stderr, "Could not open device.\n"); return 1; } - printf("Disabling lizard mode...\n"); - if (!controller.DisableLizardMode()) { - fprintf(stderr, "Lizard mode disable failed.\n"); - return 1; + // Feature-report command channel — the critical unknown on a new + // transport. Failure here is a finding, not a fatal error: input reports + // may still flow, so keep going and dump whatever arrives. + printf("Disabling lizard mode (feature-report command channel test)...\n"); + const bool lizardOff = controller.DisableLizardMode(); + if (lizardOff) { + printf("Lizard mode OFF — feature reports work on this transport.\n\n"); + } else { + printf("*** Lizard-off FAILED — the feature-report command channel may not\n" + "*** work on this transport. Continuing with raw input dump.\n\n"); } - printf("Lizard mode OFF.\n\n"); - RunCalibration(controller); + if (imuMode) { + RunImuTest(controller, lizardOff); + return 0; + } + + // Output-report test — independent of the feature channel. + printf("--- Output-report test: haptic tick each pad, then a rumble pulse ---\n"); + controller.PulseTrackpadHaptic(/*left=*/false, /*strongClick=*/true); + Sleep(150); + controller.PulseTrackpadHaptic(/*left=*/true, /*strongClick=*/true); + Sleep(150); + controller.SetRumble(0x90, 0x90); + Sleep(300); + controller.SetRumble(0, 0); + printf("(any 'WriteOutputReport ... failed' lines above mean output reports\n" + " were rejected; silence + felt haptics = output path works)\n\n"); + + const bool calibrated = RunCalibration(controller); + if (!calibrated) + printf("Raw dump mode: state reports shown 1-in-25, all others in full.\n\n"); + + uint8_t buf[64]; + uint32_t stateCount = 0; + auto lastKeepalive = std::chrono::steady_clock::now(); + bool keepaliveFailing = false; - uint8_t buf[64]; while (true) { + // Keepalive — same cadence as the tray app, so the firmware doesn't + // silently revert to lizard mode mid-session. Also a live health + // check of the feature channel: watch for FAIL/recover transitions. + if (lizardOff) { + const auto now = std::chrono::steady_clock::now(); + if (now - lastKeepalive >= std::chrono::seconds(2)) { + lastKeepalive = now; + const bool ok = controller.SendKeepalive(); + if (!ok && !keepaliveFailing) { + keepaliveFailing = true; + printf("*** KEEPALIVE failed — feature channel dropped (sleep? out of range?)\n"); + } else if (ok && keepaliveFailing) { + keepaliveFailing = false; + printf("*** KEEPALIVE recovered\n"); + } + } + } + size_t n = controller.ReadReport(buf, sizeof(buf), 100); if (n == 0) continue; - uint8_t reportId = buf[0]; - if (reportId == SteamController::REPORT_STATE) { - PrintState42Diff(buf, n); + if (SteamController::IsStateReportId(buf[0])) { + if (calibrated) + PrintStateDiff(buf, n); + else if (stateCount++ % 25 == 0) + PrintRawReport(buf, n); } else { - printf("[0x%02X %zu bytes] ", reportId, n); - for (size_t i = 0; i < n; ++i) printf("%02X ", buf[i]); - printf("\n"); + PrintRawReport(buf, n); } } diff --git a/src/steam/SteamController.cpp b/src/steam/SteamController.cpp index 920c7c2..4229e58 100644 --- a/src/steam/SteamController.cpp +++ b/src/steam/SteamController.cpp @@ -4,6 +4,7 @@ #include #include #include +#include // --------------------------------------------------------------------------- // Internal helper: build a 64-byte feature report command buffer. @@ -53,12 +54,40 @@ static constexpr uint8_t HAPTIC_COMMAND_CLICK = 2; std::vector SteamController::EnumerateAll() { std::vector result; - for (uint16_t pid : { SC2026_PID, SC2026_DONGLE_PID }) + for (uint16_t pid : { SC2026_PID, SC2026_BT_PID, SC2026_DONGLE_PID }) for (auto const& path : HidDevice::Enumerate(VALVE_VID, pid, VENDOR_USAGE_PAGE)) result.push_back(path); return result; } +SteamController::Transport SteamController::TransportFromPath(const std::wstring& path) { + std::wstring p = path; + for (auto& c : p) c = static_cast(towlower(c)); + + // BLE (HID over GATT) paths carry the HOGP service GUID; classic + // Bluetooth HID enumerates under bthenum. Check these before PID — the + // BT path also embeds a PID token, and transport must win. + if (p.find(L"{00001812-0000-1000-8000-00805f9b34fb}") != std::wstring::npos || + p.find(L"bthledevice") != std::wstring::npos || + p.find(L"bthenum") != std::wstring::npos) + return Transport::Bluetooth; + if (p.find(L"pid_1304") != std::wstring::npos) + return Transport::Dongle; + if (p.find(L"pid_1302") != std::wstring::npos || + p.find(L"pid_1303") != std::wstring::npos) + return Transport::Wired; + return Transport::Unknown; +} + +const char* SteamController::TransportName(Transport t) { + switch (t) { + case Transport::Wired: return "wired"; + case Transport::Dongle: return "dongle"; + case Transport::Bluetooth: return "bluetooth"; + default: return "unknown"; + } +} + bool SteamController::Open() { auto paths = EnumerateAll(); if (paths.empty()) { diff --git a/src/steam/SteamController.h b/src/steam/SteamController.h index e11b2f2..e6a5373 100644 --- a/src/steam/SteamController.h +++ b/src/steam/SteamController.h @@ -10,8 +10,15 @@ class SteamController { public: static constexpr uint16_t VALVE_VID = 0x28DE; static constexpr uint16_t SC2026_PID = 0x1302; // wired USB + static constexpr uint16_t SC2026_BT_PID = 0x1303; // Bluetooth LE (HID over GATT) static constexpr uint16_t SC2026_DONGLE_PID = 0x1304; // wireless dongle ("Steam Controller Puck") + // How the controller is linked to the PC. Bluetooth is detected from the + // device interface path (HOGP service GUID / bthenum), the other two by PID. + enum class Transport { Unknown, Wired, Dongle, Bluetooth }; + static Transport TransportFromPath(const std::wstring& path); + static const char* TransportName(Transport t); + // HID Usage Page for the vendor collection that carries all game input. static constexpr uint16_t VENDOR_USAGE_PAGE = 0xFF00;