mirror of
https://github.com/ddeverill/SteamlessController.git
synced 2026-10-22 06:32:20 -05:00
Fix puck activation after firmware updates (#44)
The receiver stopped working after a controller firmware update. Original diagnosis and fix by @danbosscher; extended here for Bluetooth, Steam handoff, and several defects found during QA on the hardware. The puck publishes a controller interface per slot plus a management endpoint, and on current firmware an empty slot rejects the lizard-mode command outright. - Enumerate only the controller collection (FF00:0001), excluding the puck's management endpoint at FF00:0002 - Wait for a live 0x42/0x45 state report before sending feature commands, and skip slots that never produce one - Prefer an exclusive claim but accept a shared handle when Windows keeps a compatible write handle open — only while Steam is absent, since a handle held with Steam running is probably Steam's, and driving the controller alongside it is worse than refusing. Refusing is what escalates to the device cycle that takes the handle back. - Add the Nereid receiver PID (0x1305) Defects found by QA and fixed here: - The battery payload is [chargeState, percent] on every transport, not just Bluetooth. A 92% battery was reported, and fed to the DS4 battery level, as 1%. - Game mode could not tell an idle receiver from a contested one, so a controller that was merely switched off triggered three device cycles, restarting the receiver's devnodes for nothing. - A receiver raises no device event when a slot becomes occupied, so waking a controller went unnoticed for as long as it was left. Poll for it instead. - Overlapping cycles undid successful claims: a cycle is asynchronous, and the arrivals it generated re-entered the acquire path and fired another on top of it, ripping the device back out a second after game mode came up. Cycles are now spaced. - Failure was announced while the last cycle was still landing — the alert fired one second before success. The verdict now waits out a grace period. Verified on the puck, Bluetooth, and wired: handoff to Steam on game launch, reclaim afterwards, and no duplicate input. Reclaiming takes around nine seconds on a four-slot receiver, dominated by SetupDiCallClassInstaller tearing down each interface in turn. Three attempts to shorten it (batching the cycle, a shorter settle, cycling only the contested interface) all regressed the reclaim and were reverted; it needs to be worked out against hardware rather than reasoned about. Co-Authored-By: Dan Bosscher <danbosscher@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
9 files changed
+314
-66
No files matched your search
+67
-13
@@ -229,9 +229,14 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
||||
}
|
||||
return 0;
|
||||
|
||||
case WM_STEAMSTATE:
|
||||
ApplySteamState(static_cast<SteamState>(wp));
|
||||
case WM_STEAMSTATE: {
|
||||
const auto steamState = static_cast<SteamState>(wp);
|
||||
// Outside ApplySteamState, which returns early in Manual mode — the
|
||||
// shared-handle decision needs to know about Steam in every mode.
|
||||
m_controller->SetSteamPresent(steamState != SteamState::NoSteam);
|
||||
ApplySteamState(steamState);
|
||||
return 0;
|
||||
}
|
||||
|
||||
case WM_ALERT: {
|
||||
if (!m_notificationsEnabled)
|
||||
@@ -252,6 +257,21 @@ LRESULT TrayApp::HandleMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
||||
KillTimer(m_hwnd, IDT_ACQUIRE);
|
||||
if (m_autoMode != AutoMode::Manual && WantControl(m_steamWatcher.GetState()))
|
||||
TryAcquireController();
|
||||
} else if (wp == IDT_WAKE_POLL) {
|
||||
KillTimer(m_hwnd, IDT_WAKE_POLL);
|
||||
// Cheap probe — we are only asking whether anything woke up.
|
||||
if (m_autoMode != AutoMode::Manual && WantControl(m_steamWatcher.GetState()))
|
||||
TryAcquireController(WAKE_PROBE_MS);
|
||||
} else if (wp == IDT_ACQUIRE_VERDICT) {
|
||||
KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT);
|
||||
// The last cycle's re-arrival had a grace period to land. If it
|
||||
// did, the controller is ours and there is nothing to report.
|
||||
if (m_controller->IsGameModeActive()) return 0;
|
||||
EventLog::Write("AUTO: giving up acquiring after %d device cycles", MAX_ACQUIRE_CYCLES);
|
||||
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 0;
|
||||
|
||||
@@ -304,7 +324,10 @@ void TrayApp::ApplySteamState(SteamState state) {
|
||||
TryAcquireController();
|
||||
} else {
|
||||
KillTimer(m_hwnd, IDT_ACQUIRE);
|
||||
KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT);
|
||||
KillTimer(m_hwnd, IDT_WAKE_POLL);
|
||||
m_acquireRetries = 0;
|
||||
m_lastCycleTick = 0;
|
||||
const bool hadControl = m_controller->IsGameModeActive();
|
||||
// Good citizen: restore lizard mode and close our HID handles so
|
||||
// Steam can claim the controller without contention.
|
||||
@@ -317,9 +340,9 @@ void TrayApp::ApplySteamState(SteamState state) {
|
||||
}
|
||||
}
|
||||
|
||||
void TrayApp::TryAcquireController() {
|
||||
void TrayApp::TryAcquireController(uint32_t stateWaitMs) {
|
||||
m_controller->OnDeviceChange();
|
||||
m_controller->EnableGameMode();
|
||||
auto outcome = m_controller->EnableGameMode(stateWaitMs);
|
||||
|
||||
// A short burst of rapid retries: right after a device cycle we race
|
||||
// Steam's re-enumeration for the exclusive open, and starting the claim
|
||||
@@ -327,33 +350,64 @@ void TrayApp::TryAcquireController() {
|
||||
for (int i = 0; i < 10 && !m_controller->IsGameModeActive(); ++i) {
|
||||
Sleep(50);
|
||||
m_controller->OnDeviceChange();
|
||||
m_controller->EnableGameMode();
|
||||
outcome = m_controller->EnableGameMode(stateWaitMs);
|
||||
}
|
||||
if (m_controller->IsGameModeActive()) {
|
||||
KillTimer(m_hwnd, IDT_ACQUIRE_VERDICT);
|
||||
KillTimer(m_hwnd, IDT_WAKE_POLL);
|
||||
m_acquireRetries = 0;
|
||||
m_lastCycleTick = 0;
|
||||
return;
|
||||
}
|
||||
if (!m_controller->IsConnected()) return; // nothing plugged in — arrival will retrigger
|
||||
|
||||
// No slot is emitting state: the controller is off, asleep, or the
|
||||
// receiver has no controller paired into it. Cycling cannot conjure one
|
||||
// up, and doing it anyway restarts the receiver's devnodes (and can
|
||||
// prompt for elevation) just because nothing is switched on.
|
||||
//
|
||||
// Poll instead of waiting for an event. A receiver keeps publishing all
|
||||
// of its slot interfaces whether or not a controller is paired into one,
|
||||
// so switching the controller on produces no WM_DEVICECHANGE at all —
|
||||
// measured: 88 seconds of silence after the controller was turned on.
|
||||
if (outcome == ControllerManager::GameModeOutcome::NoActiveController) {
|
||||
SetTimer(m_hwnd, IDT_WAKE_POLL, WAKE_POLL_MS, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
// A cycle is asynchronous — fired through Task Scheduler, and the helper
|
||||
// pauses a second between disable and enable. Every device arrival it
|
||||
// produces re-enters this function, so without a floor on the spacing we
|
||||
// stack another cycle on top of one still in flight. Observed: game mode
|
||||
// came up at :55.355 and our own second cycle ripped the device back out
|
||||
// at :56.367. Wait for the one already running to finish instead.
|
||||
const ULONGLONG nowTick = GetTickCount64();
|
||||
if (m_lastCycleTick != 0 && nowTick - m_lastCycleTick < CYCLE_MIN_GAP_MS) {
|
||||
SetTimer(m_hwnd, IDT_ACQUIRE, ACQUIRE_RETRY_MS, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
// Steam holds a write handle, so our exclusive claim can't succeed while
|
||||
// its handle lives. Cycle the device to invalidate it and retry on
|
||||
// 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.");
|
||||
// Don't announce failure yet. The last cycle's re-arrival can still be
|
||||
// in flight — on a four-interface receiver the round trip outran the
|
||||
// retry timer, so the app cried failure a second before succeeding.
|
||||
// Let the grace timer deliver the verdict instead.
|
||||
SetTimer(m_hwnd, IDT_ACQUIRE_VERDICT, ACQUIRE_VERDICT_MS, nullptr);
|
||||
return;
|
||||
}
|
||||
++m_acquireRetries;
|
||||
m_lastCycleTick = nowTick;
|
||||
EventLog::Write("AUTO: exclusive claim blocked — cycling device (attempt %d)", m_acquireRetries);
|
||||
m_controller->ReleaseDevices();
|
||||
if (!RestartControllerDevices()) return; // helper unavailable — balloon shown
|
||||
// The cycle runs asynchronously via the helper task; the device-arrival
|
||||
// notification drives the claim, with this timer as the fallback.
|
||||
SetTimer(m_hwnd, IDT_ACQUIRE, 2500, nullptr);
|
||||
// notification drives the claim, with this timer as the fallback. It must
|
||||
// outlast a cycle: the helper waits a second between disable and enable,
|
||||
// then a multi-slot receiver has to re-enumerate every interface.
|
||||
SetTimer(m_hwnd, IDT_ACQUIRE, ACQUIRE_RETRY_MS, nullptr);
|
||||
}
|
||||
|
||||
bool TrayApp::RestartControllerDevices() {
|
||||
|
||||
Reference in new issue
Block a user