QMKのRaw HIDでPCからキーボードを操作する
背景
QMK Firmware(以下、FW)では様々な機能が簡単に使えるよう、APIが用意されています。
その中でもあまり注目されないものの、実は便利で普段からお世話になっている機能としてRaw HIDがあります。この機能を使うと、USB経由でPCとキーボードの双方向通信をすることができます。
特定の開発者には必須の機能ですが、FWの機能を使う多くの人はあまり気にすることがないかと思い、せっかくなので記事にまとめてみました。
他のFWでも同じような機能があるかと思いますが、触っていないのでご存知の方はぜひ教えてください。また、「こんな使い方もあるよ!」というコメントもあると泣いて喜びます。
何ができるのか
はじめに、Raw HIDの具体的な活用例を紹介します。
直感的で実用的な例では、Webブラウザから各種パラメーターの調整ができます。
特に便利なのはトラックボールです。トラックボールはその仕様上、エンドユーザーが角度やポインターの移動速度を変更したいことが多く、微調整が必要なモジュールです。これをグラフィカルで直感的かつ高速に設定できるのは有用です。
参考として、ブラウザならではの調整方法をアプリで実装してみました。
円の中心からトラックボールを真上にコロコロしてもらい、円と接した辺りでクリックしてもらいます。すると、現在の設定値と真上からの角度の誤差を使って設定すべき角度が算出できるので、それをキーボードに保存します。

トラックボール調整UI。くっつきー2で採用予定
磁気スイッチであればアクチュエーションポイントとリリースポイントの設定も分かりやすくできます。また、LEDのパターン色など、カスタムキーコードでの設定が難しい値にも役立ちます。
設定可能なパラメーターが多く複雑なキーボードほど価値が出そうですね。
展示デモでも活用できます。
例えば複数あるライティングのパターンを紹介したい場合、Webアプリにボタンを表示しておき、それを押すと表示が変わるということができます。カスタムキーコードを押してもらうより準備が手間ですが、どのキーを押せばどう変わるのかの説明が不要なので、より直感的で色々試してもらえるかもしれません。
ここまでは、いわば「(永続)パラメーター設定」系の活用例です。
他の例として、キーボードの状態を把握するのにも便利です。
現在のFWバージョンを表示したり、接続されているモジュールの状態を一覧したり。特に、PC側ではFWが最新版かどうかの判定・リンクを作ることも容易なので、分かりやすいアップデートへの導線をつくることができます。
また、キーボード自体が大きなディスプレイを搭載していなくても、(リアルタイム性の低い情報は)Webブラウザ経由で確認してもらうことができます。
これは、いわば「状態参照」系の活用例です。
難度は上がりますが、開いているアプリによって自動でレイヤーを変えることもできそうです(試せてはいません)。
OSネイティブアプリを作ることになり大変ですが、Logicoolなどのマウスにはよくある機能なので、いずれはキーボードにも……?[1]
これは「動的な状態変更」系の活用例です。
マイナーな機能?
さて、そんな便利そうな機能ですが、明示的に使われている例は非常に少ないです。
QMKのドキュメントにあるraw_hid_receive関数またはraw_hid_send関数を実装している数で確認してみましょう。
どれくらいマイナーかというと、QMKのリポジトリにマージされているFWではわずか2件。記事投稿現在1090件のFWが登録されているのを踏まえると、0.18%の使用率です。
実はたくさん使われている
一見可哀想な機能ですが、実は縁の下で物凄く使われています。
というのも、VIA内部で使われているからです。
VIAはWebブラウザからキーマップを設定し、その内容をキーボードのフラッシュメモリに書き込みます。まさにPCとのやり取りが行われていますね。コードを見るとquantum/via.c#L290で使われている事が分かります。
さらに、VIAにはCustom UIとして、ユーザー定義のパラメーター調整機能が提供されています。設定したいパラメーターを基本的なフォーム、例えば2値用のボタン、レンジ、ドロップダウンなどと紐づけることができます。またカラーピッカーもあります。まさにブラウザ経由で設定したいものですね。
この機能を実装しているキーボードはQMK本体で4件、the-via/qmk_userspace_viaでは14件見つかりました。ちょっと増えましたね。
別FWですが併せて紹介すると、せきごんさんがVial向けのCustom UI機能を提供しています(sekigon-gonnoc/via-custom-ui-for-vial)。
提供されているキーボードのトラックボールの調整に活用されていたり、無線接続用に上手く通信を削減していたりと、まさに実践的な活用がなされています(参考記事)。
このように、縁の下で頑張っている機能がRaw HIDです。
導入方法
では、実際に導入方法を見てみましょう。
実装方法は様々ですが、今回は次の前提を置きます。
- VIAを利用し、機能を併存させる
- VIA Custom UIのプロトコルに(なるべく)沿う
- Webフロントエンドも用意する
この前提では、既存のVIAの機能を残したまま追加で機能を提供できます。欲しい機能だけちょい足ししてみましょう。
事前に押さえておくべきポイント
QMK Raw HIDのドキュメントを見ると、Warningとして次の文言があります。
Because the HID specification does not support variable length reports, all reports in both directions must be exactly RAW_EPSIZE (currently 32) bytes long, regardless of actual payload length. However, variable length payloads can potentially be implemented on top of this by creating your own data structure that may span multiple reports.
https://docs.qmk.fm/features/rawhid#sending-data-to-the-keyboard
要は、1回の通信は必ず32バイトのデータになるということです。後述の実装はこれを前提としているので、押さえておきましょう。
また、Raw HID利用のためRAW_ENABLE = yesを設定すると書かれていますが、これはVIAを有効にする場合は記載不要です。
QMK側の実装
ここからは、QMK上の実装を行います。まずはVIAを有効にするため、rules.mkにVIA_ENABLE = yesを記載します。
VIA_ENABLE = yes
ここからは、VIA Custom UIのドキュメントを参照しつつ進めると良いでしょう。重要な部分だけピックアップして紹介してゆきます。
中核になるのは、void via_custom_value_command_kb(uint8_t *data, uint8_t length) 関数です。
VIAがPCからの通信を処理する中で、「これはCustom UI用の機能だな」と判定されると、この関数が呼ばれます。各キーボードのFWでは、この関数をオーバーライドして必要な機能を実装します。
具体的には、32バイトの先頭1byteで示されるコマンドIDが0x07(id_custom_set_value)、0x08(id_custom_get_value)、0x09(id_custom_save)のいずれかの場合、この関数に処理が移譲されます。
また、続く1byteはチャンネルIDが入ります。これはユーザーが自由に指定できますが、VIA側で使うIDもあるので、0を指定するのが無難です。以降の実装も0とします。
この取り決めの上で、自前の処理を実装します。
送られてくるデータは自由に設定できるため、私の場合は次のような構造体にしています。VIAで決まっているのは先頭2バイトだけです。ヘッダーとして合計6バイト使っていてやりすぎ感がありますが、将来的な拡張と検証のため色々入れています。
なお、全てポインタで保持していますが、理由は後述します。
typedef struct {
uint8_t *command_id;
uint8_t *channel_id;
uint8_t *protocol_version;
uint8_t *seq;
uint8_t *value_id;
uint8_t *data_length;
uint8_t *payload;
} pkt_t;
pkt_t load_pkt(uint8_t *data) {
pkt_t pkt;
pkt.command_id = &data[0];
pkt.channel_id = &data[1];
pkt.protocol_version = &data[2];
pkt.seq = &data[3];
pkt.value_id = &data[4];
pkt.data_length = &data[5];
pkt.payload = &data[6];
return pkt;
}
本体のvia_custom_value_command_kbの実装は次の通りです。所定の値で分岐をしつつ、独自の関数set_param、get_param、save_param内で自由に処理をします。
void via_custom_value_command_kb(uint8_t *data, uint8_t length) {
pkt_t pkt = load_pkt(data);
if (*pkt.channel_id == id_custom_channel) {
switch (*pkt.command_id) {
case id_custom_set_value: {
set_param(&pkt);
break;
}
case id_custom_get_value: {
get_param(&pkt);
break;
}
case id_custom_save: {
save_param();
break;
}
default: {
// Unhandled message.
*pkt.command_id = id_unhandled;
break;
}
}
return;
}
// Return the unhandled state
*pkt.command_id = id_unhandled;
// DO NOT call raw_hid_send(data,length) here, let caller do this
}
パラメーターの保存
まずはsave_paramから見てみましょう。これは名前の通り、設定値を実際にキーボードのEEPROM(フラッシュメモリ)に書き込みます。
void save_param(void) {
eeconfig_update_kb_datablock(&kb_config.raw, 0, sizeof(kb_config));
}
とても簡単ですが、ここは実装上の判断があります。
QMKのドキュメントでもある通り、EEPROMへの書き込みはなるべく減らすべきです。フラッシュメモリは書き込み回数が限定されています[2]ので、キー入力ごとに書き込んだりすると、一瞬で枯渇します。そのため、FW側でタイマーを使って書き込み間隔を調整するのが理想です。
しかし、QMK側で実装するのが面倒なのと、フロントエンド側も自分で作るのでまぁいいか、という感じで簡単実装にしています。
また、単純なコードではありますが、QMKのドキュメントにはeeconfig_update_kb_datablockの記載がなく、使う必要があるキーボードは少ないので馴染みがないかもしれません。気になる方は調べてみてください。
パラメーターの読み取り
次に、get_paramを見てみます。キーボードから値を取得する処理です。
これもキーボードに応じて自由にデザインできますが、擬似コードは下記の通りです。
void get_param(pkt_t *pkt) {
uint8_t value_id = *pkt->value_id;
uint8_t *data = pkt->payload;
uint8_t *data_length = pkt->data_length;
if (value_id < VCVID_START) {
// parameter value is always 32 bits
uint32_t value = 0;
*data_length = 4; // max length
switch (value_id) {
case 1:
value = kb_config.mouse_layer_on;
break;
case 2:
value = kb_config.mouse_layer_off_delay_ms;
break;
...
default:
dprintf("value_id %d not found\n", value_id);
return;
}
for (uint8_t i = 0; i < (*data_length); i++) {
data[i] = (uint8_t)(value >> (i * 8));
}
return;
} else {
switch (value_id) {
case VCVID_SPECIAL_CASE:
<特殊な場合>
...
default:
dprintf("value_id %d not found\n", value_id);
return;
}
}
return;
}
まず、渡されたデータに「どのパラメーターか」を示すID(value_id)があるので、それを取り出します。
次に、実際の値を取り出します。通常、パラメーターは4バイトもあれば十分なため、適当に定義した(100など)VCVID_STARTより小さいvalue_idであれば、値は必ず4バイトと仮定します。それ以外は特殊処理になり、4バイト以上のやり取りをします。これは後述します。
値はどこで返すの?と思いますが、元々やってきたデータに詰め直すと、VIA側の処理で同じ地点の32バイトが送り返されます。そのため、必要なデータだけ変更すれば、他はそのまま同じ値が送り返されます。これが構造体の要素をポインタにしていた理由ですね。
パラメーターの設定
最後に、set_paramです。キーボード側に設定したい値は通常1つ4バイトは超えないので、こちらも必ず4バイトとの仮定を置いています。
static inline uint32_t clamp_u32(uint32_t v, uint32_t max) {
return v > max ? max : v;
}
void set_param(pkt_t *pkt) {
// the value is always 32 bits
uint32_t value = pkt->payload[3] << 24 | pkt->payload[2] << 16 | pkt->payload[1] << 8 | pkt->payload[0];
uint8_t value_id = *pkt->value_id;
switch (value_id) {
case 1:
kb_config.mouse_layer_on = clamp_u32(value, 1);
break;
case 2:
kb_config.mouse_layer_off_delay_ms = clamp_u32(value, 63);
break;
...
default:
dprintf("value_id %d not found\n", value_id);
return;
}
apply_set_param_side_effect(value_id);
}
コードの通りですが、clamp_u32という関数で上限値を制限し、変な値が設定されないようにしています。
また、最後にapply_set_param_side_effectという自作関数を呼んでいます。
トラックボールドライバーへの設定値など、設定を即反映する必要があるものはここに切り出し、見通しを良くしています。
void apply_set_param_side_effect(uint8_t value_id) {
# ifdef POINTING_DEVICE_ENABLE
switch (value_id) {
case 1:
set_auto_mouse_enable(kb_config.mouse_layer_on);
break;
case 2:
set_auto_mouse_timeout(calc_auto_mouse_timeout_by_kbconfig(kb_config.mouse_layer_off_delay_ms));
break;
...
default:
break;
}
# endif
}
前述の通り、EEPROMへの保存は別途行われるので、ここでは考慮しません。
Webフロントエンド側の実装
ここからは、Webブラウザで動くアプリを作ります。よりいっそう選択肢が多いですが、次の前提で進めます。
- TypeScriptを使う
- Webアプリのフレームワークは問わない
- node-hidは使わずWebHID APIで実装(説明のため)
Webアプリの開発をしたことがないとピンとこないかもしれませんが、適宜他のドキュメントを参照してみてください。細かい点は使うフレームワークやライブラリに依存するので、雰囲気を掴んでいただければと思います。
なお通常は静的Webサイトで十分なので、色々なサービスで基本無料でホスティングできます。個人の自作キーボード活動に優しいですね。
キーボードとの接続
まずはキーボードとの接続処理です。VIAが有効なキーボードであれば、下記のような処理を実行すると、ブラウザにデバイス選択ポップアップを出すことができます。
export async function connect(): Promise<HIDDevice | undefined> {
const list = await navigator.hid.requestDevice({
filters: [
{
vendorId: VENDOR_ID,
productId: PRODUCT_ID,
usagePage: USAGE_PAGE,
usage: USAGE_ID,
},
],
});
if (!list.length) return;
const dev: HIDDevice = list[0]!;
await dev.open();
return dev;
}

デバイス選択
全デバイスが出てくるとどれか目的のキーボードか分からなくなってしまうので、接続先のフィルタリングをしています。VENDOR_IDとPRODUCT_IDは自分のキーボード設定を、USAGE_PAGEとUSAGE_IDはQMKのドキュメントを参考に設定します。
これでデバイスのインスタンスが得られるので、以降はこれを使って操作します。
キーボードへのデータ送信
やり取りのキモとなる、キーボードへのデータ送受信部分を見ていきましょう。
export async function send(
dev: HIDDevice,
apiCommand: APICommand,
valueID: CustomValueID,
payload: Uint8Array,
): Promise<SendData> {
if (!dev) throw new Error("not connected");
const sd: SendData = new SendData(apiCommand, valueID, payload);
const out = sd.toUint8Array();
await dev.sendReport(REPORT_ID, out);
const response = new Promise<SendData>((resolve, reject) => {
const onInput = (e: HIDInputReportEvent) => {
const u8 = new Uint8Array(e.data.buffer);
if (sd.verifyResponse(u8)) {
const typedData = SendData.loadFromUint8Array(u8);
clearTimeout(to);
try {
dev.removeEventListener("inputreport", onInput);
} catch {}
resolve(typedData);
}
};
dev.addEventListener("inputreport", onInput);
const to = setTimeout(() => {
try {
dev.removeEventListener("inputreport", onInput);
} catch {}
reject(new Error("timeout"));
}, 2000);
});
return response;
}
ちょっとややこしいですが、独自のSendDataクラスにデータを詰め、dev.sendReport(REPORT_ID, out)で送るまでが送信部分です。REPORT_IDは0です。独自に決めたデータ形式に従い送ります。先頭2バイトはVIA側で決められていることを忘れずに。
続いて、返信を受け取る処理があります。説明用に自前で処理をしていますが、実践では適切なライブラリを使うと楽です。
先程見た通り、VIAはこちらから送った値を送り返してきます。そのため、送信をしたら受信処理も行っています。具体的には、inputreportのイベントをハンドリングし、2秒のタイムアウトを設けて処理をしています。
本当に送った値に対する返信なのかを判断するため、変更されない箇所の値をチェックしているのがverifyResponse関数です。正しければtypedDataに詰めて返しています。
これで値の送受信ができるので、フォームと紐づけることで目的の処理が実現できます。
VIAで定義されている3種類のコマンドIDをフロントエンドから打ち分けて、機能を実装してゆきましょう。
値の保存処理(コマンドIDid_custom_saveをsend)は頻度を下げるため、1秒以内に連続して呼ばれたらキューイングする、などの処理を入れると良いでしょう。
応用編: マルチパート
最後に、送受信の応用編です。
前述の通り、1回の通信は32バイトが上限です(実質ペイロードはヘッダー分だけさらに小さい)。キーボード側からもっと大きなデータや可変長データ、例えばキーごとの設定や接続しているモジュールの個数・状態といった情報を送りたい場合はどうすれば良いのでしょう?
ここでは簡単な方法として、ヘッダーに2バイト増やし、それぞれパート番号と総パート数を表すようにします。これで複数に分けてデータ送信ができます。最大256パートにしか分けられないですが、説明のためこれで十分とします。
また、VIA側の標準実装では32バイトしか送ってくれないので、raw_hid_sendを使います。
全てを記載すると長いので、実装のキモだけ記載します。QMK側では、get_param内で必要に応じて分岐をし、次のようにraw_hid_send関数を直に使い複数回送りつけます。
...
for (uint8_t idx = 0; idx < chunks + 1; idx++) {
multipart_t resp = {0};
uint16_t off = (uint16_t)idx * PAYLOAD;
uint16_t rem = (uint16_t)(total_len - off);
uint8_t take = (uint8_t)(rem > PAYLOAD ? PAYLOAD : rem);
// echo header fields
resp.command_id = *pkt->command_id;
resp.channel_id = *pkt->channel_id;
resp.protocol_version = *pkt->protocol_version;
resp.seq = *pkt->seq;
resp.value_id = *pkt->value_id;
resp.data_length = (uint8_t)(META + take);
resp.part = idx;
resp.total_parts = chunks;
memcpy(&resp.data[0], json_buf + off, take);
// always send 32 bytes
uint8_t out[32] = {0};
memcpy(&out[0], &resp, 32);
raw_hid_send(out, 32);
}
...
PAYLOADは1回で送れるバイト数です。32バイトからヘッダー部を減らした値です。要はデータをいい感じに分割し、繰り返しraw_hid_sendしているだけです。
フロントエンド側は、既存の受信処理に加え、全パートが揃ったら順番に結合してバイト列を復元するようにします。ざっくりした実装なので、雰囲気だけ掴んでください。
const response = SendData.loadFromUint8Array(u8);
const chunk = MultiPartData.loadFromUint8Array(
response.data.slice(0, response.dataLength),
);
if (!parts.length)
for (let i = 0; i < chunk.totalParts + 1; i++) parts.push(null);
parts[chunk.part] = chunk.data;
// done?
if (parts.every((p) => p !== null)) {
try {
dev.removeEventListener("inputreport", onInput);
} catch {}
clearTimeout(to);
const totalLen = parts.reduce((acc, cur) => acc + (cur ? cur.length : 0), 0);
const buf = new Uint8Array(totalLen);
let off = 0;
for (const chunk of parts) {
const rem = Math.min(chunk.length, totalLen - off);
if (rem <= 0) break;
buf.set(chunk.slice(0, rem), off);
off += rem;
}
resolve(buf);
}
これで長いデータも受け取れるようになりました。使い所は限られますが、困ったときにはこのような方法もあると頭に留めておくと良いかもしれません。
まとめ
Raw HIDの基礎を紹介しました。すごくざっくりした説明でしたが、利用と実装のイメージが掴めたら幸いです。
実際に活用するには別途フロントエンドアプリが必要なので、少し難しく感じるかもしれません。一方で、アプリと併せて提供することで、そのキーボードを分かりやすく使ってもらうことができたり、独自の特色が出せて楽しい場合があります。Webアプリであれば、比較的低コストで長期の提供がしやすいです。
また、デモ展示で分かりやすく機能を紹介するのにも便利かと思います。他にも面白い使い方があれば、ぜひコメントください。
この記事は、開発中のくっつきー2で書きました。
Raw HIDを使った専用Webアプリも提供予定です!