Недавно я купил себе клавиатуру от Corsair модели K55 RGB Pro. У нее есть модная нынче цветная подсветка, а чтобы ее настраивать, производитель предлагает скачать программу iCUE. На сайте написано, что некоторые игры могут управлять подсветкой совместимых устройств. Гугл обнаружил официальный SDK с примерами, а также документацию. Я решил сделать что-то полезное для себя, а заодно посмотреть, как создаются приложения под Windows.

Мой код (для Visual Studio) можно найти здесь.


Выглядит это вот так

Для того, чтобы начать работать с периферией, достаточно подключить библиотеку, заинклюдить заголовки и положить dll рядом с программой. В комплекте идет несколько версий, я взял последнюю, CUESDK_2019 для 32 бит.

Начинается работа с вызова CorsairPerformProtocolHandshake(). Если что-то пошло не так, CorsairGetLastError() вернет код последней ошибки.

CorsairPerformProtocolHandshake();
    if (const auto error = CorsairGetLastError()) {
        std::cout << "Handshake failed: " << toString(error) << std::endl;
        return 2;
    }

Метод toString - обычный switch-case, возвращающий строку по коду. Ошибок всего 6, я не буду их здесь перечислять, можно посмотреть, как это сделано в примере.

К компьютеру может быть подключено несколько устройств, для каждого устройства может быть доступно несколько светодиодов. Каждый из них имеет свой ID, координаты в пространстве относительно устройства, цвет, и может управляться независимо. Вот структура, описывающая светодиод:

	struct CorsairLedPosition
	{
		CorsairLedId ledId;				// identifier of led.
		double top;
		double left;
		double height;
		double width;					// values in mm.
	};

Я хочу сделать все максимально просто и менять цвет сразу всем диодам, поэтому мне надо получить список их идентификаторов. Меня не очень интересует их расположение, но, в принципе, зная его, можно попробовать высвечивать флаг страны или что-то более интересное.

Сначала вызовем CorsairGetDeviceCount(), чтобы узнать, сколько у нас вообще подключено совместимой периферии, и, если есть хотя бы одно устройство, вызовем CorsairGetLedPositionsByDeviceIndex(i) для каждого. В моем случае устройство всего одно, и я передаю i=0. В примерах из документации можно посмотреть, как управлять разными устройствами. Сразу же, как только мы получили идентификаторы светодиодов, можно создать массивы с нужными нам цветами (CorsairLedColor)

void getAllLeds()
{
    if (CorsairGetDeviceCount() > 0) {
        if (const auto ledPositions = CorsairGetLedPositionsByDeviceIndex(0)) {
            for (auto i = 0; i < ledPositions->numberOfLed; i++) {
                const auto ledId = ledPositions->pLedPosition[i].ledId;
                leds1.push_back(CorsairLedColor{ ledId, en_r, en_g, en_b });
                leds2.push_back(CorsairLedColor{ ledId, ru_r, ru_g, ru_b });
            }
        }
    }
}

Я использую два языка ввода, для них я создал два пресета: голубой для английского и оранжевый для русского.

Теперь, чтобы изменить цвет, мы должны вызвать CorsairSetLedsColorsBufferByDeviceIndex и передать туда индекс устройства (в моем случае 0 - у меня оно всего одно) и массив из CorsairLedColor-ов.

CorsairSetLedsColorsBufferByDeviceIndex(0, static_cast<int>(leds1.size()), leds2.data());

Изменения вступят в силу, как только мы вызовем CorsairSetLedsColorsFlushBuffer().

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

Как узнать язык ввода?

О, а вот тут начинается интересная часть. Из нескольких способов, описанных в документации к winapi, у меня заработал только один - использовать SetWindowsHookEx на событие WH_SHELL. Описание можно найти в документации по ссылке, если кратко, это работает так:

  1. Создаем специальную функцию ShellProc, которая должна вызываться на различные события, связанные с оболочкой windows.

  2. Нас интересует параметр nCode, который может принимать значение HSHELL_LANGUAGE, означающее, что пользователь сменил язык ввода.

  3. Handle языка ввода передается в lParam. Это так называемый HKL (input locale identifier), нижнее слово которого содержит код языка, а верхнее - физическую раскладку клавиатуры). Можно было бы собрать его руками из двух частей с помощью соответствующих констант, но проще было просто записать возможные значения и прописать в константы (или в конфиг).

Здесь нужно немного поговорить о структуре нашего приложения. Дело в том, что для установки глобального хука его обработчик должен быть в DLL. Эта DLL затем подключается ко всем запущенным процессам. Однако мы живем в век 64-битных систем, и это накладывает дополнительные ограничения. Так, в 64-битные процессы можно загружать только 64-битные DLL, и наоборот. Чтобы наше приложение работало везде, нам понадобятся две DLL разной разрядности и два приложения, которые будут их подгружать/выгружать. В моем случае DLL собирается из проекта ShellHook. Его код очень простой. Функции установки и удаления хука выглядят вот так:

extern "C" SHELLHOOK_API void install()
{
    hook = SetWindowsHookEx(WH_SHELL, hookproc, module, 0);
}

extern "C" SHELLHOOK_API void uninstall()
{
    UnhookWindowsHookEx(hook);
}

SHELLHOOK_API описан рядом в заголовочном файле, который инклюдится в проект приложения, использующего эту DLL. Это стандартная практика для библиотек, как утверждает туториал: так мы можем описать импорт или экспорт в зависимости от того, какая сторона инклюдит заголовки.

При срабатывании хука вызывается функция hookproc из этой же библиотеки. Чтобы не тащить логику в DLL, мы просто сообщаем родительскому приложению через отправку сообщения окну о событии смены языка, а оно там уже само разберется, что с этим делать.

extern "C" LRESULT CALLBACK hookproc(int nCode, WPARAM wParam, LPARAM lParam)
{
    if (nCode < 0) // do not process message
        return CallNextHookEx(hook, nCode,
            wParam, lParam);
    switch (nCode)
    {
    case HSHELL_LANGUAGE:
    {
        HWND wnd = FindWindow(L"CueLangApp", L"CueLangApp");    // we're hard-coding the strings here for simplicity
        if (wnd != NULL)
            PostMessage(wnd, WM_USER + 1, wParam, lParam);
    }
    default:
        break;
    }

    return CallNextHookEx(hook, nCode, wParam, lParam);
}

Как говорилось выше, ShellHook.dll надо собрать для двух архитектур, x86 и x64. Кроме того, чтобы хуки правильно работали, эти библиотеки должны иметь разные названия. Используем суффикс .x64 для 64-битной версии - установим в настройках проекта Target name в $(ProjectName).x64 для платформы x64.

Для загрузки этой DLL нам потребуется приложение, разрядность которого совпадает с разрядностью библиотеки. Его задача проста: вызвать install, ждать сигнала о завершении, вызвать uninstall. Библиотеку можно либо включить в проект через LIB-файл, либо подгрузить в рантайме с помощью LoadLibrary. Используем второй вариант.

HMODULE dll = LoadLibrary(HOOKLIBNAME);
if (dll == NULL)
	return 2;
	
install_ = (InstallProc)GetProcAddress(dll, "install");
uninstall_ = (UninstallProc)GetProcAddress(dll, "uninstall");

install_();

Так как имя библиотеки зависит от разрядности, можно воспользоваться макросом Visual Studio _WIN64:

#if _WIN64
#define HOOKLIBNAME L"ShellHook.x64.dll"
#else
#define HOOKLIBNAME L"ShellHook.dll"
#endif

Это вспомогательное приложение, и ему ни к чему иметь окно и вообще как-то отсвечивать, поэтому мы не будем его создавать. Очередь сообщений в windows привязана к треду, и мы можем сделать цикл обработки сообщений прямо в WinMain:

while (GetMessage(&msg, NULL, 0, 0) > 0) {
		if (msg.message == WM_CLOSE) {
				break;
		}
}

Windows не отправляет никакого WM_CLOSE потокам без окна, это сообщение выбрано произвольно, чтобы можно было остановить выполнение из родительского приложения.

В конце снимем хук и освободим ресурсы:

uninstall_();
FreeLibrary(dll);

Как этим всем управлять?

Для того, чтобы это все заработало, нам надо:

  1. Запустить обе версии HookSupportApp.

  2. Реагировать на сообщения WM_USER+1 из коллбэка хука.

  3. Остановить все и снять хуки, когда пользователь закрыл программу.

Для этого сделаем третье, основное приложение. Оно представляет из себя консольное приложение (мне так было удобнее выводить дебажные сообщения и ошибки), но создает невидимое окно, чтобы принимать события о переключении языка. В нем же определены языки, цвета и реализована работа с CUE SDK, описанная в начале статьи. Код, по сравнению с предыдущими двумя проектами, достаточно объемный, поэтому я предлагаю интересующимся ознакомиться с ним по ссылке, а ниже я опишу то, что, на мой взгляд, достойно внимания.

Точкой входа для консольного приложения является main. Внутри подключаемся к CUE и инициализируем цвета для светодиодов. Затем создаем невидимое окно, которое будет получать сообщения:

WNDCLASS wc = {};
wc.lpfnWndProc = WndProc;
wc.hInstance = hInstance;
wc.lpszClassName = CLASS_NAME;

RegisterClass(&wc);

hwnd = CreateWindowEx(
    0,
    CLASS_NAME,
    L"CueLangApp",
    WS_OVERLAPPEDWINDOW,
    CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
    NULL,
    NULL,
    hInstance,
    NULL
);
if (hwnd == NULL) {
    return 0;
}
ShowWindow(hwnd, SW_HIDE);

Здесь нужно использовать те же имя класса окна и его заголовок, которые использованы в FindWindow внутри DLL, иначе окно не найдется. WndProc это обработчик событий окна, на все неизвестные события вызывается DefWindowProc, кроме двух, интересных нам: WM_CLOSE на закрытие окна и WM_USER+1 на изменение языка.

LRESULT CALLBACK WndProc(
    _In_ HWND hWnd,
    _In_ UINT message,
    _In_ WPARAM wParam,
    _In_ LPARAM lParam
)
{
    switch (message)
    {
    case WM_USER + 1:
        changeLang(wParam, lParam);
        break;
    case WM_CLOSE:
        PostQuitMessage(0);
    default:
        return DefWindowProc(hWnd, message, wParam, lParam);
    }
    return 0;
}

Для запуска и контроля дочерних процессов, ответственных за хуки, используем CreateProcess:

STARTUPINFO si;
PROCESS_INFORMATION pi;
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
ZeroMemory(&pi, sizeof(pi));

if (!CreateProcess(NULL,   // No module name (use command line)
        &childexe[0],        // Command line
        NULL,           // Process handle not inheritable
        NULL,           // Thread handle not inheritable
        FALSE,          // Set handle inheritance to FALSE
        0,              // No creation flags
        NULL,           // Use parent's environment block
        NULL,           // Use parent's starting directory 
        &si,            // Pointer to STARTUPINFO structure
        &pi)           // Pointer to PROCESS_INFORMATION structure
        )
{
    printf("CreateProcess 32 failed (%d).\n", GetLastError());
    return 1;
}
//...
childThread32 = GetThreadId(pi.hThread);

childThread32 затем используется для того, чтобы отправить ему сообщение об остановке:

PostThreadMessage(childThread32, WM_CLOSE, 0, 0);

Еще одна деталь: так как у нас консольное приложение, а дополнительное окно невидимо, мы должны как-то реагировать на остановку приложения пользователем (CTRL-C, закрытие окна консоли, и т.д.). Для этого у нас есть функция-обработчик таких событий CtrlHandler, которая устанавливается с помощью SetConsoleCtrlHandler(CtrlHandler, TRUE).

После того, как окно и консоль созданы, дочерние процессы запущены, и подключение к CUE SDK прошло успешно, запускается цикл обработки сообщений. Здесь он выглядит немного не так, как в HookSupportApp, мы используем DispatchMessage(), потому что на этот раз у нас есть окно с указанной для него WndProc.

while (GetMessage(&msg, NULL, 0, 0) > 0) {
    TranslateMessage(&msg);
    DispatchMessage(&msg);
}

Вот, собственно, и все. Здесь еще большой простор для улучшений: можно добавить полноценный GUI для удобного взаимодействия с пользователем, список устройств и языков, конфиг, более сложные цветовые комбинации - все, что может прийти вам в голову!


Список того, что мне помогло в процессе работы над проектом.

Комментарии (19)


  1. AlexEx70
    14.02.2022 06:50
    +2

    Идея отличная! В свое время у меня была идея сделать коротко появляющийся флажок с текущим языком рядом с курсором при получении фокуса полем ввода. Но не осилил и не уверен что в windows это возможно в принципе.


    1. Adler_lug
      14.02.2022 09:38
      +2

      флажок с текущим языком рядом с курсором

      Есть вот такое решение - KeybX.



  1. mistergrim
    14.02.2022 08:58
    +2

    Возможно, конечно, вот например habr.com/ru/post/138940
    Или навороченная Aml Maple, должны быть и ещё.


  1. oldbie
    14.02.2022 10:37
    +2

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

    Мб потому что большая часть компаний родом из англоязычных стран, им переключать ничего не нужно.


    1. Heliki Автор
      14.02.2022 11:51

      Мне тоже интересно, почему не было каких-то готовых решений, хотя тот же Corsair CUE, судя по копирайту, русские авторы.


  1. Schicout
    14.02.2022 11:25

    Как Вы считаете, можно ли сделать это приложение сервисом, чтобы оно запускалось до входа в систему? Не силён в этой части...


    1. Heliki Автор
      14.02.2022 11:54

      Скорее всего, короткий ответ - "да", но надо будет несколько дополнительных шагов: сервисы запускаются не от юзерской учетки, и для внедрения dll в процессы пользователя все равно понадобятся отдельные приложения. Чтобы их запустить с нужными правами, придется разбираться в виндовой системе контроля доступа. Кроме того, сервисы не могут напрямую взаимодействовать с пользователем, например, создавать окна (они работают в своей отдельной сессии, если я правильно понимаю), и для работы придется вместо простых оконных сообщений городить сложный IPC, возможно, опять же, с контролем доступа.


  1. Vold2D
    14.02.2022 14:53

    Делал нечто подобное - правда, не с подсветкой клавиатуры, а отдельными светодиодами. Очень удобно и наглядно. Проблема в том, что приходится часто работать по RDP, а как получать язык раскладки из RDP-сеанса, придумать не получилось.


  1. lemos
    14.02.2022 20:00

    А с клавиатурой на QMK прошивке можно такое провернуть? Как клавиатура понимает какой сейчас языковой режим Windows?


    1. Heliki Автор
      14.02.2022 21:34

      Я не сталкивался с ними, но, думаю, можно, если там есть какая-то управляющая программа, с которой можно взаимодействовать. Программа определяет язык ввода из параметров при получении события HSHELL_LANGUAGE от WH_SHELL хука.


    1. Lex98
      14.02.2022 21:46
      +1

      Я такую проблему решил с помощью разных слоев для разных раскладок (+ так же помогает не думать какая там в ОС раскладка по клавишам и менять их как угодно), сменой раскладок на Ctrl+Shift+ число и вот такого кода:

      bool process_record_user(uint16_t keycode, keyrecord_t *record) {
        switch (keycode) {
          case TO(1):
            if (record->event.pressed) {
              SEND_STRING(SS_LCTL(SS_LSFT(SS_TAP(X_2))));
            }
            break;
          case TO(0):
            if (record->event.pressed) {
              SEND_STRING(SS_LCTL(SS_LSFT(SS_TAP(X_1))));
            }
            break;
      

      При переключении на слой с русской раскладкой отправляется Ctrl+Shift+2 и меняется раскладка на русскую, при переключении на слой с английской раскладкой отправляется Ctrl+Shift+1 и меняется раскладка на, соответственно, английскую. И для разных слоев разная подсветка.


      1. lemos
        15.02.2022 02:26

        Проблема рассинхронизации при этом не возникает? Не бывает, что какая-нибудь программа сама язык переключает?


        1. Lex98
          15.02.2022 12:00
          +1

          Если настроить ОС включить глобальное переключение раскладки то вроде бы нет такого. Не могу более уверенно говорить, я только месяц своей клавиатурой на QMK пользуюсь. При использовании виртуалок есть проблемы, хотя не могу сказать что большие, видишь что раскладка не та, жмешь кнопку смены слоя и все решается.


  1. Kuzyok777
    14.02.2022 21:58

    Хм... а если взять attiny85 и ws2812, то и любая клава станет этим вашим RGB PRO всего за 5$


  1. chnav
    14.02.2022 22:53
    +2

    (Не совсем по теме, зато работает с любой клавиатурой)

    Последние 15 лет пользуюсь утилитой KbLangLED (14.5 Kb). При переключении раскладки она переключает (светодиод) ScrollLock. К ней прилагается исходник, источник alt-soft.info мёртв.

    Source Code
    program KbLangLED;
    
    uses
      Windows;
    
    
    type
      TKbLEDTag = (kbledNum, kbledCaps, kbledScroll);
    
    procedure ToggleKeybrdLED(LEDTag: TKbLEDTag);
    
      procedure SimulateLkKey(KeyDown: Boolean);
      const
        VKeyCodes: array[TKbLEDTag] of Byte = (VK_NUMLOCK, VK_CAPITAL, VK_SCROLL);
        ScanCodes: array[TKbLEDTag] of Byte = ($45, $3A, $46);
      begin
        keybd_event(VKeyCodes[LEDTag], ScanCodes[LEDTag], KEYEVENTF_EXTENDEDKEY +
          Ord(not KeyDown) * KEYEVENTF_KEYUP, 0);
      end;
    
    begin
      SimulateLkKey(True);
      SimulateLkKey(False);
    end;
    
    
    const
      LangKbLED = kbledScroll;  // LED to indicate language
    
    var
      Mutex: THandle;
      EnUS: Boolean;
      OldEnUS: Boolean = True;
      Msg: TMsg;
    
    begin
      // Check already running
      Mutex := CreateMutex(nil, True, 'KbLangLED_Running');
      if WaitForSingleObject(Mutex, 0) <> WAIT_OBJECT_0 then
        Exit;
    
      // Turn off the feedback cursor
      if PostThreadMessage(GetCurrentThreadId, 0, 0, 0) then
        GetMessage(Msg, 0, 0, 0);
    
      while True do
      begin
        EnUS := GetKeyboardLayout(GetWindowThreadProcessId(GetForegroundWindow)) and
          $FFFF = $0409;
        if EnUS <> OldEnUS then
        begin
          ToggleKeybrdLED(LangKbLED);
          OldEnUS := EnUS;
        end;
        Sleep(100);
      end;
    end.
    


  1. Netoen
    15.02.2022 13:42

    Идея класс.

    Но в текущих реалиях интересует осилит ли хук SetWindowsHookEx заметить изменения языка при работе с RDP и подобным. Нередко что язык хоста у меня один - RDP другой, сработает ли ваше решение в этом случае или нет? Было бы интересно узнать.


  1. tavi
    15.02.2022 21:26

    Использую такое же под линуксом. там это можно реализовать "штатными" средствами. В моем случае это OpenRazer + KDE (в нем можно настроить запуск произвольного скрипта при переключени раскладки, им дергается настройка подсветки из OpenRazer). Клавиатура - Razer Cynosa Lite, у неё прозрачные символы, они подсвечиваются в зависимости от раскладки. С одной стороны, это лучше, чем сплошная подсветка под клавишами, не бьет по глазам в вечернее время. С другой - они в целом выглядят тусклее, чем на обычной клавиатуре: серый на черном вместо белого на черном. Видимо, это проблема большинства игровых клавиатур.


    1. Heliki Автор
      16.02.2022 11:55

      Теоретически, светодиоды в моей клавиатуре поддерживают RGB, а значит, можно выставить значения около 0, что должно дать тусклый свет. На практике я так делать еще не пробовал :)