Всем привет! Я — новичок на Хабре, потому, набравшись смелости, хотел бы поделиться небольшим домашним проектом, над которым работаю последний год в свободное время, а затем интересным случаем взаимодействия с контроллером Xbox Series.

Введение

Я много пользуюсь Steam на Windows 10\11, но лишь последние пару лет я предпочитаю играть не за компьютером, потому стал часто пользоваться режимом Big Picture - это режим для игры на диване: целый отдельный UI с более консольным ощущением. Steam делает ваш телевизор основным монитором и запускает на нем игру.

Steam Big Picture UI
Steam Big Picture UI

Но у этого режима есть несколько проблем, которые меня никак не устраивали и которые я принялся решать.

  1. Big Picture не меняет устройства аудиовывода. Да, картинка улетает на телевизор на запуске, но если у вас настольные колонки у монитора, звук останется в них.

  2. Если включен Ночной Свет по расписанию, то во время игры он может активироваться и экран "потеплеет".

  3. В Big Picture есть явная настройка "Turn off controllers when exiting Big Picture Mode" (Отключить джойстики при выходе из Big Picture), а так же есть горячие клавиши для Xbox (Chords) Guide + Y. Но ни на выходе из режима, ни по горячим клавишам джойстик не отключается. Как ни странно, но в интернете мало информации о том, почему джойстик не отключается, даже особо не нашлось каких-то постов-жалоб пользователей. Я смог лишь найти пост от 2018 года, где представитель Valve говорит о том, что действительно, джойстик Xbox через Steam отключить нельзя, но при этом до сих указывают горячие клавиши для этого.

Big Picture Manager

Почти всё это я решил в самописном C# приложении Big Picture Manager. Суть проста: приложение слушает запуск окна Steam с названием Steam Big Picture.

  1. В приложении можно выбрать на какое аудиоустройство переключиться при включении Big Picture, а после выхода вернуться на исходное.

  2. Благодаря ИИ-агентам, вроде Cursor, мне удалось разобраться как записываются байты в регистре Windows, отвечающие за работу Ночного Света. На запуске приложение проверяет каким образом запущен ночной свет, отключает его, а после выхода восстанавливает.

  3. Пункт три становится тем местом, откуда начнется основное повествование статьи.

Xbox Series X/S - Electric Volt
Xbox Series X/S - Electric Volt

Я пользуюсь джойстиком Xbox Series в цвете Electric Volt. Пока не вышел Steam Controller 2, я считаю, что это лучший контроллер, доступный сегодня на рынке (вопрос, конечно, субъективный). Его можно подключить к ПК тремя способами: по Bluetooth, с помощью проприетарного радио-свистка от Microsoft, который работает по собственной закрытой технологии 2.4GHz и просто по USB кабелю.

Xbox Wireless Dongle
Xbox Wireless Dongle

В случае с Big Picture Manager решить отключение джойстика я смог только одним простым образом: на выходе из режима целиком отключается Bluetooth в системе. В таком сценарии джойстик сразу же уходит в отключение. Это то поведение, которое я пытаюсь добиться, но не путем полного отключения Bluetooth в системе. Идеально было бы отключать только джойстик при его работе от беспроводного адаптера.

Джойстик Xbox Series и GIPUSB

Я вообще не был знаком с тем, как игровые устройства взаимодействуют с компьютером, потому не было понятно с чего начать. На YouTube мне попалось видео с Дмитрием Рязанцовым, который раскрывает немного суть работы устройств ввода с играми, потому недолго думая я отыскал его на LinkedIn и обратился за советом. Можно сказать, что он дал главный толчок всему этому расследованию.

В 2024 году Microsoft выкатили документацию для разработчиков, создающих свои контроллеры, для того, чтобы они могли единообразно работать через GameInput. Кратко, GameInput - единый интерфейс для работы с игровыми аксессуарами, который под собой объединяет XInput, DirectInput, Raw Input, Human Interface Device (HID) и WinRT API. Сам же транспортный протокол для передачи данных называется GIP (Game Input Protocol). В него упирается всё.

Совет Дмитрия заключался в следующем:

Так что в теории можно получить хендл и попробовать отправить команду отключения контроллера. 

В протоколе есть:

3.1.5.5.5 Set Device State Command

0x04 Off GIP device SHOULD transition to the GIP Off State. See State Machine for details on device behavior.

Как открыть хендл и отправить пакет на девайс описано тут: https://gist.github.com/TheNathannator/bcebc77e653f71e77634144940871596 (как я понял можно отправить пакет через WriteFile)

Суть совета в том, что есть определенный байт, который, при отправке на устройство, вызывает отключение. Его нужно правильно сформировать и передать по GIP на контроллер.

Ссылка, которую дал Дмитрий ведет на небольшую статью от TheNathannator, который исследовал работу с GIP до того, как вышла документация. Несмотря на то, что она является устаревшей, я нашел точку входа для работы с протоколом:

Usage of the GIP interface starts with acquiring a handle to the interface via a device path of \\.\XboxGIP:

HANDLE hFile = CreateFileW(L"\\\\.\\XboxGIP", GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);

У меня есть небольшой опыт работы с C#, потому я начал делать проект на этом языке. Прошу держать в уме, что все эти манипуляции я проводил при джойстике, подключенном именно по проприетарному беспроводу. Вот что мне удалось сделать:

using System.ComponentModel;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;

public class XboxGipController
{
    private const uint GENERIC_READ = 0x80000000;
    private const uint GENERIC_WRITE = 0x40000000;
    private const uint FILE_SHARE_READ = 0x00000001;
    private const uint FILE_SHARE_WRITE = 0x00000002;
    private const uint OPEN_EXISTING = 3;
    private const uint FILE_ATTRIBUTE_NORMAL = 0x00000080;

    private const uint GIP_ADD_REENUMERATE_CALLER_CONTEXT = 0x40001CD0;

    [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
    private static extern SafeFileHandle CreateFileW(
        string lpFileName,
        uint dwDesiredAccess,
        uint dwShareMode,
        IntPtr lpSecurityAttributes,
        uint dwCreationDisposition,
        uint dwFlagsAndAttributes,
        IntPtr hTemplateFile
    );

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool DeviceIoControl(
        SafeFileHandle hDevice,
        uint dwIoControlCode,
        IntPtr lpInBuffer,
        uint nInBufferSize,
        IntPtr lpOutBuffer,
        uint nOutBufferSize,
        out uint lpBytesReturned,
        IntPtr lpOverlapped
    );

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool ReadFile(
        SafeFileHandle hFile,
        byte[] lpBuffer,
        uint nNumberOfBytesToRead,
        out uint lpNumberOfBytesRead,
        IntPtr lpOverlapped
    );

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool WriteFile(
        SafeFileHandle hFile,
        byte[] lpBuffer,
        uint nNumberOfBytesToWrite,
        out uint lpNumberOfBytesWritten,
        IntPtr lpOverlapped
    );

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool CloseHandle(IntPtr hObject);

    private static void ProcessGipMessage(byte[] data, int length)
    {
        Console.WriteLine($"Received {length} bytes:");
        for (int i = 0; i < length; i++)
        {
            Console.Write($"{data[i]:X2} ");
            if ((i + 1) % 16 == 0)
                Console.WriteLine();
        }
        Console.WriteLine();
    }

    public static void ReenumerateGipControllers()
    {
        SafeFileHandle? hFile = null;

        try
        {
            // Open the GIP device interface
            hFile = CreateFileW(
                @"\\.\XboxGIP",
                GENERIC_READ | GENERIC_WRITE,
                FILE_SHARE_READ | FILE_SHARE_WRITE,
                IntPtr.Zero,
                OPEN_EXISTING,
                FILE_ATTRIBUTE_NORMAL,
                IntPtr.Zero
            );

            if (hFile.IsInvalid)
            {
                throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
            }

            // Send the re-enumeration command
            uint bytesReturned;
            bool success = DeviceIoControl(
                hFile,
                GIP_ADD_REENUMERATE_CALLER_CONTEXT,
                IntPtr.Zero,
                0,
                IntPtr.Zero,
                0,
                out bytesReturned,
                IntPtr.Zero
            );

            if (!success)
                throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());

            Console.WriteLine("GIP controller re-enumeration triggered successfully");

            byte[] powerOffCommand = new byte[64];
            // Xbox controller ID
            powerOffCommand[0] = 0x7E;
            powerOffCommand[1] = 0xED;
            powerOffCommand[2] = 0x82;
            powerOffCommand[3] = 0xC6;
            powerOffCommand[4] = 0x8B;
            powerOffCommand[5] = 0xDA;
            powerOffCommand[6] = 0x00;
            powerOffCommand[7] = 0x00;

            powerOffCommand[8] = 0x05; // Command ID
            powerOffCommand[9] = 0x20; // Flags
            powerOffCommand[10] = 0x01; // Sequence number
            powerOffCommand[11] = 0x01; // payload length
            powerOffCommand[12] = 0x04; // payload

            // Send the power off command
            // Comment the next 15 lines to skip writing and check how ReadFile works.
            // Otherwise the code will throw.
            uint bytesWritten;
            bool successWrite = WriteFile(
                hFile,
                powerOffCommand,
                (uint)powerOffCommand.Length,
                out bytesWritten,
                IntPtr.Zero
            );

            if (!successWrite)
            {
                ProcessGipMessage(powerOffCommand, powerOffCommand.Length);
                throw new Win32Exception(Marshal.GetLastWin32Error());
            }

            byte[] buffer = new byte[1000];
            uint bytesRead;
            while (true)
            {
                bool successRead = ReadFile(
                    hFile, // Handle to the GIP device
                    buffer, // Buffer to receive data
                    (uint)buffer.Length, // Buffer size
                    out bytesRead, // Number of bytes actually read
                    IntPtr.Zero
                );

                if (!successRead)
                {
                    int error = Marshal.GetLastWin32Error();
                    if (error == 259)
                        break;

                    throw new Win32Exception(error);
                }

                if (bytesRead > 0)
                {
                    ProcessGipMessage(buffer, (int)bytesRead);
                }
                else
                {
                    System.Threading.Thread.Sleep(10);
                }
            }
        }
        finally
        {
            hFile?.Close();
        }
    }

    public static void Main()
    {
        try
        {
            ReenumerateGipControllers();
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
    }
}

Что делает код:

  1. Открывает устройство \\.\XboxGIP для чтения и записи.

  2. Отправляет управляющий код 0x40001CD0 (IOCTL), который инициирует реенумерацию подключённых контроллеров.

  3. Формирует и отправляет команду выключения контроллера (power‑off) – фиксированная последовательность байт, включающая идентификатор, код команды и полезную нагрузку.

  4. Запускает бесконечный цикл чтения данных из устройства. Полученные пакеты выводятся в консоль в шестнадцатеричном виде.

  5. При ошибке чтения с кодом 259 (ERROR_NO_MORE_ITEMS) цикл завершается; любые другие ошибки приводят к исключению.

  6. Закрывает дескриптор устройства.

Исполнение этого кода привело к получению нескольких сообщений:

Received 315 bytes:
7E ED 82 C6 8B DA 00 00 04 20 00 00 27 01 00 00
00 00 00 00 5E 04 12 0B 10 00 01 00 00 00 00 00
00 00 00 00 00 00 23 01 CD 00 16 00 1B 00 1C 00
26 00 2F 00 4C 00 00 00 00 00 00 00 00 00 01 05
00 17 00 00 09 01 02 03 04 06 07 0C 0D 1E 08 01
04 05 06 0A 0C 0D 1E 01 1A 00 57 69 6E 64 6F 77
73 2E 58 62 6F 78 2E 49 6E 70 75 74 2E 47 61 6D
65 70 61 64 08 56 FF 76 97 FD 9B 81 45 AD 45 B6
45 BB A5 26 D6 2C 40 2E 08 DF 07 E1 45 A5 AB A3
12 7A F1 97 B5 E7 1F F3 B8 86 73 E9 40 A9 F8 2F
21 26 3A CF B7 FE D2 DD EC 87 D3 94 42 BD 96 1A
71 2E 3D C7 7D 6B E5 F2 87 BB C3 B1 49 82 65 FF
FF F3 77 99 EE 1E 9B AD 34 AD 36 B5 4F 8A C7 17
23 4C 9F 54 6F 77 CE 34 7A E2 7D C6 45 8C A4 00
42 C0 8B D9 4A C0 C8 96 EA 16 B2 8B 44 BE 80 7E
5D EB 06 98 E2 03 17 00 20 2C 00 01 00 10 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 17 00 09
3C 00 01 00 08 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 17 00 1E 40 00 01 00 22 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00

Received 53 bytes:
7E ED 82 C6 8B DA 00 00 02 20 00 00 21 00 00 00
00 00 00 00 7E ED 82 C6 8B DA 00 00 5E 04 12 0B
05 00 17 00 06 00 00 00 08 04 01 00 01 00 01 00
00 00 00 00 00

Received 24 bytes:
7E ED 82 C6 8B DA 00 00 03 20 00 00 04 00 00 00
00 00 00 00 8B 00 00 58

    Документация раскрывает, что при перечислении устройств, джойстик, работающий по GIP, выдает несколько сообщений.

    1. Сообщение метаданных (Metadata Response, 315 байт). Это ответ с метаданными геймпада (идентификация, возможности, строка класса Gamepad, плюс бинарные блоки по GIP).

    2. Приветственное сообщение (Hello message, 53 байта). Устройство шлёт Hello при установлении связи. Стандартное приветствие геймпада с VID/PID, прошивкой и версиями протоколов.

    3. Состояние устройства (Status Device, 24 байта). Состояние устройства (в т.ч. батарея, тип питания и т.д.).

    Здесь любопытно то, что все сообщения начинаются с 7E ED 82 C6 8B DA 00 00, что мне кажется, является ID контроллера.

    Хорошо, с чтением данных разобрались, в этот момент вся затея показалась мне необычайно радужной, но при попытке записи данных в устройство я столкнулся с проблемами. Я начал с того, что составил пакет, описывающий команду выключения устройства: 0x05 0x20 0x01 0x01 0x04.

    • 0x05 — Set Device State (Command Data Class, command 5)

    • 0x20 — Flags (флаги). Одиночный пакет с системным сообщением к главному устройству, без ack.

    • 0x01 — Инкрементирующийся, 0x00 зарезервирован, я пробовал разные значения

    • 0x01 — Payload Length (Длина отпраляемой команды)

    • 0x04 — State "Off" (Состояние Выкл.)

    Я пытался отправлять эту команду несколькими способами:

    • Отправлял только эти 5 байт. В ответ получал 1167 Device Not Connected;

    • Затем я решил добавить в начало ID моего джойстика. 7E ED 82 C6 8B DA 00 00 05 20 01 01 04. В результате я получил 122 Wrong length;

    • Почитав доки, я понял, что пакет должен быть 64 байта, потому я добавил нули в конце и на это получил 87 Invalid parameter;

    О каком параметре идет речь - одному богу известно. В документации ничего не говорится о параметрах, потому на этом месте идеи стали заканчиваться. На Stackoverflow я наткнулся на пост пользователя Victor, который занимается разработкой собственного контроллера. После короткой имейл-переписки, он мне подал идею отправить пакет выключения при подключении по USB-проводу. Я так и поступил.

    И вуаля, джойстик выключился. Успех был одновременно так близко, и так далеко. Это событие позволило сделать вывод, что сам контроллер способен принимать команды, и дело в самом протоколе GIP. Случайная идея заключалась в том, что данные каким-то образом шифруются при передаче и потому, когда я пытался отправлять команду, она отвергалась за неимением каких-то дополнительных или шифрованных данных.

    Microsoft learn и CRC16

    Я сделал пост на learn.microsoft с описанием того, что пытаюсь достичь. Честно сказать, первое впечатление от форума какое-то мусорное. Мне начали лететь ответы от ИИ, которые тут же удалялись модераторами, но затем мне ответил некий Varsha Dundigalla. Я до сих пор думаю, что он тоже какая-то версия ИИ (да и вообще интернет давно мёртв), потому что его ответы были иногда противоречивыми тому, что я уже установил, а его источники часто не содержали информации, на которую он ссылался.

    Он дал следующую информацию:

    The problem is that the Xbox controller expects a full GIP message, not just the raw payload. Your packet (05 20 01 04) is correct for the USB layer but incomplete for GIP. The protocol requires the device ID, command ID, flags, sequence number, payload length, payload, and a CRC16 checksum. Without these, the driver rejects the command with ERROR_INVALID_PARAMETER.

    The GIP protocol uses CRC-16-CCITT (polynomial 0x1021, initial value 0xFFFF). You calculate it over the entire message (device ID, command ID, flags, sequence number, payload length, payload) and append the two-byte checksum at the end. Without this, the controller will reject the packet.

    What it should look like conceptually: a downstream transport/report prefix (commonly 0x02 for GIP/USB), then the Set Device State header 0x05 0x20 <seq> 0x01, then payload 0x04 (Off), then two CRC bytes right after that payload. Your analyzer screenshot matches this pattern (02 05 20 01 04 + CRC) and the device ACKs it—so formatting, not permissions, is the blocker.

    Кратко говоря, структура должна выглядеть так: префикс + команда выключения (хедер + пэйлод) + CRC (на основе префикса и команды) + нули до 64 байт. Само собой, я пошел тестить, отправил 0x02 0x05 0x20 0x01 0x01 0x04 0x9D 0x3B, но, к сожалению, безуспешно. Получил ту же ошибку про неверный параметр. На этом момент я оставил попытки пытаться что-то записать в контроллер через WriteFile.

    Hidapistester

    Да, я перестал пытаться записывать что-то через SafeFileHandle, но это не значит, что я перестал пытаться вообще. Исследуя другие способы отправки команд, я открыл для себя hidapitester. Простая утилита для работы с HIDAPI. Используя аргументы, я попытался так же отправлять различные вариации команды отключения в свой джойстик, подключая его как по GIP, так и по Bluetooth:

    .\hidapitester.exe --open-path "\\?\HID#VID_045E&PID_0B12&IG_00#a&14e160f7&d&0000#{4d1e55b2-f16f-11cf-88cb-001111000030}" --send-output 0x02,0x05,0x20,0x01,0x01,0x04,0x9D,0x3B --length 64

    Ответ:

    Writing output report of 64-bytes...wrote 9 bytes:
     02 05 20 01 01 04 9D 3B 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
    Closing device

    Именно в этом варианте команды джойстик начал неожиданно вибрировать. Интересно, что записалось лишь 9 байт из всей команды. Этот момент обозначил новую веху - я могу что-то записать в контроллер. Сейчас я думаю, что я просто каким-то образом попал в команду вибрации, передаваемую через GameInput. Но все дальнейшие эксперименты не были результативны.

    Windows Management Instrumentation

    Теряя надежду, я начал думать шире. Задавшись вопросом отключения джойстика средствами системы, не пытаясь напрямую отдавать команды, я решил погуглить чем бы еще можно было бы отключить его по аналогии с тем, как он отключается, когда в системе выключается Bluetooth. Здесь так же не обошлось без подсказок. На тот момент я уже был так одержим желанием реализовать задуманное, что просто начал писать всем причастным к C# на LinkedIn.

    Юрий Худошин помог мне завайбкодить программу на С#, которая отключала джойстик и беспроводной адаптер на уровне Диспетчера устройств в системе (devmgmt.msc). Год назад итог был такой, что джойстик действительно отключался! Я не мог поверить глазам! У него гасла Guide кнопка и джойстик отключался! Неужели это был тот самый момент триумфа? Но скептики меня поймут, когда я скажу, что отказывался верить, пока не увижу весь цикл включения и выключения контроллера.

    И действительно, при попытке включить джойстик он буквально на долю секунды подключался к компьютеру и тут же выключался обратно. Помогала лишь переустановка драйверов путем удаления устройства из Диспетчера и повторным подключением устройства к компьютеру. Как я понял, отключив адаптер через Диспетчер, он терял связь с контроллером, а тот в свою очередь отключался, но нюанс в том, что, по всей видимости, то, что отключал код, не включалось обратно, так как терялся линк с джойстиком, который было невозможно восстановить. Я пробовал этот путь пару недель назад снова, сейчас джойстик даже не выключается. Система ждет перезагрузки, чтобы отключить устройство. Вероятно, было какое-то обновление.

    Вот код отключения и включения джойстика из репо, который я использовал:

    using System.Management;
    
    class GamepadDisabler
    {
        static void Main()
        {
            Console.WriteLine("=== Список HID устройств ===\n");
            ListDevices();
            
            Console.WriteLine("\n1 - Отключить геймпад");
            Console.WriteLine("2 - Включить геймпад");
            Console.Write("\nВыбери: ");
            
            string choice = Console.ReadLine();
            
            if (choice == "1")
            {
                DisableDevice();
            }
            else if (choice == "2")
            {
                EnableDevice();
            }
            
            Console.WriteLine("\nГотово! Нажми Enter...");
            Console.ReadLine();
        }
    
        static void ListDevices()
        {
            try
            {
                ManagementObjectSearcher searcher = new ManagementObjectSearcher(
                    "SELECT * FROM Win32_PnPEntity WHERE PNPClass='HIDClass'"
                );
    
                foreach (ManagementObject device in searcher.Get())
                {
                    string name = device["Name"]?.ToString() ?? "Unknown";
                    string deviceId = device["DeviceID"]?.ToString() ?? "";
                    string status = device["Status"]?.ToString() ?? "";
                    
                    Console.WriteLine($"Имя: {name}");
                    Console.WriteLine($"ID: {deviceId}");
                    Console.WriteLine($"Статус: {status}");
                    Console.WriteLine("---");
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Ошибка: {ex.Message}");
            }
        }
    
        static void DisableDevice()
        {
            try
            {
                ManagementObjectSearcher searcher = new ManagementObjectSearcher(
                    "SELECT * FROM Win32_PnPEntity WHERE PNPClass='HIDClass' OR DeviceID LIKE '%VID_045E%'"
                );
    
                foreach (ManagementObject device in searcher.Get())
                {
                    string name = device["Name"]?.ToString() ?? "";
                    string deviceId = device["DeviceID"]?.ToString() ?? "";
                    string configManagerErrorCode = device["ConfigManagerErrorCode"]?.ToString() ?? "";
                    
                    // Пропускаем виртуальные устройства AMD и уже отключённые
                    if (name.Contains("AMD") || name.Contains("Emulation"))
                    {
                        continue;
                    }
                    
                    // Ищем Xbox устройства по VID (045E = Microsoft)
                    if (deviceId.Contains("VID_045E") || name.ToLower().Contains("xbox") || name.ToLower().Contains("wireless"))
                    {
                        Console.WriteLine($"\nНайдено: {name}");
                        Console.WriteLine($"DeviceID: {deviceId}");
                        
                        // Вызываем метод Disable
                        ManagementBaseObject outParams = device.InvokeMethod("Disable", null, null);
                        
                        if (outParams != null)
                        {
                            uint returnValue = (uint)outParams["ReturnValue"];
                            if (returnValue == 0)
                            {
                                Console.WriteLine("✓ Успешно отключено!");
                            }
                            else
                            {
                                Console.WriteLine($"✗ Ошибка: код {returnValue}");
                                if (returnValue == 5)
                                    Console.WriteLine("  (Нет прав доступа - запусти от администратора)");
                            }
                        }
                    }
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Ошибка: {ex.Message}");
                Console.WriteLine("\nЗапусти программу от имени администратора!");
            }
        }
    
        static void EnableDevice()
        {
            try
            {
                ManagementObjectSearcher searcher = new ManagementObjectSearcher(
                    "SELECT * FROM Win32_PnPEntity WHERE PNPClass='HIDClass' OR DeviceID LIKE '%VID_045E%'"
                );
    
                foreach (ManagementObject device in searcher.Get())
                {
                    string name = device["Name"]?.ToString() ?? "";
                    string deviceId = device["DeviceID"]?.ToString() ?? "";
                    
                    // Пропускаем виртуальные устройства AMD
                    if (name.Contains("AMD") || name.Contains("Emulation"))
                    {
                        continue;
                    }
                    
                    // Ищем Xbox устройства
                    if (deviceId.Contains("VID_045E") || name.ToLower().Contains("xbox") || name.ToLower().Contains("wireless"))
                    {
                        Console.WriteLine($"\nНайдено: {name}");
                        
                        // Вызываем метод Enable
                        ManagementBaseObject outParams = device.InvokeMethod("Enable", null, null);
                        
                        if (outParams != null)
                        {
                            uint returnValue = (uint)outParams["ReturnValue"];
                            if (returnValue == 0)
                            {
                                Console.WriteLine("✓ Успешно включено!");
                            }
                            else
                            {
                                Console.WriteLine($"✗ Ошибка: код {returnValue}");
                            }
                        }
                    }
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Ошибка: {ex.Message}");
            }
        }
    }

    Общение с Microsoft

    На странице документации GIP есть имейл техподдержки по протоколу. Я не постеснялся и напрямую им задал свой вопрос. Они мне отвечали раз в неделю, но вот что получилось узнать:

    The implementation of the protocol within Microsoft Windows sends these packets for various scenarios(such as device idle timeout etc) but we don’t have any public way for an application to send them. However, there is a private API no one is currently using that can do this :

    IGameControllerProviderPrivate has a PowerOff method that would cause this packet to be sent to gip devices including the Xbox one devices, and also the series controller you are interested in. You may QueryInterface this from the public interface GipGameControllerProvider Class (Windows.Gaming.Input.Custom) - Windows apps | Microsoft Learn

    В общем, есть некий приватный метод, который можно было бы использовать, но он может поменяться в будущем, потому стабильности нет, а еще нужно каким-то образом получить этот метод через QueryInterface. К сожалению, для меня это оказалось сложным даже с Cursor и платной подпиской. Как бы я не гонял в шею несчастный чат, ничего толкового у меня здесь не вышло.

    Затем мне написали это:

    there are 4 different ways this task can be accomplished. 

    1. Use the D4XDevice SDK, This requires Non Disclosure Agreement between Microsoft and your Corporation.

    2. Reverse engineer the ioctl (but we aware it may break in the future because its not a public API). You will be calling DeviceIOControl API with the IOCTL code that results in device power-off. 

    3. Reverse engineer the COM PowerOff API (but we aware it may break in the future because its not a public API). Here you would be figuring out the COM Class GUID value. 

    4. Uninstalling the Microsoft implementation of the protocol in the “xboxgip.sys” driver and replacing it with your own usb driver that implements the protocol in device manager.

    Methods 2 and 3 are essentially unsupported by Microsoft. Hope this helps.

    Здесь я начал ловить ощущение, что данный квест мне становится не по зубам. К тому же, у меня не было уверенности, что предложенные варианты действительно являются рабочими. В предоставлении SDK мне отказали, так как я не являюсь компанией-разработчиком. Варианты 2 и 3 я гонял вместе с чатом, но опять же IOCTL код надо каким-то чудом знать или откуда-то вычислить, чтобы понимать что отправлять, так же как и COM Class GUID. Вариант 4 тоже показался излишним. Ради моей задачи писать целый драйвер невероятно избыточно.

    Еще мне показался интересным метод SendRawDeviceOutput из GameInput третьей версии. Судя по описанию, он делает ровно то, что мне нужно - отправляет сырые GIP команды джойстику. Мне удалось даже собрать небольшой пример кода с помощью чатаГПТ, но я исправлял такое количество ошибок, совместимости библиотек и сборок проекта, что неудивительно, что оно не взлетело.

    Wireshark

    Еще мне показалось хорошей идеей попробовать проанализировать пакеты данных и посмотреть, что вообще отправляет джойстик. При его подключении к адаптеру данные летят нонстопом. Разобраться что там к чему невероятно трудно, учитывая, что протокол поддерживает дробление пакетов. То есть, одна команда может быть разбита на несколько пакетов, а так же несколько маленьких команд могут быть в одном пакете. Однако, Victor, которого я нашел на стеке, оказался довольно добр ко мне и сделал Wireshark дамп со своего джойстика, подключенного к адаптеру. Мне удалось найти там пакеты выключения, но выглядят они очень странно. Команда выключения шлётся большое количество раз, постоянно инкрементируя sequence байт. Когда я пытался так же пронюхать свой джойстик, я такого не замечал. К тому же, ID устройства выглядит чуть по другому и данных больше. Например, вот ID моего контроллера при анализе в Wireshark: 7e ed 82 c6 8b da 62 45.

    Пакет выключения 05 20 <seq> 01 04
    Пакет выключения 05 20 <seq> 01 04

    Итоги

    На сегодня я исчерпал все возможности, которые у меня были, для реализации этой задачи. То, что я рассчитывал будет вечерним развлечением и веселой задачкой, превратилось в годовую одержимость и неугомонность, которая заставляла меня писать огромному количеству специалистов и спрашивать их мнения. Все воспринимали меня по-разному, но я благодарен, что находились те, у кого появлялся искренний энтузиазм относительно моей задачи и они давали ценные подсказки.

    Задача оставила меня со списком неотвеченных вопросов:

    • В документации GIP говорится, что все устройства должны иметь ID, у которого обязательно должны быть байты 0x00, 0x00, 0xFF, 0xFB. Однако, у моего джойстика их нет.

    • Что вообще такое @“\.\XboxGIP”? Похоже на какой-то путь, где происходит вообще весь транспорт данных, связанный с Xbox, между устройствами и хостом. C помощью hidapitester я писал команды напрямую в джойстик по его vidpid.

    • Неизвестно, нужен ли в итоге CRC. Документация его не упоминает, говорится лишь, что GIP-устройство должно осуществлять шифрование, но не уточняет как.

    • Что за ошибка 87 ERROR_INVALID_PARAMETER? О каком параметре идет речь?

    • Способен ли джойстик вообще принимать команды вне GameInput?

    • Почему команда через hidapitester вызвала вибрацию? Как она преобразовалась?

    • Где взять IOCTL или COM Class GUID?

    • Как получить доступ к IGameControllerProviderPrivate::PowerOff?

    • Должен ли я в итоге писать команду в контроллер или всё же через адаптер?

    • Почему при чтении данных из @“\.\XboxGIP”ID контроллера кончается на 00 00, но в Wireshark у него этих байт нет?

    Истина где-то рядом
    Истина где-то рядом

    Все эти вопросы пока остаются без ответа. Пускай и не получилось добиться желаемого, но это было довольно увлекательно. Я буду рад любому продуктивному взаимодействию, направленному на раскрытие этих вопросов. Со мной можно связаться на Хабре, либо по контактам в профиле. Надеюсь, что вам тоже было любопытно ознакомиться с моим опытом. Спасибо за внимание!