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. image 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