Controlling the keyboard backlight when changing the input language

Recently I bought myself a keyboard from Corsair model K55 RGB Pro. It has a fashionable color backlight, and in order to customize it, the manufacturer offers to download the program iCUE. The site says that some games can control the lighting of compatible devices. Google discovered official SDK with examples and documentation. I decided to do something useful for myself, and at the same time see how applications are created under Windows.

My code (for Visual Studio) can be found here.


It looks like this

In order to start working with peripherals, it is enough to include the library, include the headers and put the dll next to the program. There are several versions included, I took the latest one, CUESDK_2019 for 32 bit.

Starts with a call CorsairPerformProtocolHandshake(). If something went wrong CorsairGetLastError() will return the last error code.

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

The toString method is a normal switch-case that returns a string by code. There are only 6 errors, I will not list them here, you can see how it was done in the example.

Multiple devices can be connected to the computer, and multiple LEDs can be available for each device. Each of them has its own ID, coordinates in space relative to the device, color, and can be controlled independently. Here is the structure describing the LED:

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

I want to keep everything as simple as possible and change the color of all diodes at once, so I need to get a list of their IDs. I’m not really interested in their location, but, in principle, knowing it, you can try to highlight the flag of the country or something more interesting.

First let’s call CorsairGetDeviceCount()to find out how many compatible peripherals we have connected at all, and if there is at least one device, we will call CorsairGetLedPositionsByDeviceIndex(i) for everybody. In my case, there is only one device, and I pass i=0. V examples from documentation You can see how to manage different devices. As soon as we got the LED IDs, we can create arrays with the colors we need (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 });
            }
        }
    }
}

I use two input languages, for them I created two presets: blue for English and Orange for Russian.

Now to change the color we have to call CorsairSetLedsColorsBufferByDeviceIndex and pass the device index there (in my case, 0 – I have only one) and an array of CorsairLedColor-ov.

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

Changes will take effect as soon as we call CorsairSetLedsColorsFlushBuffer().

In principle, at this point you can already compile and run the code and see how it all looks. But I want the color to change depending on the language.

How to know the input language?

Oh, and here comes the interesting part. Of the several methods described in the winapi documentation, only one worked for me – to use SetWindowsHookEx per event WH_SHELL. The description can be found in the documentation at the link, in short, it works like this:

  1. Create a custom function ShellProcwhich should be called on various events related to the windows shell.

  2. We are interested in the parameter nCodewhich can take the value HSHELL_LANGUAGEindicating that the user has changed the input language.

  3. The handle of the input language is passed to lParam. I couldn’t find a full description of this setting, partly because it’s called differently in different places (handle to a keyboard layout, input language handle). However, experiments have shown that each language (input method) corresponds to one numerical value, which, moreover, does not change from launch to launch and even from reboots, which allows us to take out the values ​​we need into constants (or in config).

Here we need to talk a little about the structure of our application. The fact is that to install a global hook, its handler must be in the DLL. This DLL is then attached to all running processes. However, we live in the age of 64-bit systems, and this imposes additional restrictions. Thus, only 64-bit DLLs can be loaded into 64-bit processes, and vice versa. In order for our application to work everywhere, we need two DLLs of different bitness and two applications that will load / unload them. In my case, the DLL is built from a project shellhook. Its code is very simple. The functions for installing and removing a hook look like this:

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

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

SHELLHOOK_API is described next to header file, which is included in the application project that uses this DLL. This standard practice for libraries, as the tutorial states: this is how we can describe the import or export depending on which side includes the headers.

When the hook fires, the function is called hookproc from the same library. In order not to drag the logic into the DLL, we simply inform the parent application by sending a message to the window about the language change event, and it will figure out what to do about it there.

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);
}

As mentioned above, ShellHook.dll needs to be built for two architectures, x86 and x64. In addition, for the hooks to work correctly, these libraries must have different names. We use the suffix .x64 for the 64-bit version – set the Target name in the project settings to $(ProjectName).x64 for x64 platform.

To load this DLL, we need an application that has the same bit depth as the library. Its task is simple: call installwait for completion signal, call uninstall. The library can either be included in the project through a LIB file, or loaded at runtime using LoadLibrary. Let’s use the second option.

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

install_();

Since the library name depends on the bitness, you can use the Visual Studio macro _WIN64:

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

This is a helper application, and it doesn’t need to have a window or shine in any way, so we won’t create it. The message queue in windows is thread-bound, and we can do a message loop right in WinMain:

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

Windows does not send any WM_CLOSE to threads without a window, this message is arbitrarily chosen so that execution can be stopped from the parent application.

At the end, we will remove the hook and free the resources:

uninstall_();
FreeLibrary(dll);

How to manage all this?

In order for this to work, we need:

  1. Run both versions of HookSupportApp

  2. React to messages WM_USER+1 from callback hook

  3. Stop everything and remove hooks when the user has closed the program

For this we will do third, main application. It is a console application (it was more convenient for me to display debug messages and errors), but it creates an invisible window to receive language switching events. It also defines languages, colors, and implements work with the CUE SDK described at the beginning of the article. The code, compared to the previous two projects, is quite voluminous, so I suggest those who are interested to familiarize themselves with it. linkand below I will describe what, in my opinion, is worthy of attention.

The entry point for a console application is main. Inside, we connect to the CUE and initialize the colors for the LEDs. Then we create an invisible window that will receive messages:

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);

Here you need to use the same window class name and title as used in FindWindow inside DLLotherwise the window will not be found. WndProc this is the window event handler, on all unknown events it is called DefWindowProcexcept for two that are of interest to us: WM_CLOSE to close the window and WM_USER+1 to change the language.

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;
}

To start and control child processes responsible for hooks, we use 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 then used to send it a stop message:

PostThreadMessage(childThread32, WM_CLOSE, 0, 0);

One more detail: since we have a console application, and the additional window is invisible, we must somehow react to the user stopping the application (CTRL-C, closing the console window, etc.). To do this, we have a function-handler for such events CtrlHandlerwhich is set using SetConsoleCtrlHandler(CtrlHandler, TRUE).

After the window and console are created, the child processes are started, and the connection to the CUE SDK is successful, the message loop starts. Here it looks a little different than in HookSupportAppwe use DispatchMessage()because this time we have a window with WndProc.

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

That, in fact, is all. There is still a lot of room for improvement: you can add a full-fledged GUI for easy user interaction, a list of devices and languages, a config, more complex color combinations – anything you can think of!


A list of what helped me in the process of working on the project.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *