AIによるキーボード&マウス自動操作:ESP32-S3を自作USB HIDにしてマインスイーパーを解かせる

ESP32-S3でUSB HID、AIがマインスイーパー。花ロボが開発ボードを手に持ち、光る線でキーボードとマウスを操作してマインスイーパーを解き、花イヌが旗をくわえて見ている
Information

この記事は、AI が行った検証の記録をもとに AI が執筆し、人間が確認しています。

ESP32-S3の開発ボードを、USBキーボード&マウスとして動かしました。さらに、AI(Claude Code)がこのボードに命令を送ってRaspberry Pi 5を操作し、画面を見ながらマインスイーパーを解くところまで試しました。

結果を先にまとめます。ESP32-S3のUSB(OTG)機能を使うと、PCからは普通のUSBキーボードとマウスに見える機器を作れました。操作される側のRaspberry Pi 5には、何もインストールしていません。AIはSSHで画面を撮り、盤面を読み、絶対座標でマスをクリックして、マインスイーパー(9×9、地雷10個)を1分19秒、ノーミスでクリアしました。狙ったマスを正確にクリックするには、普通の「相対移動」のマウスではなく、画面の座標を直接指定する「絶対座標マウス」を自作する必要がありました。

作業日:2026年10月2日(日本時間)。

やったこと

  1. ボタンで決まった動作をする:基板のBOOTボタンを押すと、文字を入力し、マウスを動かす
  2. AIから操作する:Claude CodeがシリアルでESP32に命令を送り、キーボードとマウスを操作する
  3. 画面を見ながら操作する:SSHとgrimで相手の画面を撮り、AIが状況を見て次の手を決める。最後にマインスイーパーを解かせる

使ったもの

項目内容
ボードFreenove ESP32-S3 開発ボードキット(フラッシュ 8MB、PSRAM 8MB。ESP32-S3-WROOM-1 N8R8 相当と推定)
開発PCWindows 11、VS Code、PlatformIO 拡張
フレームワークArduino(PlatformIO の espressif32 プラットフォーム 7.1.3)
操作される側Raspberry Pi 5(Raspberry Pi OS Bookworm、Wayland のデスクトップ)
AIClaude Code(VS Code 拡張)

このボードにはUSB-Cのポートが2つあります。今回の構成は、この2つの役割を分けたことがポイントです。

ポート中身用途
UARTUSB-シリアル変換チップ(WCH CH343)プログラムの書き込みと、AIからの命令の受け取り。Windows に接続(COM5)
USBESP32-S3 本体の USB(OTG)キーボード&マウスとして Raspberry Pi 5 に接続

全体の構成

Claude Code(Windows)
   │  hid_send.py が "CLICK 960 566" のような1行の命令を送る
   ▼
[UART ポート / COM5]
   ▼
ESP32-S3 … 命令を解釈して、USB HID のレポートを作る
   ▼
[USB ポート(OTG)]
   ▼
Raspberry Pi 5 … 「キーボードとマウスが操作された」と受け取る
   │
   └ SSH + grim で画面を撮り、Claude Code が画像を見て次の手を決める

ESP32自体にAIの機能は入っていません。UARTから届いた文字列の命令を、キーボードやマウスの操作に変えるだけです。考えるのはPC側のAIです。

セットアップ

ボードの中身を確かめる

届いたボードが本当にESP32-S3なのかも分からなかったので、まず esptool で調べました。

esptool --port COM5 chip-id
esptool --port COM5 flash-id
esptool --port COM5 get-security-info

USB を OTG モードにする

ESP32-S3のUSBは、標準ではデバッグ用のシリアル(USB-Serial/JTAG)として動きます。キーボードやマウスとして使うには、PlatformIOの設定でTinyUSB(OTG)モードに切り替えます。

[env:freenove_esp32_s3_wroom]
platform = espressif32 @ ^7.1.3
board = freenove_esp32_s3_wroom
framework = arduino

build_unflags =
    -DARDUINO_USB_MODE=1
    -DARDUINO_USB_CDC_ON_BOOT=1
build_flags =
    -DARDUINO_USB_MODE=0          ; 本体の USB を TinyUSB (OTG) で使う
    -DARDUINO_USB_CDC_ON_BOOT=0   ; Serial は UART 側 (CH343) に出す

キーボードには、Arduino標準の USBHIDKeyboard を使いました。

ボタンで決まった動作をする

最初の動作確認として、BOOTボタン(GPIO0)を押すと、Hello from ESP32 S3 と入力してEnterを押し、マウスカーソルで100ピクセル四方の四角を描くようにしました。

USBを挿しただけでは何も入力しないようにしています。また、テストはメインのWindows PCではなく、Raspberry Pi 5で行いました。間違ってメインのPCに入力が飛ぶと困るからです。

AI から操作できるようにする

ESP32が、UARTから1行に1つの命令を受け取って実行するようにしました。主な命令は次のとおりです。座標は画面のピクセル(左上が0, 0)で指定します。

命令例動作
TYPE <文字列>TYPE hello文字を入力する
KEY <キー>KEY ctrl+sキーを押す。同時押しもできる
CLICK [ボタン] [x y]CLICK right 1020 606指定した座標をクリックする
ABS <x> <y>ABS 300 200マウスを座標へ移動する
DRAG x1 y1 x2 y2DRAG 100 100 400 300ドラッグする
SCROLL <n>SCROLL -3ホイールを回す

PC側は、短いPythonスクリプト(hid_send.py)で命令を送ります。ESP32は1命令ごとに OK か ERR ... を返すので、AIは失敗にすぐ気づけます。

python hid_send.py "TYPE Hello from Claude Code" "KEY enter" "CLICK 960 566"

相手の画面を見る(SSH + grim)

画面を見ずに操作するだけでは、決まった手順を流すことしかできません。そこで、Raspberry Piのデスクトップ画面をSSH経由で撮るようにしました。

ssh pi 'XDG_RUNTIME_DIR=/run/user/1000 WAYLAND_DISPLAY=wayland-1 grim /tmp/screen.png'
scp pi:/tmp/screen.png .

なお、画面を撮るのにSSHを使っているので、「相手に何もインストールしていない」のは操作の部分だけです。

結果:AI がマインスイーパーをクリア

Raspberry Piに sgt-puzzles(Simon Tatham のパズル集)を入れ、Mines(9×9、地雷10個)を起動しました。

Raspberry Pi 5 のデスクトップの中央に、まだ何も開けていないマインスイーパー(Mines)のウィンドウがある
開始時の画面。Raspberry Pi 5 のデスクトップを grim で撮ったもの

Claude Codeは、次のループを繰り返しました。

  1. grim で盤面を撮る
  2. 画像から盤面を読み取り、数字から地雷のマスと安全なマスを推理する
  3. 地雷には右クリックで旗を立て、安全なマスは左クリックで開ける

sgt-puzzlesは最初の1手で地雷を踏まない作りです。その1手目を除いて、勘で開けたマスはありませんでした。結果は1分19秒でクリア(COMPLETED!)です。

地雷10個すべてに旗が立ち、下に [1:19] COMPLETED! と表示されたマインスイーパーの盤面
クリアした盤面。地雷10個すべてに旗が立っている

つまずいた点

相対移動のマウスでは、狙った場所をクリックできない

今回いちばん大きな問題でした。普通のUSBマウスは「右に10動いた」という相対移動を送ります。OSのマウス加速がかかるので、同じ量を送っても実際の移動量が変わり、マインスイーパーのマス(約20ピクセル)を正確に狙えませんでした。

そこで、画面の座標を直接指定する絶対座標マウスを自作しました。仮想マシンのUSBタブレット(QEMU の usb-tablet)と同じ方式で、XとYを0〜32767の範囲で送ると、OSが画面全体に割り当ててくれます。HIDレポートディスクリプタの要点は次のとおりです。

0x05, 0x01,              // Usage Page (Generic Desktop)
0x09, 0x30, 0x09, 0x31,  // Usage (X), Usage (Y)
0x16, 0x00, 0x00,        // Logical Minimum (0)
0x26, 0xFF, 0x7F,        // Logical Maximum (32767)
0x75, 0x10, 0x95, 0x02,  // 16bit × 2
0x81, 0x02,              // Input (Data, Var, Abs)  ← Abs が絶対座標

ESP32側では、ピクセル座標をこの0〜32767の値に変換して送ります。画面の解像度(既定は1920×1080)は命令で変えられるようにしました。ABS 300 200 を送ると、カーソルの先端がぴったり (300, 200) に来ました。

Raspberry Pi のデスクトップの左上部分。マウスカーソルの先端が座標 (300, 200) にある
ABS 300 200 を送った後の画面(左上の 600×400 ピクセルを切り出し)。カーソルの先端が (300, 200) に来ている

相対マウスと絶対マウスを同じUSB機器に同居させると、Linux側で1台のマウスにまとめられて誤動作するおそれがあります。そのため、相対マウスは外して絶対マウスだけにしました。

シリアルポートを開くと ESP32 がリセットされる

このボードは、DTRとRTSの信号線でESP32を自動リセットする回路を持っています。何もしないと、命令を送ろうとポートを開くたびにESP32が再起動し、Raspberry Piから見るとUSBが抜き挿しされたことになります。ポートを開く前にDTRとRTSを下げておくことで回避しました。

s = serial.Serial()
s.port = "COM5"
s.baudrate = 115200
s.dtr = False
s.rts = False
s.open()

PlatformIO にボードの定義がない

pio boards freenove の一覧には freenove_esp32_s3_wroom が出てくるのに、ビルドすると Unknown board ID になりました。一覧はレジストリ上の最新版を見ていて、インストール済みのプラットフォーム(6.5.0)は古かったのが原因です。プロジェクトの設定で platform = espressif32 @ ^7.1.3 と指定して解決しました。

esptool が intelhex がないと言って止まる

新しいプラットフォームに付いてくる esptool v4.11 は、Pythonのモジュール intelhex を必要とします。PlatformIOのPython環境(penv)にpipで入れると一度は直りますが、VS CodeのPlatformIO拡張がpenvを作り直したときに消えてしまいました。最終的に、esptoolを依存の少ない v4.5.1 に固定して解決しました。

platform_packages =
    platformio/tool-esptoolpy @ ~1.40501.0

できないこと・注意点

次に試したいこと

参照資料

Arduino-ESP32 USB API / PlatformIO Espressif 32 / esptool ドキュメント / USB HID 仕様(USB-IF) / Simon Tatham’s Portable Puzzle Collection