弊社では業務PCとしてノートPCが支給されますが、外付けキーボードやマウスを利用したい方は追加支給してもらうこともできます。ですが自分好みのキーボード・マウスを利用したい方は、各人の責任で持ち込んで利用することも認められています。
その流れで私は自作したキーボードを業務PCに接続して使っていますが、そのキーボードを作成した際の知見を少しまとめました。
アナログジョイスティックについて
アナログジョイスティックは主に縦横4方向を直感的に操作するためのデバイスのうちon/offを表す2値スイッチではなく、方向の量をアナログ的に検出できるものです。
ぶっちゃけ日頃からよく見かけるゲームのコントローラーについていて自キャラを移動させるときに使うやつです。
最近はゲーム機の修理用で単体で利用できるモジュールが手ごろな価格で販売されています(例:サイトA、サイトB、サイトC)ので、その中からキーボード基板に固定しやすく、設置面積が小さく、高さが低いものとして写真の中で黄色枠で囲ったものを選びました。
(K-Silverと言うメーカのJP19の互換品のようです)

これは元々両手に分かれて使うあのコントローラーで使われているものを基板で転用しやすくするために端子の形を変えたものではないでしょうか。端子の形を電子工作で扱いやすい通常のスルーホール端子に変えると同時に、基板に固定するための突起が付いているところがポイント高いです。
キーボードに取り付ける際、マウスだけの使い方では勿体ないと思い、出来れば折角4方向を指示できるのでカーソルキーの入力として利用したいと考えました。
qmkのアナログスティック
私は自作キーボードでqmk_firmware(Quantum Mechanical Keyboard)というオープンソースのファームウェアとその派生物を使っています。qmkでアナログジョイスティックを使う一般的な方法は、ゲームコントローラーのアナログスティックとする方法と、ポインティングデバイスとしてマウスカーソルを移動させるために使う2種類です。
カーソルキーとして使うにはスティックを倒しこんだ際にキーをONに、戻したり逆方向に倒した時にOFFにする必要があり、既存のプログラムでは対応できません。
動きを変えるには全面的に差し替えてしまうと今度はポインティングデバイスとして利用するのが難しくなる懸念があります。
このため、ポインティングデバイスのロジックが呼び出すアナログスティックの状態確認部分に手を入れることにします。
rules.mkで POINTING_DEVICE_DRIVER=custom を指定してからカスタムドライバの関数を定義します。

動作方針
自作キーボードは使用中に動作モードを変更する手段としてキーを押したときに入力される文字を変更するためのキーマップがあります。キーマップではキースイッチそれぞれを押した際にどのキーを入力したことにするかを割り当てるために文字コードに似たキーコードという物を使います。
そしてキーボードでマウスの代わりをするためにUSBを使ってPCに送ることはせず、qmk内部で異なる処理をしてマウスの動作を発生させるマウスキーと呼ばれる機能があり、専用のキーコードが存在します。
今回ジョイスティックをカーソルキーの代わりに使うと決めましたが、マウスの代わりにマウスカーソルを操作したくなることもあるかもしれませんので、マウスキーが指示された場合はマウスとして動くことにします。
ということで、下記2つの方針とします
- スティックを倒した時は各方向に指示されたキーコードを発生させる
- その方向に定義されたキーコードがポインティングデバイスの移動移動を扱う場合は倒し込み角度を返す
qmkでデバイスドライバなど機能拡張を行う場合は、qmkのソースコード本体で(weak)宣言されている関数と同名の関数を定義することで差し替えることができます。
今回はquantum/pointing_device/pointing_device.h を参考にしてポインティングデバイスを利用する際に使われる下記の関数を定義しました。
1 2 3 |
__attribute__((weak)) void pointing_device_driver_init(void); __attribute__((weak)) report_mouse_t pointing_device_driver_get_report(report_mouse_t mouse_report); __attribute__((weak)) report_mouse_t pointing_device_task_user(report_mouse_t mouse_report); |
- pointing_device_driver_init()では、スティックで利用するアナログ入力のピンを初期化ます
- pointing_device_driver_get_report()では、上記ピンの電圧を確認してスティックの倒しこみを検出し、スティックの向きとキーマップの定義内容から実行する動作をqmkに伝えます
- pointing_device_task_user()では、簡易的なドリフト対策をします
元々のqmkではquantum/pointing_device/pointing_device.c と drivers/sensors/analog_joystick.c の2層構造でしたが、両方のレイヤで加工が必要になってうまくまとめられなかったので quantum 側の関数でドライバー側の処理もしてしまうことにしてしまいました。
実装
※コード全文は GitHub に置きました
処理のトリガは通常のマウス操作と同じく pointing_device_driver_get_report() が呼び出されることでトリガにします。この関数が呼び出されるたびに毎回マウスレポートを報告してしまうと過剰すぎるために間隔をあける必要があります。
timer_elapsed() は引数で示した時刻から経過時間を返すので、これがインターバル時間(ANALOG_JOYSTICK_READ_INTERVAL)を超えるまではスキップするようにしておきます。
次に analog_joystick_read() を呼び出してスイッチとして確認と動作を実行した上で処理が残ったポインターのアナログ的移動を取得します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
uint16_t lastCursor = 0; // 最終取得時刻 // ここでポインティングデバイスの状態を取得する report_mouse_t pointing_device_driver_get_report(report_mouse_t mouse_report) { if (timer_elapsed(lastCursor) > ANALOG_JOYSTICK_READ_INTERVAL) { lastCursor = timer_read(); report_mouse_t data = analog_joystick_read(); pd_dprintf("Raw ] X: %d, Y: %d, H: %d, V: %d\n", data.x, data.y, data.h, data.v); mouse_report.x = data.x; mouse_report.y = data.y; mouse_report.h = data.h; mouse_report.v = data.v; mouse_report.buttons = pointing_device_handle_buttons(mouse_report.buttons, data.buttons, POINTING_DEVICE_BUTTON1); } return mouse_report; } |
analog_joystick_read() は既存のqmkでは drivers/sensors/analog_joystick.c で行う処理のレイヤですが、キーマップを確認して操作を依頼するという上位レイヤの処理が入ります。
(qmkの流儀に従うなら返り値として戻す方が好ましいでしょう)
倒しこみ量のアナログ値をスイッチとして判定する際、境界値の前後でon/offを割り当てるとチャタリングと似た症状が発生してしまいます。
このためにonにする値とoffに戻す値を分けて設定します。

ジョイスティック1本に対して必要な情報は、3種あります
- 確認するアナログピン2本 ※1
- アナログピンのニュートラル位置 ※2
- 4方向の仮想的なマトリクス位置 ※3
その他に、内部データと状態保存のために下記3種の情報が必要でした
- マウスカーソルのための倒しこみと速度の対応表 ※4
- マウスホイールのための倒しこみと速度の対応表 ※5
- 仮想キースイッチの状態マップ ※6
関数全体は以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
static int8_t weightsCursor[101] = ANALOG_JOYSTICK_WEIGHTS; // ※4 static int8_t weightsWheel[101] = ANALOG_JOYSTICK_WEIGHTS2; // ※5 static pin_t adpins[HFJS_STKS] = HFJS_ANALOG_PINS; // ※1 int16_t origins[HFJS_STKS] = {0}; // ※2 uint8_t vmatrix = 0; // ※3 // 値を取得する・キー入力の場合はキーイベントを発生させる report_mouse_t analog_joystick_read(void) { static uint8_t rows[8] = HFJS_ROWS; // {6,7,5,8, 1,2,0,3} ※3 static uint8_t cols[8] = HFJS_COLS; // {9,9,9,9, 9,9,9,9} ※3 report_mouse_t report = {0}; uint16_t keycode; int16_t position; int8_t coordinate; int num; int flg; int8_t distance; for (int i = 0; i < HFJS_STKS; i++){ // ※a position = analogReadPin(adpins[i]); // ※b coordinate = axisCoordinate(position, origins[i], 0); // -100 ~ 0 ~ 100 の値に変換する ※c for (int n = 0; n < 2; n++){ // ※d distance = (n ? -1 : 1) * coordinate; num = i * 2 + n; flg = 0x1 << (num); // vmatrix のビットマップへの割り当て(BUG: 押している最中のレイヤ切替でリリースしていない) keycode = matrix_to_keycode(rows[num], cols[num]); switch(keycode){ // 現在設定されたキーコードを確認 case MS_DOWN: // ※e if(distance > 0){ report.y -= axisToMouseComponent(coordinate, origins[i], maxCursorSpeed, weightsCursor); } break; case MS_UP: // ※e if(distance > 0){ report.y -= axisToMouseComponent(coordinate, origins[i], maxCursorSpeed, weightsCursor); } break; case MS_RGHT: // ※e if(distance > 0){ report.x += axisToMouseComponent(coordinate, origins[i], maxCursorSpeed, weightsCursor); } break; case MS_LEFT: // ※e if(distance > 0){ report.x += axisToMouseComponent(coordinate, origins[i], maxCursorSpeed, weightsCursor); } break; (※マウスホイールについては省略) default: // キー入力として処理する if(vmatrix & flg){ // 前回押されていた時 ※f if(distance < HFJS_RELEASE) { // 下限以下ならリリース vmatrix &= ~flg; action_exec(MAKE_KEYEVENT(rows[num], cols[num], false)); switch_events(rows[num], cols[num], false); // 点灯させる位置を知らせるためにkeyboard.jsonでダミーのLEDを登録する } }else { // 未だ押されていない場合 ※g if(distance > HFJS_ACTION * (vmatrix ? HFJS_DBLACT : 1) ){ // 上限を超えたらプッシュ (斜め入力の際は HFJS_DBLACT で判定を緩和する) vmatrix |= flg; action_exec(MAKE_KEYEVENT(rows[num], cols[num], true)); switch_events(rows[num], cols[num], true); } } } } } return report; } |
処理の流れとしては下記の様になっています。
- ※a スティックごとに縦横のそれぞれ2回ずつ処理にする
- >※b アナログ値を取得
- >※c -100 ~ 100 の割合値に加工
- >※d アナログ値ごとに正の方向、負の方向の2回処理にする
- >>※e キーコードを確認してマウスカーソル関連だったら返答用reportに記録
- >>※f 通常キーで前回onだった場合、キーコードが設定された方向を正として、off境界以下になっていたらキーを離したアクションを発生して記憶
- >>※g 通常キーで前回offだった場合、キーコードが設定された方向を正として、on境界値以上になっていたらキー入力アクションを発生して記憶
こうすることで、通常時はカーソルキーだけれどレイヤーを変更するとマウスカーソルやホイール操作ができるようになったり、上下はマウスホイールで左右はブラウザバックなどと言った自由なキー割り当てが可能になりました。

さて、これを升目状に並べたら物理フリックキーボードが出来上がるわけですが、それはそれで大変そうなので将来の課題にさせてください。
ドリフト対策
今回アナログジョイスティックをあまり品質が良いものでないものを選んでしまったため、乱暴に扱った時などに値がおかしいことが頻繁に発生しました。これは携帯ゲーム機などでドリフト現象などと呼ばれているものです。これをプログラムで対処しようと思います。
簡易的なアプローチは2つあり、一つはホームポジションの0とする範囲を広げること。二つ目として異常を検出して修正することです。
ここでは2つ目のアプローチについて説明します。
ドリフトとはホームポジションがずれてしまい、操作をしていなくても一定の固定の操作が続いていると誤認してしまう状態ですので、ドライバーからの応答が一定時間続いたらドリフトと判断することにします。今回は3秒続いたらドリフトと判断してホームポジションを今の位置に変更しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
static report_mouse_t last_mouse_report = {0}; // アナログジョイスティックの値を取得した際の処理 report_mouse_t pointing_device_task_user(report_mouse_t mouse_report) { static uint32_t pointing_device_changed = 0; // 最後に移動した時刻 int32_t timer = timer_elapsed32(pointing_device_changed); // 移動からの経過時間 if(!(mouse_report.x == 0 && mouse_report.y == 0)) { // マウス移動は間欠的に発生して隙間は0,0になるので読み飛ばす if(memcmp(&mouse_report, &last_mouse_report, sizeof(report_mouse_t)) == 0) { // 変化が無かったら if (timer > 3000) { // 3秒越えたら pointing_device_driver_set_adjust(); // ポジションリセット(init同等の処理) pointing_device_changed = timer_read32(); } }else { // 移動した場合タイマーリセット pointing_device_changed = timer_read32(); } last_mouse_report = mouse_report; } return mouse_report; } |
この3秒ルールでドリフトに対してイラつくことが少なくなりました。
実際にはこの処理だけで全てのドリフトに対処できないため、特定の操作でポジションリセットを行っています。
おわりに
自作キーボードのファームウェアはソフトウェアと電子回路、機械的な事柄まで幅広い知識が身に着けられるのでソフトウェアエンジニアとして幅を広げるにはいい課題ではないでしょうか。
キーボードと言う毎日使うデバイスですので、活動の結果がQOLに直結する点も素晴らしいと思います。
この記事で興味を持って一人でも多くの仲間が現れることを期待しています。