diff --git a/CMakeLists.txt b/CMakeLists.txt
index dc6e52b..0b00b84 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -173,6 +173,17 @@ if (MSVC)
set_target_properties(TriggerProbe PROPERTIES LINK_FLAGS_DEBUG "/SUBSYSTEM:CONSOLE")
endif()
+# PowerOffProbe — tries the firmware "turn off controller" command candidates and
+# watches whether the pad goes quiet, so Steam + Y can power it down (issue #106).
+add_executable(PowerOffProbe
+ src/probe/PowerOffProbe.cpp
+)
+target_link_libraries(PowerOffProbe PRIVATE SteamController)
+if (MSVC)
+ target_compile_options(PowerOffProbe PRIVATE /W4 /WX /utf-8)
+ set_target_properties(PowerOffProbe PROPERTIES LINK_FLAGS_DEBUG "/SUBSYSTEM:CONSOLE")
+endif()
+
# HapticTester — local-only interactive haptic tuning tool (source is gitignored)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/src/probe/HapticTester.cpp")
add_executable(HapticTester src/probe/HapticTester.cpp)
diff --git a/README.md b/README.md
index d4a17bd..0e7611b 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ When **Steamless Mode** is active, the app disables the controller's built-in ke
- Brand new extensive remapping controls for back buttons and trackpads. Save custom mappings and load them per game!
- Use a trackpad as a **directional pad** — press its outer edge for a direction and its middle for a button of its own — or as a **single button**. Each direction, the click and a tap are all rebindable.
- Enable/Disable manually, or use new auto modes to turn on / off when it makes sense.
+- **Steam + Y turns the controller off**, just like it does under Steam (puck and Bluetooth; a controller on a cable is powered by it and stays on).
- Start on launch, suppress notifications, make this utility your own.
diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp
index 46f2185..69b88ff 100644
--- a/src/app/ControllerManager.cpp
+++ b/src/app/ControllerManager.cpp
@@ -53,6 +53,16 @@ struct ControllerManager::Slot {
SteamController::AccessClaim::Failed;
int lastBatteryPercent = -1; // -1 = never reported
+ // Steam + Y powers the controller off, as it does under Steam. Set once the
+ // firmware has accepted the command, so the silence and the disconnect that
+ // follow are read as what the user asked for rather than as a fault. Atomic
+ // because the read thread sets it and the device sync reads it.
+ std::atomic powerOffRequested{false};
+ // Read thread only. When the command went out, and whether the chord was
+ // already down last frame — it fires on the press, not on every frame held.
+ std::chrono::steady_clock::time_point powerOffAt{};
+ bool powerChordDown = false;
+
// When this slot was last found silent. Probing for a state report costs
// the full timeout on an empty puck slot, and the acquire path retries in
// a rapid burst — without this, three empty slots would block the UI
@@ -953,7 +963,8 @@ void ControllerManager::SyncDevices() {
// 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) {
+ // Nor when the user switched it off with Steam + Y.
+ if ((*it)->gameModeActive && m_alertFn && !(*it)->powerOffRequested.load()) {
if ((*it)->transport == SteamController::Transport::Bluetooth)
m_alertFn(L"Controller disconnected",
L"The Bluetooth connection dropped — the controller "
@@ -1252,7 +1263,8 @@ void ControllerManager::ReadLoop(Slot* slot) {
stalled = true;
EventLog::Write("STALL: no reports for 4s (lastBattery=%d%%) %ls",
slot->lastBatteryPercent, slot->path.c_str());
- if (m_alertFn) {
+ // Silence after Steam + Y is the controller doing as it was told.
+ if (m_alertFn && !slot->powerOffRequested.load()) {
wchar_t text[256];
if (slot->lastBatteryPercent >= 0)
swprintf_s(text,
@@ -1313,6 +1325,54 @@ void ControllerManager::ReadLoop(Slot* slot) {
"dropped before it reaches SendInput", n);
}
+ // Steam + Y powers the controller off, the same chord Steam reserves
+ // for it. Handled before anything else sees the frame so the game never
+ // gets the Guide press or the Y: the virtual pad is released and the
+ // frame dropped. Fires on the press of the chord, in either order, and
+ // not on a cable — the pad is powered by it and would only ignore the
+ // command, after we had frozen input for nothing.
+ {
+ const bool chord = n > 4
+ && (buf[4] & SteamController::BTN_STEAM) != 0
+ && (buf[2] & SteamController::BTN_Y) != 0
+ && slot->transport != SteamController::Transport::Wired;
+ const bool pressed = chord && !slot->powerChordDown;
+ slot->powerChordDown = chord;
+
+ if (pressed && !slot->powerOffRequested.load()) {
+ if (slot->sc->PowerOff()) {
+ EventLog::Write("POWEROFF: Steam+Y, command sent (transport=%s) %ls",
+ SteamController::TransportName(slot->transport),
+ slot->path.c_str());
+ slot->powerOffAt = std::chrono::steady_clock::now();
+ slot->powerOffRequested = true;
+ ReleaseHeldPaddleInputs(*slot);
+ if (slot->vc) {
+ uint8_t idle[64] = {};
+ idle[0] = buf[0];
+ slot->vc->Update(idle, n, m_profile, PadDigital{});
+ }
+ } else {
+ EventLog::Write("POWEROFF: Steam+Y, command write failed %ls",
+ slot->path.c_str());
+ }
+ }
+
+ if (slot->powerOffRequested.load()) {
+ // The pad has a moment to go quiet. One still reporting after
+ // that ignored the command, or woke straight back up, so hand
+ // it back to the game.
+ static constexpr auto kPowerOffGrace = std::chrono::seconds(3);
+ if (std::chrono::steady_clock::now() - slot->powerOffAt < kPowerOffGrace) {
+ hasPrev = false;
+ continue;
+ }
+ slot->powerOffRequested = false;
+ EventLog::Write("POWEROFF: still reporting after %lld s, resuming input",
+ static_cast(kPowerOffGrace.count()));
+ }
+ }
+
// Whether each pad is pressed has to be settled before the pads are
// updated, because a directional pad resolves its directions from it.
{
diff --git a/src/probe/PowerOffProbe.cpp b/src/probe/PowerOffProbe.cpp
new file mode 100644
index 0000000..725f514
--- /dev/null
+++ b/src/probe/PowerOffProbe.cpp
@@ -0,0 +1,352 @@
+// PowerOffProbe — finds out whether the Steam Controller 2026 has a firmware
+// command that turns it off, so Steam + Y (issue #106) can power the pad down
+// the way Steam does.
+//
+// What is known: the older Steam hardware has "turn off controller" command
+// 0x9F in the same feature-report channel the rest of the app already uses (the
+// Linux hid-steam driver and SDL both list it, and the original Steam Controller
+// took the ASCII payload "off!"). What is not known: whether the SC2026 firmware
+// honours it, with which payload, or over which transport. Neither SDL nor Linux
+// ever sends it to a Triton pad, so there is nothing to copy; this tool tries the
+// candidates and watches what the pad does.
+//
+// "Did it work" is read two ways, because a write that returns success only means
+// the host handed the report over:
+// - the write result (a refused report is a clear no), and
+// - whether the pad's input reports stop. A pad that powered off goes quiet and,
+// over Bluetooth, drops its connection; one that ignored the command keeps
+// streaming. The tool prints the silence time after every send.
+//
+// Like the other probes it opens the device shared and never touches lizard mode,
+// so Steam can keep running. The one thing that changes the pad's state is the
+// command you choose to send — and a successful one switches it off, which is the
+// point. Press the Steam button to wake it, then re-run the tool (the device path
+// can change on reconnect).
+//
+// Run it on each transport you care about (USB cable, puck, Bluetooth): the
+// firmware path may differ, and a command the pad ignores over one can work over
+// another.
+
+#define WIN32_LEAN_AND_MEAN
+#define NOMINMAX
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include "hid/HidDevice.h"
+#include "steam/SteamController.h"
+
+namespace {
+
+constexpr uint8_t kCmdTurnOff = 0x9F;
+
+struct Variant {
+ const char* name;
+ uint8_t reportId; // feature report ID the command rides in
+ std::vector payload;
+};
+
+// The order is most to least likely, so a first success stops the search early.
+// The Steam Controller 2015 form is first: 9F, length 4, "off!".
+const Variant kVariants[] = {
+ { "9F \"off!\" (report 01)", SteamController::FEATURE_REPORT_CMD, { 'o', 'f', 'f', '!' } },
+ { "9F no payload (report 01)", SteamController::FEATURE_REPORT_CMD, {} },
+ { "9F \"off\" (report 01)", SteamController::FEATURE_REPORT_CMD, { 'o', 'f', 'f' } },
+ { "9F 01 (report 01)", SteamController::FEATURE_REPORT_CMD, { 0x01 } },
+ { "9F \"off!\" (report 02)", SteamController::FEATURE_REPORT_CMD2, { 'o', 'f', 'f', '!' } },
+ { "9F no payload (report 02)", SteamController::FEATURE_REPORT_CMD2, {} },
+};
+constexpr int kVariantCount = static_cast(sizeof(kVariants) / sizeof(kVariants[0]));
+
+double NowMs() {
+ static const LARGE_INTEGER freq = [] {
+ LARGE_INTEGER f;
+ QueryPerformanceFrequency(&f);
+ return f;
+ }();
+ LARGE_INTEGER c;
+ QueryPerformanceCounter(&c);
+ return static_cast(c.QuadPart) * 1000.0 / static_cast(freq.QuadPart);
+}
+
+std::atomic g_quit{false};
+std::atomic g_stateReports{0};
+std::atomic g_lastStateMs{0.0};
+std::atomic g_lastButtons[4]{};
+
+// Reads state reports so the tool can tell "pad went quiet" from "pad is still
+// talking". Also keeps the latest button bytes so `watch` can show Steam + Y.
+void ReaderThread(HidDevice* dev) {
+ while (!g_quit.load()) {
+ uint8_t buf[64] = {};
+ const size_t n = dev->ReadInputReport(buf, sizeof(buf), 100);
+ if (n == 0) continue;
+ if (!SteamController::IsStateReportId(buf[0]) || n < 6) continue;
+ for (int i = 0; i < 4; ++i) g_lastButtons[i].store(buf[2 + i]);
+ g_lastStateMs.store(NowMs());
+ ++g_stateReports;
+ }
+}
+
+double SilenceMs() {
+ const double last = g_lastStateMs.load();
+ return last > 0.0 ? NowMs() - last : -1.0;
+}
+
+std::string Hex(const std::vector& v) {
+ std::string s;
+ char b[4];
+ for (uint8_t x : v) {
+ snprintf(b, sizeof(b), "%02X ", x);
+ s += b;
+ }
+ if (!s.empty()) s.pop_back();
+ return s;
+}
+
+// Frame the command the way every other firmware command in the app is framed:
+// [report id | command | payload size | payload...], zero padded to 64.
+bool SendCommand(HidDevice& dev, uint8_t reportId, uint8_t cmd, const std::vector& payload) {
+ if (payload.size() > 61) {
+ puts("Payload too long (max 61 bytes).");
+ return false;
+ }
+ uint8_t buf[64] = {};
+ buf[0] = reportId;
+ buf[1] = cmd;
+ buf[2] = static_cast(payload.size());
+ if (!payload.empty()) std::memcpy(buf + 3, payload.data(), payload.size());
+ if (!dev.SendFeatureReport(buf, sizeof(buf))) {
+ const DWORD err = GetLastError();
+ printf(" write FAILED (Win32 error %lu) - the report was refused%s\n", err,
+ err == ERROR_DEVICE_NOT_CONNECTED ? " (1167: nothing is connected behind this interface)" : "");
+ return false;
+ }
+ puts(" write ok");
+ return true;
+}
+
+// After a send, watch for the pad going quiet. A powered-off pad stops reporting
+// within a report interval or two, so a few seconds is plenty; a pad that is
+// merely idle still streams state, so silence is a real signal.
+void Observe(double sentAtMs) {
+ constexpr double kWatchMs = 4000.0;
+ constexpr double kQuietMs = 1000.0;
+ const uint64_t before = g_stateReports.load();
+ while (NowMs() - sentAtMs < kWatchMs) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(100));
+ if (SilenceMs() >= kQuietMs) {
+ // The last report is the moment the pad actually went quiet.
+ printf(" -> input reports STOPPED %.2f s after the send. The pad likely powered off.\n"
+ " Press the Steam button to wake it, then restart this tool.\n",
+ std::max(g_lastStateMs.load() - sentAtMs, 0.0) / 1000.0);
+ return;
+ }
+ }
+ printf(" -> still streaming (%llu reports in the %.0f s since the send). The pad ignored it.\n",
+ static_cast(g_stateReports.load() - before), kWatchMs / 1000.0);
+}
+
+bool Confirm(const char* what) {
+ printf("%s\nThis may switch the controller off. Type 'yes' to send: ", what);
+ fflush(stdout);
+ char line[64];
+ if (!fgets(line, sizeof(line), stdin)) return false;
+ return std::strncmp(line, "yes", 3) == 0;
+}
+
+std::vector Split(const std::string& line) {
+ std::istringstream is(line);
+ std::vector out;
+ std::string t;
+ while (is >> t) out.push_back(t);
+ return out;
+}
+
+bool ParseHex(const std::string& s, uint8_t& out) {
+ char* end = nullptr;
+ const unsigned long v = std::strtoul(s.c_str(), &end, 16);
+ if (end == s.c_str() || *end != '\0' || v > 0xFF) return false;
+ out = static_cast(v);
+ return true;
+}
+
+void PrintHelp() {
+ puts(
+ "\nPowerOffProbe - does the pad have a software power-off command?\n"
+ "\n"
+ " list the built-in candidates\n"
+ " send send candidate n, then watch whether the pad goes quiet\n"
+ " raw [payload hex..] send an arbitrary command in feature report 01, e.g.\n"
+ " 'raw 9f 6f 66 66 21'. The size byte is filled in.\n"
+ " rawid [hex..] same, but in a feature report ID of your choosing\n"
+ " watch show the live button bytes and report silence; press\n"
+ " Steam + Y to see which bits Steam itself would see\n"
+ " status reports seen and how long the pad has been silent\n"
+ " q quit\n"
+ "\n"
+ "A write that succeeds only means the host handed the report over. What counts is\n"
+ "whether the pad then stops reporting. If it powers off, press Steam to wake it.\n");
+}
+
+void PrintList() {
+ puts("\nCandidates:");
+ for (int i = 0; i < kVariantCount; ++i) {
+ const Variant& v = kVariants[i];
+ printf(" %d %-28s frame: %02X %02X %02X %s\n", i + 1, v.name, v.reportId,
+ kCmdTurnOff, static_cast(v.payload.size()), Hex(v.payload).c_str());
+ }
+ puts("");
+}
+
+void Watch() {
+ puts("Watching - hold Steam + Y, then any key to stop. Bytes are buf[2..5].");
+ while (!_kbhit() && !g_quit.load()) {
+ const uint8_t b2 = g_lastButtons[0].load();
+ const uint8_t b4 = g_lastButtons[2].load();
+ printf("\r %02X %02X %02X %02X %s %s silent %5.0f ms ",
+ b2, g_lastButtons[1].load(), b4, g_lastButtons[3].load(),
+ (b4 & SteamController::BTN_STEAM) ? "STEAM" : " ",
+ (b2 & SteamController::BTN_Y) ? "Y" : " ", std::max(SilenceMs(), 0.0));
+ fflush(stdout);
+ std::this_thread::sleep_for(std::chrono::milliseconds(40));
+ }
+ while (_kbhit()) (void)_getch();
+ puts("");
+}
+
+} // namespace
+
+int main(int argc, char** argv) {
+ auto paths = SteamController::EnumerateAll();
+ if (paths.empty()) {
+ puts("No Steam Controller found (wired PID=1302, BT 1303 or dongle PID=1304).");
+ return 1;
+ }
+
+ // A puck publishes an interface per slot, and an empty slot rejects feature
+ // reports with error 1167 (device not connected). So rather than trust #0,
+ // listen on each interface and take the first one that is streaming state.
+ printf("%zu interface(s):\n", paths.size());
+ size_t which = paths.size();
+ for (size_t i = 0; i < paths.size(); ++i) {
+ bool live = false;
+ {
+ HidDevice t;
+ if (t.Open(paths[i])) {
+ uint8_t buf[64] = {};
+ const size_t n = t.ReadInputReport(buf, sizeof(buf), 500);
+ live = n > 0 && SteamController::IsStateReportId(buf[0]);
+ }
+ }
+ printf(" #%zu %-9s %s %ls\n", i,
+ SteamController::TransportName(SteamController::TransportFromPath(paths[i])),
+ live ? "LIVE" : "no reports", paths[i].c_str());
+ if (live && which == paths.size()) which = i;
+ }
+
+ if (argc > 1) {
+ which = static_cast(std::strtoul(argv[1], nullptr, 10));
+ if (which >= paths.size()) {
+ printf("Interface %zu does not exist (%zu found).\n", which, paths.size());
+ return 1;
+ }
+ } else if (which == paths.size()) {
+ puts("\nNo interface is producing state reports. Wake the controller (press Steam) and\n"
+ "run again, or force one with 'PowerOffProbe '.");
+ return 1;
+ }
+
+ HidDevice dev;
+ if (!dev.Open(paths[which])) {
+ puts("Failed to open HID device.");
+ return 1;
+ }
+ printf("\nUsing #%zu (%s)\n", which,
+ SteamController::TransportName(SteamController::TransportFromPath(paths[which])));
+ puts("Reading shared - lizard mode and Steam are untouched.");
+
+ std::thread reader(ReaderThread, &dev);
+
+ // Give the reader a moment so the first `status` is meaningful.
+ std::this_thread::sleep_for(std::chrono::milliseconds(500));
+ if (g_stateReports.load() == 0)
+ puts("WARNING: no state reports yet - a 'went quiet' result would be meaningless.\n"
+ " Move a stick; if still nothing, try another interface number.");
+
+ PrintHelp();
+ PrintList();
+
+ char lineBuf[256];
+ while (true) {
+ printf("> ");
+ fflush(stdout);
+ if (!fgets(lineBuf, sizeof(lineBuf), stdin)) break;
+
+ const auto toks = Split(lineBuf);
+ if (toks.empty()) continue;
+ const std::string& cmd = toks[0];
+
+ if (cmd == "q" || cmd == "quit") break;
+ if (cmd == "?" || cmd == "help") { PrintHelp(); continue; }
+ if (cmd == "list") { PrintList(); continue; }
+ if (cmd == "watch") { Watch(); continue; }
+
+ if (cmd == "status") {
+ printf("State reports: %llu, silent for %.0f ms\n",
+ static_cast(g_stateReports.load()),
+ std::max(SilenceMs(), 0.0));
+ continue;
+ }
+
+ if (cmd == "send") {
+ const int n = toks.size() >= 2 ? std::atoi(toks[1].c_str()) : 0;
+ if (n < 1 || n > kVariantCount) { printf("usage: send <1-%d>\n", kVariantCount); continue; }
+ const Variant& v = kVariants[n - 1];
+ if (!Confirm(v.name)) { puts("Not sent."); continue; }
+ const double t = NowMs();
+ if (SendCommand(dev, v.reportId, kCmdTurnOff, v.payload)) Observe(t);
+ continue;
+ }
+
+ if (cmd == "raw" || cmd == "rawid") {
+ const bool withId = cmd == "rawid";
+ const size_t first = withId ? 2 : 1;
+ if (toks.size() < first + 1) {
+ puts(withId ? "usage: rawid [payload hex..]" : "usage: raw [payload hex..]");
+ continue;
+ }
+ uint8_t id = SteamController::FEATURE_REPORT_CMD;
+ uint8_t c = 0;
+ bool ok = (!withId || ParseHex(toks[1], id)) && ParseHex(toks[first], c);
+ std::vector payload;
+ for (size_t i = first + 1; ok && i < toks.size(); ++i) {
+ uint8_t b = 0;
+ ok = ParseHex(toks[i], b);
+ payload.push_back(b);
+ }
+ if (!ok) { puts("Bytes are hex, 00..FF."); continue; }
+ char what[96];
+ snprintf(what, sizeof(what), "raw: report %02X cmd %02X, %zu payload byte(s)", id, c, payload.size());
+ if (!Confirm(what)) { puts("Not sent."); continue; }
+ const double t = NowMs();
+ if (SendCommand(dev, id, c, payload)) Observe(t);
+ continue;
+ }
+
+ puts("Unknown command. 'help' lists them.");
+ }
+
+ g_quit.store(true);
+ reader.join();
+ return 0;
+}
diff --git a/src/steam/SteamController.cpp b/src/steam/SteamController.cpp
index 07d4a23..87a43e1 100644
--- a/src/steam/SteamController.cpp
+++ b/src/steam/SteamController.cpp
@@ -269,6 +269,18 @@ bool SteamController::SendKeepalive() {
return m_device.SendFeatureReport(buf, sizeof(buf));
}
+bool SteamController::PowerOff() {
+ // Rumble first: a motor left running when the pad drops off the link would
+ // be latched on until it wakes again.
+ SetRumble(0, 0);
+
+ static constexpr uint8_t kOff[] = { 'o', 'f', 'f', '!' };
+ uint8_t buf[64];
+ BuildCmd(buf, CMD_TURN_OFF_CONTROLLER, kOff, sizeof(kOff));
+ std::lock_guard lock(m_writeMutex);
+ return m_device.SendFeatureReport(buf, sizeof(buf));
+}
+
void SteamController::EmergencyLizardRestore() noexcept {
if (!m_device.IsOpen()) return;
uint8_t buf[64];
diff --git a/src/steam/SteamController.h b/src/steam/SteamController.h
index f80a4fa..340551f 100644
--- a/src/steam/SteamController.h
+++ b/src/steam/SteamController.h
@@ -60,6 +60,7 @@ public:
static constexpr uint8_t CMD_SET_SETTINGS = 0x87;
static constexpr uint8_t CMD_GET_SETTINGS = 0x89;
static constexpr uint8_t CMD_LOAD_DEFAULT_SETTINGS = 0x8E; // ← restore settings for lizard on
+ static constexpr uint8_t CMD_TURN_OFF_CONTROLLER = 0x9F; // payload "off!" — powers the pad down
// Triton output report IDs.
static constexpr uint8_t OUT_HAPTIC_RUMBLE = 0x80; // continuous two-channel haptic
@@ -212,6 +213,13 @@ public:
// every couple of seconds while game mode is active, like hid-steam does.
bool SendKeepalive();
+ // Ask the firmware to power the controller off — what Steam does for
+ // Steam + Y. Confirmed on a puck slot with payload "off!" (also accepted
+ // with no payload or a bare "off"). Only means anything on a wireless
+ // link: a pad on a USB cable is powered by the cable and stays on. Returns
+ // whether the write was accepted; the pad going quiet is the real answer.
+ bool PowerOff();
+
// Crash-context lizard restore: sends the default-mappings and
// default-settings feature reports WITHOUT taking locks or joining
// threads, so it is safe from an unhandled-exception filter where another