ダイソーで買った330円のテンキーを自分専用デバイスにカスタマイズする


ダイソーで330円(税込)のテンキーを入手しました。
これを本来のテンキーとして使用するのではなく、各キーに自分好みの機能を割り当てられないかなーと思い、試行錯誤したところ、それなりに満足いく結果となったためまとめました。
Windows限定の内容となります。

![PXL_20220821_040238981_mini.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235258/adb66fca-8498-9c02-7fe8-73ce6eb60ced.jpeg)


# 目的

テンキーとしての機能は除去し、各キーを押した際にプログラムした挙動が実行されるようにする

# 動機

・スリープを頻繁に使用するため、ワンタッチでスリープさせたい
・アクティブなアプリケーションの音量を増減させたい(マスターボリュームはそのままにしたい)
・物理キーによるアプリケーションランチャーが欲しい

# 実現手法

[Interception](http://www.oblita.com/interception)というドライバ兼ライブラリによって、もともとのテンキーの機能を無効にしつつ、キー入力を受け取ることを可能にした。

ここからヒント(というかほぼ答え)を得た。
[C# RawInputDevice in Windows: How I can disable messages for all applications except my?](https://stackoverflow.com/questions/26245124/c-sharp-rawinputdevice-in-windows-how-i-can-disable-messages-for-all-applicatio)

## Interceptionについて

Francisco Lopes氏により開発されたドライバとAPI。キーボードやマウス入力をキャプチャしたり、差し替えたりすることができる。入力情報とデバイスIDが紐づいた状態で提供されるため、特定のデバイスの入力のみフィルタリングすることができるので、今回の目的にはちょうど合致した。

### 入手

以下のGithubにダウンロードリンクがあるため、リリースされたアーカイブを取得する

[https://github.com/oblitum/Interception](https://github.com/oblitum/Interception)

### ドライバのインストール

1. 管理者権限でコマンドプロンプトを起動する
    Winキー押して「cmd」と入力し、「管理者として実行」を選択
2. ダウンロードしたアーカイブを展開したフォルダへ移動
3. 「command line installer」フォルダに入る
4. 「install-interception.exe /install」を実行してインストール

以下の通り、successfullyのメッセージが出れば、インストール成功。　　
```
Interception command line installation tool
Copyright (C) 2008-2018 Francisco Lopes da Silva

Interception successfully installed. You must reboot for it to take effect.
```

得体の知れないドライバは入れたくない、という方はソースコードから自前でビルドするという方法もある(試していないが)。ただしWDKの導入や署名の問題など、結構大変そう。

### インクルードファイル

`library`フォルダをインクルードディレクトリに追加しておくこと。`interception.h`をインクルードする。

### ライブラリファイル

`library\x64`または`library\x86`(フルパス)をライブラリディレクトリに追加しておくこと。また、`interception.lib`をリンクする。

### DLLファイル

`library\x64`または`library\x86`(フルパス)を環境変数`PATH`に追加しておくこと。実行時には`interception.dll`が必要になる。

### 使い方

Interceptionの簡単な使い方としては以下のようなコードとなる。

```c
#include <interception.h>

int main()
{
    InterceptionContext context;
    InterceptionDevice device;
    InterceptionKeyStroke stroke;

    // 1. コンテキスト生成
    context = interception_create_context();
    // 2. フィルタ設定
    interception_set_filter(context, interception_is_keyboard, INTERCEPTION_FILTER_KEY_DOWN | INTERCEPTION_FILTER_KEY_UP);
    // 3. メッセージ受信
    while(interception_receive(context, device = interception_wait(context), (InterceptionStroke *)&stroke, 1) > 0)
    {
        // 4. メッセージ送信
        interception_send(context, device, (InterceptionStroke *)&stroke, 1);
    }
    // 5. コンテキスト削除
    interception_destroy_context(context);

    return 0;
}
```

1. コンテキストを生成する
   ドライバとの接続を確立する
2. 取得するメッセージの種類を設定する
   このコードではキーボードとマウスの入力を取得する設定
3. ドライバからメッセージを受け取る
4. 受け取ったメッセージを送信する
   送信するとOSにキーボード/マウスの入力が配信され、各種アプリに伝わる
   ここで送信しないと入力はなかったことになる(一切の操作が効かなくなるため注意)。
   入力の内容を書き換えることもできる(Xのキーを押したらYを押したようにもできる)
5. コンテキストを破棄する


# テンキーの挙動について

購入したテンキーについて、キー入力時の挙動を調べた。
キー入力時のスキャンコードはコードセット1に基づいていると思われる。

[USB HID to PS/2 Scan Code Translation Table](https://download.microsoft.com/download/1/6/1/161ba512-40e2-4cc9-843a-923143f3456c/translate.pdf)


|キー|スキャンコード(10進)|備考|
|---|---|---|
|/|53||
|*|55||
|-|74||
|+|78||
|BS|14||
|Enter|28||
|9|73||
|8|72||
|7|71||
|6|77||
|5|76||
|4|75||
|3|81||
|2|80||
|1|79||
|0|82||
|000|82 82 82|0を3回分押した挙動|
|.|83||

`NumLock`キーを押した場合、OS側には何もメッセージは送信されない。`NumLock`が`On`の場合、各キー入力のメッセージの前後に`NumLock`メッセージが追加で送信される。具体的にはNumLock ON→9キー→NumLock OFFのような入力が行われる。

`000`キーを押したときには`0`が3回送られてくるので、`0`と`000`を区別するには、0が単独かあるいは3連かを判断する必要がある。今回はタイマーを使って、100ms間に`0`を3回受け取ったら`000`、それ以外は`0`という雑な判定を入れてみたが、(私の用途としては)問題なく動作している。キーをもっとバシバシ叩くような用途に使う場合はもっと真面目な対応を考える必要があるかもしれない。

NumLockキーはデバイスのつくり上、入力を受け取れないため除外するが、それ以外の18キーは自由に使用できる。

また、USBにおけるデバイスID/プロダクトIDは`VID_13BA&PID_0001`であることが分かった。調べてみると、「Konig Electronic CMP-KEYPAD12 Numeric Keypad」というデバイスが該当した。今回のテンキーがそのメーカー由来の正規品なのか、コピー品なのかは分からないが……。何にしてもこのIDを用いて他のキーボードと入力を区別する。

# 設計

Interceptionを使うことでやりたいことが実現できるが、アプリケーション部分はPythonで書きたい(C/C++は面倒)。よって以下のようにプログラムを2つに分割して作成することにした。

![blocks.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235258/18a3b01d-fc7f-66ec-2413-7e72da46ae92.png)

プロセス間通信の方法はいろいろあるが、調べた限りではWindows的には`名前付きパイプ`を使うのが簡単だと判断した。Python側では[pywin32](https://github.com/mhammond/pywin32)ライブラリを使用することで`ReadFile`関数からデータを受け取ることができる。

使用できる18種類のキーをそれぞれID1～18に割り当て、Pythonプログラム側でその番号を受け取るようにする。

## 機能

それぞれのキーには以下の機能を割り当てることにした。

|キー|機能|備考|
|---|---|---|
|/|音量MUTEのON/OFF|[pycaw](https://github.com/AndreMiras/pycaw), [comtype](https://github.com/enthought/comtypes)を使用|
|*|音量ミキサー(SndVol.exe)の再起動|*1|
|-|アクティブプロセスの音量減|pycawを使用|
|+|アクティブプロセスの音量増|pycawを使用|
|Enter|システムスリープ||
|他|対応するIDに応じたショートカットファイルを実行||

*1 音量ミキサーは後から立ち上げたアプリが表示されず音量操作できない場合が多々あり、毎回手動で再起動していたので、常々簡略化したいと思っていた

# 完成物

考えていた通りのものが出来上がった。
ソースコードは以下の3種類

|ファイル名|言語|概要|
|---|---|---|
|keypad_host.cpp|C++11|Interceptionドライバからキー入力を受け取る|
|main.py|Python|Python側のメインコード。C++側からの入力受け付け|
|event.py|Python|キーに応じた処理を実装したコード|

ソースコードの中身は長いので折り畳み。

<details><summary>keypad_host.cpp</summary><div>

```cpp
#include <iostream>
#include <string>
#include <map>
#include <thread>
#include <cstdint>
#include <interception.h>
#include <Windows.h>


// 対象とするデバイス
static const std::wstring target_device_id = L"HID\\VID_13BA&PID_0001";

// 名前付きパイプのファイル名
static const std::wstring pipe_name = L"\\\\.\\pipe\\chromia\\keypad\\id";

// スレッド間データ受け渡し用変数
static int send_event_id = 0;

// 終了フラグ
static bool exit_flag = false;

// 000キーと0キーの入力区別のための変数
static ULONGLONG first_num0_time = 0;
static int num0_count = 0;
static const int NUM0_TIMEOUT = 100;
static const int NUM0_MAX_LENGTH = 3;

// interception用スレッドと名前付きパイプ用スレッドのデータ受け渡しには
// クリティカルセクションを用いる
static CRITICAL_SECTION event_cs;
static const int EVENT_NONE = 0;

// パイプへの送信データ
static const int EVENT_DATA_TYPE_KEYBOARD = 1;
static const int EVENT_DATA_TYPE_MOUSE = 2;
struct EventData
{
	int32_t type;
	union {
		int32_t key;
		struct {
			int16_t cursor_x;
			int16_t cursor_y;
		} mouse;
	} param;
	int32_t reserved1;
	int32_t reserved2;
	int32_t reserved3;
};

enum ScanCode : int
{
	SCANCODE_NONE = 0,
	SCANCODE_NUMLOCK = 69,
	SCANCODE_SLASH = 53,
	SCANCODE_ASTERISK = 55,
	SCANCODE_MINUS = 74,
	SCANCODE_PLUS = 78,
	SCANCODE_BACKSPACE = 14,
	SCANCODE_ENTER = 28,
	SCANCODE_NUMKEY0 = 82,
	SCANCODE_NUMKEY1 = 79,
	SCANCODE_NUMKEY2 = 80,
	SCANCODE_NUMKEY3 = 81,
	SCANCODE_NUMKEY4 = 75,
	SCANCODE_NUMKEY5 = 76,
	SCANCODE_NUMKEY6 = 77,
	SCANCODE_NUMKEY7 = 71,
	SCANCODE_NUMKEY8 = 72,
	SCANCODE_NUMKEY9 = 73,
	SCANCODE_PERIOD = 83,
	SCANCODE_DUMMY_000 = -1,
};

const std::map<int, int> event_list = {
	{ SCANCODE_SLASH, 1 },
	{ SCANCODE_ASTERISK, 2 },
	{ SCANCODE_MINUS, 3 },
	{ SCANCODE_PLUS, 4 },
	{ SCANCODE_BACKSPACE, 5 },
	{ SCANCODE_ENTER, 6 },
	{ SCANCODE_NUMKEY0, 7 },
	{ SCANCODE_NUMKEY1, 8 },
	{ SCANCODE_NUMKEY2, 9 },
	{ SCANCODE_NUMKEY3, 10 },
	{ SCANCODE_NUMKEY4, 11 },
	{ SCANCODE_NUMKEY5, 12 },
	{ SCANCODE_NUMKEY6, 13 },
	{ SCANCODE_NUMKEY7, 14 },
	{ SCANCODE_NUMKEY8, 15 },
	{ SCANCODE_NUMKEY9, 16 },
	{ SCANCODE_PERIOD, 17 },
	{ SCANCODE_DUMMY_000, 18 },
};

int get_event_id(int scan_code)
{
	auto it = event_list.find(scan_code);
	if (it != event_list.end()) {
		const int event_id = it->second;
		return event_id;
	}
	else {
		return 0;
	}
}

void clear_event()
{
	EnterCriticalSection(&event_cs);
	send_event_id = EVENT_NONE;
	LeaveCriticalSection(&event_cs);
}

void set_event(int event_id)
{
	wprintf(L"set_event : %d\n", event_id);
	EnterCriticalSection(&event_cs);
	send_event_id = event_id;
	LeaveCriticalSection(&event_cs);
}

int get_event()
{
	int event_id;
	EnterCriticalSection(&event_cs);
	event_id = send_event_id;
	LeaveCriticalSection(&event_cs);
	return event_id;
}

VOID CALLBACK on_timer(
	_In_opt_ LPVOID /*lpArgToCompletionRoutine*/,
	_In_     DWORD /*dwTimerLowValue*/,
	_In_     DWORD /*dwTimerHighValue*/)
{
	if (num0_count >= 3) {
		// 000キーが押された
		const int event_id = get_event_id(SCANCODE_DUMMY_000);
		set_event(event_id);
	}
	else {
		// 0キーが押された
		const int event_id = get_event_id(SCANCODE_NUMKEY0);
		set_event(event_id);
	}
}

void check_code_000(HANDLE timer)
{
	ULONGLONG current_time = GetTickCount64();

	if (current_time - first_num0_time > NUM0_TIMEOUT) {
		// Waitable Timer作動開始
		num0_count = 1;
		first_num0_time = current_time;
		LARGE_INTEGER li{};
		li.QuadPart = -(NUM0_TIMEOUT * 10000);
		SetWaitableTimer(timer, &li, 0, on_timer, NULL, FALSE);
	}
	else {
		num0_count++;
	}
}

void interception_thread()
{
	InterceptionContext context;
	InterceptionDevice device;
	InterceptionKeyStroke stroke{};

	const int WAIT = 10;
	const int ID_BUFFER_SIZE = 500;
	wchar_t id_buffer[ID_BUFFER_SIZE]{};

	// Waitable Timerを生成
	HANDLE timer = CreateWaitableTimer(NULL, TRUE, NULL);

	// ドライバとの接続を生成
	context = interception_create_context();

	// キー入力全般についてフィルタリングする
	interception_set_filter(context, interception_is_keyboard, INTERCEPTION_FILTER_KEY_ALL);

	// キー入力の取得ループ
	while (!exit_flag)
	{
		// キー入力を待機
		while (1) {
			device = interception_wait_with_timeout(context, WAIT);
			if (device > 0) {
				break;
			}
			else {
				SleepEx(0, TRUE); // Waitable Timerのコールバックを受けられるようにするためのスリープ
			}
		}
		if (interception_receive(context, device, (InterceptionStroke*)&stroke, 1) <= 0) break;

		bool drop = false; // メッセージ破棄フラグ
		if (interception_is_keyboard(device)) {
			// デバイスIDを取得
			id_buffer[ID_BUFFER_SIZE - 1] = L'\0';
			size_t length = interception_get_hardware_id(context, device, id_buffer, sizeof(id_buffer));
			std::wstring device_id = id_buffer;

			if (length > 0 && length < sizeof(id_buffer)) {
				// 対象デバイスとIDを比較
				bool found_target = device_id.find(target_device_id) != -1;
				if (found_target) {
					// NumLock入力はそのまま通す(通さないと入力を送ってくれなくなる場合がある)
					if (stroke.code != SCANCODE_NUMLOCK) {
						drop = true;
						if (stroke.state & INTERCEPTION_KEY_UP) {
							// キーを離したときのみ処理
							int code = stroke.code;
							if (stroke.code == SCANCODE_NUMKEY0) {
								// 0と000の判別処理
								check_code_000(timer);
							}
							else {
								// キーID取得
								const int event_id = get_event_id(code);
								if (event_id) {
									// イベント発行(->pipe_threadへ)
									set_event(event_id);
								}
							}
						}
					}
				}
			}
		}

		// メッセージ送信
		if (!drop) {
			interception_send(context, device, reinterpret_cast<InterceptionStroke*>(&stroke), 1);
		}
	}

	exit_flag = true;
	interception_destroy_context(context);
}

void pipe_thread()
{
	const int WAIT = 10;

	HANDLE pipe = NULL;

	while (!exit_flag) {
		pipe = CreateNamedPipe(pipe_name.c_str(), PIPE_ACCESS_OUTBOUND, PIPE_TYPE_MESSAGE, 1, 0, 0, 1000, NULL);
		wprintf(L"New pipe(%p) created\n", pipe);
		if (!ConnectNamedPipe(pipe, NULL)) {
			wprintf(L"Error: Named Pipe(%s) is not opened.\n", pipe_name.c_str());
			return;
		}
		wprintf(L"Connect pipe!\n");

		while (1) {
			int event_id = get_event();
			if (event_id == EVENT_NONE) {
				// 受信なし・パイプの生存チェック実施
				DWORD written_size;
				BOOL ret = WriteFile(pipe, NULL, 0, &written_size, NULL);
				if (!ret || 0 != written_size) {
					if (GetLastError() == ERROR_NO_DATA) {
						// パイプが閉じられている(接続が切られた)
						break;
					}
				}
				Sleep(WAIT);
			}
			else {
				// 受信あり・パイプへ書き込み
				clear_event();

				EventData data = { EVENT_DATA_TYPE_KEYBOARD, event_id };
				DWORD written_size;
				BOOL ret = WriteFile(pipe, &data, sizeof(data), &written_size, NULL);
				wprintf(L"Write data ret = %d\n", ret);
				if (!ret || sizeof(data) != written_size) {
					break;
				}
			}
		}

		wprintf(L"Close pipe(%p)\n", pipe);
		CloseHandle(pipe);
	}
}

int main()
{
	// プロセスの優先度を上げる
	// システム全体のキーイベントをフックしているので処理の遅さは全体に影響することに留意
	SetPriorityClass(GetCurrentProcess(), HIGH_PRIORITY_CLASS);

	InitializeCriticalSection(&event_cs);

	std::thread t_filter(interception_thread);
	std::thread t_pipe(pipe_thread);
	t_filter.join();
	t_pipe.join();

	DeleteCriticalSection(&event_cs);

	return 0;
}

```
</div></details>

<details><summary>main.py</summary><div>

```python
import win32file
import event
from typing import Optional


pipe_name = "\\\\.\\pipe\\chromia\\keypad\\id"


class Pipe:
    def __init__(self, file):
        self.file = file

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        win32file.CloseHandle(self.file)
        self.file = None

    def read(self) -> Optional[int, None]:
        result, data = win32file.ReadFile(self.file, 20, None)
        if result == 0 and len(data) == 20:
            _event_type = int.from_bytes(data[0:4], "little")
            key = int.from_bytes(data[4:8], "little")
            return key
        else:
            return None


def main():
    file = win32file.CreateFile(pipe_name, win32file.GENERIC_READ,
                                win32file.FILE_SHARE_READ, None, win32file.OPEN_EXISTING, 0, None)
    with Pipe(file) as pipe:
        while True:
            key = pipe.read()
            if key is not None:
                event.run(key)


if __name__ == '__main__':
    main()

```
</div></details>

<details><summary>event.py</summary><div>

```python
import os
import sys
import ctypes
from pathlib import Path
import psutil
import subprocess

import win32con
from comtypes import CLSCTX_ALL
from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
import win32gui
import win32process
import win32api


# Event IDs
KEY_SLASH = 1
KEY_ASTERISK = 2
KEY_MINUS = 3
KEY_PLUS = 4
KEY_BACKSPACE = 5
KEY_ENTER = 6
KEY_NUM_0 = 7
KEY_NUM_1 = 8
KEY_NUM_2 = 9
KEY_NUM_3 = 10
KEY_NUM_4 = 11
KEY_NUM_5 = 12
KEY_NUM_6 = 13
KEY_NUM_7 = 14
KEY_NUM_8 = 15
KEY_NUM_9 = 16
KEY_PERIOD = 17
KEY_000 = 18


def _get_volume():
    devices = AudioUtilities.GetSpeakers()
    interface = devices.Activate(
        IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
    return ctypes.cast(interface, ctypes.POINTER(IAudioEndpointVolume))


def toggle_mute() -> None:
    volume = _get_volume()
    volume.SetMute(not volume.GetMute(), None)


def _get_active_process_name() -> str:
    hwnd = win32gui.GetForegroundWindow()
    _, active_pid = win32process.GetWindowThreadProcessId(hwnd)
    active_process_handle = win32api.OpenProcess(win32con.PROCESS_ALL_ACCESS, False, active_pid)
    active_process_full_name = win32process.GetModuleFileNameEx(active_process_handle, None)
    active_process_name = Path(active_process_full_name).name
    win32api.CloseHandle(active_process_handle)
    return active_process_name


def _inc(vol: float) -> float:
    return min(1.0, round(vol + 0.1, 1))


def _dec(vol: float) -> float:
    return max(0.0, round(vol - 0.1, 1))


def restart_mixer() -> None:
    target = "SndVol.exe"
    for p in psutil.process_iter(attrs=["pid", "name"]):
        if p.name() == target:
            p.terminate()
    subprocess.Popen(target)


def dec_volume() -> None:
    active_process_name = _get_active_process_name()
    sessions = AudioUtilities.GetAllSessions()
    for session in sessions:
        if session.Process:
            volume_if = session.SimpleAudioVolume
            if session.Process.name() == active_process_name:
                vol = volume_if.GetMasterVolume()
                volume_if.SetMasterVolume(_dec(vol), None)
                break


def inc_volume() -> None:
    active_process_name = _get_active_process_name()
    sessions = AudioUtilities.GetAllSessions()
    for session in sessions:
        if session.Process:
            volume_if = session.SimpleAudioVolume
            if session.Process.name() == active_process_name:
                vol = volume_if.GetMasterVolume()
                volume_if.SetMasterVolume(_inc(vol), None)
                break


def power_sleep() -> None:
    ctypes.windll.PowrProf.SetSuspendState(0, 1, 0)


def exec_shortcut(event_id: int) -> None:
    user_dir = Path(os.environ['USERPROFILE'])
    shortcut_dir = user_dir / 'keypad' / 'shortcuts' / f'{event_id:02}.lnk'
    if shortcut_dir.exists():
        os.startfile(shortcut_dir)
    else:
        print("Path not found: " + str(shortcut_dir))


def run(event_id: int) -> None:
    print(f"{event_id=}")
    if event_id == KEY_SLASH:
        toggle_mute()
    elif event_id == KEY_ASTERISK:
        restart_mixer()
    elif event_id == KEY_MINUS:
        dec_volume()
    elif event_id == KEY_PLUS:
        inc_volume()
    elif event_id == KEY_ENTER:
        power_sleep()
    elif KEY_SLASH <= event_id <= KEY_000:
        exec_shortcut(event_id)


def main() -> None:
    if len(sys.argv) < 2:
        print(f"Usage: python {sys.argv[0]} event_id")
    event_id = int(sys.argv[1])
    run(event_id)


if __name__ == '__main__':
    main()

```
</div></details>

あとは、作成したものをC++側→Python側(main.py)の順で実行するようにバッチファイルを作成した。また、バッチファイルの実行時にコマンドプロンプトが表示されるのが邪魔なので、VBScriptからバッチファイルを不可視で起動するようにスクリプトを作成。
最終的にVBScriptをスタートアップに登録して、導入完了。

<details><summary>_exec.bat</summary><div>

```batchfile
start /b cpp\keypad_host.exe
timeout /nobreak 3 > /nul
cd python
call venv\Scripts\activate
start /b python main.py
```
</div></details>

<details><summary>run.vbs</summary><div>

```vb
CreateObject("WScript.Shell").Run ".\_exec.bat",0
```
</div></details>

# おわりに

一通りやりたかったことができて満足。キーボードの入力を横取りするところはもっと簡単に考えていましたが、想像の3倍は時間を費やしてしまいました。最初はOSのRawInput機能が使えると思ったのですが、キー入力の受信とデバイスの識別は出来たものの、入力を握りつぶすことができなかったので一旦袋小路に。そこからなんやかんやで何とか完成にこぎつけました。Stack Overflowはやはり神。

使用したドライバInterceptionはマウス入力も対応しているようなので、マウス動作をカスタマイズしたい人はチャレンジしてみてください。メインのマウス動作に干渉しないセカンドマウス・サードマウスなんてのも作れるかと思います(何に使うかは想像力次第)。