はじめに
MKESPN K810は、12キーとスティック、ホイールを備えたUSB接続の片手キーボードです。
キーの割り当ては、メーカーが配布するWindows用の設定アプリで変更します。
筆者はこのアプリをPCに入れたくありませんでした。
K810はAliExpressやAmazonなどで売られている、ノーブランド系の低価格なデバイスです。
本体と同じ出所の設定ソフトを、そのまま信頼し切るのは難しいと判断しました。
また、Windows専用のアプリに頼らず、OSによらず設定を変えられるようにしたいとも考えました。
そこで、アプリを一度も起動せずに通信手順を解析し、ブラウザから設定を変更できるツールを作りました。
- ブラウザ版:K810 キーボード設定
- リポジトリ:skrtk98/MKESPN-K810-keyboard
- 解析の詳細:GitHub Wiki
アドレス単位の解析結果はWikiにまとめました。
本文は、通信内容を突き止めた方法と、WebHIDに載せるまでにつまずいた点に絞っています。
作ったもの
PC版のChromeまたはEdgeでページを開き、K810を接続すると、次の設定を本体に保存できます。
- 12キーとスティック4方向へのキー割り当て(F13〜F24などの単一キー、メディア操作、照明操作)
- ホイール2方向の割り当て(音量、スクロールなど)
- Ctrl+Cなどのショートカット、マウス操作、待機を含むマクロ
- 照明のモードと色
- 設定のバックアップと復元
設定は本体のEEPROMに保存されるので、一度書き込めば常駐アプリは要りません。
同じ機能をPythonのCLIからも使えます。
使わない方がよい場面
このツールは、1台の実機(VID:PID 30FA:2350)を相手にWindows上で検証したものです。
次の場面では使わないか、慎重に使ってください。
-
別のファームウェアや別バリアントの個体:保存形式が同じとは限りません。
2130:30FAの個体はWeb版の対象外です。 - macOSやLinux:WebHIDの仕組み上は動くはずですが、実機では確かめていません。
- FirefoxやSafari:WebHIDを提供していないため、デモモードしか使えません。
- バックアップを残せない環境:本体のEEPROMを書き換えるツールなので、復元用のデータを手元に保存できることが前提です。
このツールはMIT Licenseのもと無保証で提供しており、設定の消失や機器の故障が起きても作者は責任を負いません。
解析から実装までの流れ
解析から実装までは、次の5段階で進めました。
- インストーラを実行せずに展開し、設定アプリの実行ファイルを取り出す
- 実行ファイルを静的解析し、HID通信をしている関数を探す
- その関数だけをエミュレーターで動かし、送るはずのパケットを取り出す
- 取り出したパケットを正解データとして、Python版とJavaScript版を実装する
- 実機に最小限の変更を書き込み、読み戻して確かめる
通信内容を知るだけなら、USB通信をキャプチャする方法もあります。
ただし、キャプチャするには設定アプリを実際に動かす必要があります。
この流れなら、アプリを起動しないまま、アプリが送るはずの通信内容を得られます。
設定アプリを起動せずに通信手順を取り出す
展開と静的解析
インストーラはInno Setup製だったので、innoextractで中身を取り出しました。
出てきた Gaming Keyboard.exe は32ビットのWindowsアプリです。
メーカー掲載版と販売店の旧配布版とで、実行ファイルのSHA-256は一致しました。
この実行ファイルをGhidraで解析し、HidD_SetFeature と HidD_GetFeature の呼び出し元をたどりました。
その結果、K810はキーボード入力とは別に、設定用のベンダー定義HIDコレクション(Usage Page FF01)を持つと分かりました。
設定の読み書きは、すべてこのコレクションの8バイトのFeature Report 7で行っています。
関数だけをエミュレーターで動かす
静的解析だけでは、パケットの各バイトがどう組み立てられるかを読み違えるおそれがあります。
そこで、通信を担う関数の範囲だけを、CPUエミュレーターのUnicornで実行しました。
仕組みは次のとおりです。
- 実行ファイルをUnicornの仮想メモリに配置し、対象の関数の先頭から実行する
-
HidD_SetFeatureなどWindows APIの呼び出し先に来たら、フックで記録して戻る。実際のUSB通信は起きない - 対象の関数範囲の外へ実行が飛んだら、例外を投げて止める
def hook(uc, ip, size, user):
if ip == 0x2000000: # 呼び出し元へ戻ったら終了
uc.emu_stop()
elif ip in (0x58F379, 0x58F373): # HidD_SetFeature / HidD_GetFeature の呼び出し先
sp = uc.reg_read(UC_X86_REG_ESP)
_, handle, buffer, length = struct.unpack('<4I', uc.mem_read(sp, 16))
if ip == 0x58F379:
trace.append({"set": bytes(uc.mem_read(buffer, length)).hex()})
else:
trace.append({"get": length})
uc.mem_write(buffer + 1, bytes([data[read_count]])) # 合成した応答を返す
read_count += 1
return_stdcall(12)
elif not (routine <= ip <= routine_end):
raise RuntimeError(f"Unexpected code target 0x{ip:x}")
こうして得た「元アプリが送るはずのパケット列」をJSONに保存し、テストの正解データにしました。
Python版とJavaScript版は、同じ入力からこのパケット列と同じものを出すかをテストで確かめています。
正解データを作る段階でも、実機は必要ありません。
エミュレーションで分かるのは、元アプリが作る値までです。
ファームウェアがその値をどう扱うかは、実機で確かめるしかありません。
分かった通信手順
設定の読み書きは、8バイトのパケットを1つずつ送って行います。
| バイト | 内容 |
|---|---|
| 0 | Report ID(07) |
| 1 | コマンド種別(18) |
| 2 | 操作 |
| 3 | ブロック内のインデックス |
| 4..5 | EEPROMのアドレス(リトルエンディアン) |
| 6 | 値 |
| 7 | ブロック長 − 1 |
書き込みは、ステージングと確定の2段階です。
ステージングとは、書き込む値を1バイトずつ本体側に置いておく操作のことです。
全バイトをステージングした後に確定命令を送ると、EEPROMに書き込まれます。
読み取りも同じステージングの手順を使います。
確定命令を送らず、1バイトずつ値を受け取ります。
キー割り当ては、設定領域の中に8行×20列の表として保存されていました。
通常のキーはUSB HIDのキーボード用Usage IDで、メディア操作やマクロはメーカー独自のコードで表します。
保存値は正しいのに動作が変わらない問題
解析どおりに書き込み、読み戻した値も一致しているのに、本体の動作が変わらないことが2回ありました。
ホイール設定が反映されない
1回目はホイールの割り当てでした。
値は保存できていて、スティックを押し込んだりUSBを挿し直したりすると反映されることもありました。
当初は「保存後にスティックを押し込む」ことを手順として案内していましたが、元アプリがそうしている根拠はなく、のちに撤回しました。
原因を探すため、元アプリのApply処理全体をエミュレーターでトレースし、送信順を自作の実装と比べました。
設定領域の先頭には、モードなどの設定が入った8バイトの制御ブロックがあります。
元アプリは、値が変わらなくてもこの制御ブロックを毎回書き込んでいました。
一方、自作の実装は値が変わったブロックだけを送っていたので、制御ブロックが抜けていました。
読み取った制御ブロックをそのまま再送するように直すと、押し込みもUSBの挿し直しもなしに、保存直後からスクロールが効くようになりました。
ファームウェアがなぜ再送を必要とするのかまでは解析していません。
書き込み後にキー入力が止まる
2回目は、設定を書き込んだ後に物理キーの入力が戻らない現象でした。
ブロックごとの終了命令だけでは足りず、元アプリの終了処理と同じ一連の命令(07 18 11 → 07 18 00 → 07 20)を送る必要がありました。
2つの問題は、どちらもパケットの形式は正しく、元アプリの手順の一部を省略していたことが原因でした。
エミュレーションで元アプリの送信順を丸ごと取り出せたので、自作の実装との差分から原因を特定できました。
元アプリの画面にないスティック押し込み
逆に、元アプリにない機能が見つかったこともありました。
元アプリの画面ではスティックの押し込みを設定できません。
しかし保存処理は、画面に出ない列も含めて20列すべてを書き出していました。
押し込みに対応する列をF19に変えてみると、実機で押し込むたびにF19が入力されました。
ただし、押し込みの初期値はモード切り替えの独自コードです。
上書きするとモード切り替えの操作を失うおそれがあるため、Web版にはまだ載せていません。
WebHIDへの移植
WebHIDを使うと、ブラウザからHIDデバイスにレポートを送受信できます。
ドライバーの差し替えや拡張機能が要らないので、「アプリを入れずに、OSによらず設定する」という目的に合っていました。
接続するHIDコレクションの絞り込み
requestDevice() のフィルターで、設定用のコレクションだけを候補にします。
標準のキーボード入力はChromeが保護しており、WebHIDからは入力レポートを受け取れません。
K810は設定用に別のベンダー定義HIDコレクションを持っているので、この制限を受けずに通信できました。
let devices = (await navigator.hid.getDevices()).filter(d => K810.matches(d));
if (devices.length !== 1) devices = await navigator.hid.requestDevice({
filters: [{
vendorId: 0x30fa,
productId: 0x2350,
usagePage: 0xff01,
usage: 1
}]
});
Feature Reportの読み取り長
Python版は、16バイトのGET_FEATUREでメタデータを読み、設定領域の先頭アドレスを得ていました。
これは、同じReport IDでも要求する長さによって返る内容が変わる、という機器側の挙動に頼った読み方です。
WebHIDの receiveFeatureReport() には長さを指定する引数がなく、レポート記述子どおりの8バイトしか受け取れません。
そこで、元アプリが別の箇所で使っている1バイトずつの読み取り手順でEEPROMのヘッダーを読み、先頭アドレスを取り出すことにしました。
ヘッダーの前後にある固定値を検査し、一致しなければ書き込まずに止めます。
async send(bytes) {
// Report IDは別の引数で渡し、本体は残りの7バイト
await this.device.sendFeatureReport(7, bytes.slice(1));
}
async getByte() {
const view = await this.device.receiveFeatureReport(7);
if (view.byteLength !== 8 || view.getUint8(0) !== 7) throw new Error('未対応のFeature Report形式です。');
return view.getUint8(1);
}
receiveFeatureReport() の戻り値は、先頭にReport IDを含む8バイトでした。
この形以外は受け付けないようにしています。
書き込みの失敗に備える仕組み
EEPROMを書き換えるツールなので、書き込みに失敗しても元に戻せるよう、次の仕組みを入れました。
- 書き込む前に、復元用のデータをlocalStorageに保存する。保存できなければ書き込まない
- 書き込む直前に本体を読み直し、別のアプリが設定を変えていないか確かめる
- Web Locksで、同じページを開いた複数のタブが同時に書き込まないようにする
- 書き込んだ後は、設定領域248バイト(マクロを保存した場合はEEPROM全体)を読み戻して照合する
- 変更しないバイトは、読み取った値をそのまま書き戻す
ページはGitHub Pagesの静的サイトで、ビルド工程もサーバーもありません。
CSPでネットワーク接続を禁止しているので、設定データが外部に送られることはありません。
画面にはデジタル庁デザインシステムの公式CSSを使いました。
AIを使った開発で人が担った部分
この解析と開発には、GPT-6 Luna(medium)を使いました。
人とAIの分担は次のとおりです。
| 担当 | 作業 |
|---|---|
| 筆者 | 概念設計、テストシナリオの作成、設定を読み書きするスクリプトのプロトタイプ |
| AI | 解析の補助、プロトタイプをもとにした本開発、Web UIの開発、テストシナリオをもとにしたテストコードの作成、テストの実施と修正 |
何を作るか、何を確かめるかは筆者が決め、コーディングとテストはすべてAIに任せました。
そのうえで、AIだけでは完結しなかった部分が3つあります。
- 解析の進め方:調査の初期には方針の壁打ちにもAIを使いましたが、そこでAIは設定アプリ全体を逆アセンブルして読み解こうとしました。筆者はこれを止め、HID通信の呼び出し元から関数を絞り込み、その関数だけをエミュレーターで動かして出力を確かめる探索的な進め方に切り替えました。前半で紹介した解析の流れは、この切り替えの結果です。
- 物理的な確認:キーを押す、ホイールを回す、照明の色を見る、といった確認は人がやるしかありません。「保存値が一致した」と「実際に動いた」は別物で、後者は人の操作と観察が必要でした。
- 誤った回避策の見直し:ホイールの問題では、実機で効いて見えたスティック押し込みの回避策を一度採用しました。その後、Web版で設定が反映されないことを筆者が実機で見つけて報告し、それを受けて送信順の比較からやり直しました。
解析状況
解析が完了したわけではありません。
動作まで確かめたのは、次の項目です。
- 通常キーの割り当て
- Ctrl+F13のショートカット
- 音量アップ
- 照明モードの切り替えと、照明の一部のモード
- ホイールのスクロールと音量
メディア操作の大半や照明モードの多くは、保存形式を解析しただけで、実機の動作は確かめていません。
検証の範囲と未解明の項目は、Wikiの解析状況と未解明項目に分けて記録しています。
おわりに
メーカーアプリを起動せずに、関数単位のエミュレーションで通信手順を取り出し、WebHIDで設定ツールを作りました。
エミュレーションの出力をそのまま正解データにすると、実機に触る前に実装の誤りを見つけられます。
一方で、保存値が正しいのに動作が変わらない問題は、元アプリの手順全体を再現し、実機で確かめて初めて解決しました。
ベンダー定義HIDコレクションで設定する機器であれば、同じ方法でブラウザから設定できるようになる可能性があります。
