← ブログに戻る

Claude Code MCPでハードウェア制御——UART/I2C/SPI/RS-485/CAN 5プロトコル実測

Claude Code から MCP ツール経由で実機のハードウェアを叩いた。UART・I2C・SPI・RS-485・CAN の 5 プロトコルを ESP32-S3 と Raspberry Pi の上で順番に動かし、AI が「LED を点けて」と言うだけで基板上の LED が光る状態にした。

最初は LED 1 個を点けるのに丸 1 日溶かした。今は 5 プロトコル全部が MCP tool 越しに serial.write() の 1 行に畳み込まれている。この記事では、その 5 つで踏んだ地雷を実測値ごと晒す。

なお natural-language-agent-harnesses-arxiv で書いた「AI ハーネスの視界」の話は、ハードウェアを組み込んだ瞬間から一気に生々しくなる。デジタルの世界では context がずれても再実行で済むが、電気の世界では I2C のプルアップ抵抗を忘れた瞬間に SDA が浮いて何も動かない。

Claude Code x 5プロトコル 実測比較

実測 1: LED 点灯——USB-Serial の flush() 忘れで 5 秒遅延

最小構成は ESP32-S3 に赤色 LED を 220Ω 経由で GPIO2 に繋ぐだけ。Python 側は pyserialser.write(b'1') を叩く。

これが動かない。

原因は Python 側の ser.flush() 忘れだった。Linux のシリアルドライバはユーザランドから書いたバイトを内部バッファに溜める。プロセスが exit するまで送らないことがある。プロセスが 5 秒後に終わって、そこで初めて 1 バイトが送信され、LED がやっと光る。ser.write(b'1') の直後に ser.flush() を入れると即座に点く。実測レイテンシは USB-Serial 変換 IC (CP2102 で 10c4

) の応答込みで 約 20ms。Claude Code の Bash ツールから python send.py /dev/ttyACM0 1 を叩いた場合、AI の推論時間 (数秒) が支配的になる。

物理層の遅延は誤差の内側。

Ubuntu 24.04 では sudo usermod -aG dialout $USER の一発で権限が通る。CH340 (1a86

) も CP2102 (10c4
) も kernel 標準ドライバでそのまま見える。DMG を落とす手間があるのは macOS 側だけ。

実測 2: UART——ボーレート 3% ズレでフレーミングエラー

UART は非同期通信なので、送受信でクロックを共有しない。代わりに「ボーレート」を事前に合意する。仕様上は ±2% までの誤差なら受信側が中央でサンプリングして吸収する が、それを超えると Stop bit の位置がズレて Framing Error が出る。Serial.begin(115200) のスケッチに Python から --baudrate 9600 で送ると、ESP32-S3 は受信バイトを無効データとして捨てる。1 バイト送ったのに LED が点かない、という現象になる。

文字化けすらしない、という怖さがある。

MCP tool 化するときは、この事故を tool のシグネチャで防ぐ。

@server.tool()
def uart_open(port: str, baudrate: int = 115200) -> str:
    """指定ポートを開く。port は /dev/ttyUSB0 や COM3 など"""
    ...

@server.tool()
def uart_send(data: bytes) -> int:
    """開いたポートにバイト列を送る。送信バイト数を返す"""
    ...

ボーレートを uart_open の引数に押し込めば、Claude が個別の uart_send 呼び出しでボーレートを再設定してしまう事故は起きない。

tool のシグネチャは物理層の事故を防ぐバリア層でもある、というのが実務での学び。

実測 3: I2C——MPU6050 と DS3231 がアドレス 0x68 で衝突

I2C は 2 本の線 (SDA/SCL) に複数デバイスをぶら下げる。7 ビットアドレスで呼び分ける方式なので、同じアドレスの IC を 2 つ繋ぐと衝突する。私が最初にハマったのは MPU6050 (6 軸 IMU、デフォルト 0x68) と DS3231 (RTC、固定 0x68) の同居。両方の IC が ACK を返して波形が壊れる。i2c_scan() の結果すら不定になる。

MPU6050 の AD0 ピンを High に上げて 0x69 に逃がして解決。

もう 1 つの落とし穴はプルアップ抵抗。I2C は開放ドレイン駆動なので、SDA/SCL に外付けの 4.7kΩ プルアップが必要。ESP32-S3 の内部プルアップは 50kΩ 程度で弱く、Fast mode (400kHz) では波形が鈍る。センサモジュール (Adafruit や SparkFun) はオンボードで付いていることが多いが、生 IC は自前で用意する必要がある。

MCP tool 側で i2c_scan() を最初に呼ぶ運用にしておくと、Claude が勝手に衝突を検知して「アドレス 0x68 が 2 つ応答しています。設定ピンで片方をずらしてください」と言ってくれる。

ハードの事故を AI が診断できるレイヤーに引き上げるコツはここ。

実測 4: SPI——Mode 違いで半日溶かした

SPI は CS 線を 1 本ずつ引く「スター型」の高速通信。ESP32-S3 なら最大 80MHz まで出る。ただしクロックの極性 (CPOL) と位相 (CPHA) の組み合わせで Mode 0〜3 の 4 種類があり、これを合わせないと 線は繋がっているのに 1 バイトも正しく取れない

私が半日溶かしたのはこれ。

SD カード (Mode 0) に対して Mode 3 で話しかけていた。オシロスコープで波形を見るまで、自分のコードが悪いのかカードが死んでいるのか判断できない。

データシートの「SPI Mode 0」の 1 行を読み飛ばした代償。

MCP tool 側では SPI トランザクション全体を不可分な 1 操作として閉じ込める。

@mcp.tool()
def spi_transfer(cs_pin: int, tx_bytes: list[int]) -> dict:
    """CS を落として、バイト列を送受信して、CS を上げる。
    上げ忘れ事故を tool 側で防ぐ。"""
    ...

Claude に CS の上げ下げを個別に指示させると、上げ忘れが発生した瞬間に次のトランザクションが壊れる。

物理層の事故を防ぐ責任は tool 側で持つ、という設計原則を SPI で叩き込まれた。

実測 5: RS-485 + Modbus——20 台のインバータを 1 秒周期で監視

工場の生産ラインに並んだ 20 台のインバータ (モーターの回転数を制御する電力変換装置) を、ESP32-S3 + MAX3485 で RS-485 に繋いだ。Modbus RTU の Function Code 0x03 (Read Holding Registers) で全 20 台の周波数レジスタを 1 秒ごとに順番に読む。

  • バスの両端に 120Ω の終端抵抗
  • 片端だけにバイアス抵抗 (680Ω)
  • ボーレート 9600bps、8N1
  • スレーブアドレス 1〜20 を順番にポーリング

20 台を 1 周するのに実測 約 800ms。ギリギリ 1 秒周期に収まる。読んだ値は JSONL で PC に流す。Claude Code は 5 分に 1 回、蓄積された JSONL を読んで「移動平均から逸脱している個体」を検出、Telegram でアラートを飛ばす。

肝は Claude が制御ループに入っていない こと。

ポーリング自体は ESP32-S3 内で完結し、AI は 5 分周期の異常検知だけを担う。応答時間の桁 (AI = 秒、Modbus = ms) を分けたから成立する分業。

MCP stdio の安全境界: バイナリは Base64

ここまでの 5 プロトコル全部で共通する MCP 側の落とし穴が 1 つある。MCP の stdio transport は JSON-RPC メッセージを UTF-8 で送る仕様で、embedded newline を許さない (MCP transport spec 2025-11-25 版)。つまり 生のバイナリを tool の引数や戻り値に直接入れられない。UART/SPI で b'\x00\x01\xff' のようなバイト列を扱うときは、Base64 でエンコードして string に載せる必要がある。

@server.tool()
def spi_transfer_b64(cs_pin: int, tx_b64: str) -> dict:
    tx = base64.b64decode(tx_b64)
    rx = _spi.transfer(cs_pin, tx)
    return {"rx_b64": base64.b64encode(rx).decode(), "duration_us": ...}

面倒に見えるが、この UTF-8 制約が「LLM の視界に電気を上げるとき、必ずテキストの安全帯を通す」という抽象化を強制する副作用も持っている。stdio を binary passthrough にすると、LLM 側で改行 1 個入ったバイト列が来た瞬間にプロトコルが壊れる。

今の制約はむしろ設計を助けている。

LLM とハードウェアのインピーダンス整合——安全機構は AI に任せない

温度制御を Claude に任せて失敗したことがある。BME280 で温度を読み、Claude に「ファンのデューティ比を決めて」と聞き、結果を PWM に流す構成。

これが動かなかった。

Claude の応答時間は最低 2 秒、長いと 10 秒以上。室温 28℃ で「強で回して」と言われた頃には温度が動いている。ファンが 27℃ を通り過ぎてから止まるので、また上がる。ブンブンと発振する装置ができあがった。

LLM は制御周期 1Hz の温度制御にすら入れてはいけない

役割分担はこう。

  • AI (Claude Code) — 設定値の決定、診断、パラメータ調整、ログ分析、異常検出
  • MCU (ESP32-S3) — リアルタイム制御、センササンプリング、アクチュエータ駆動、安全機構

そして 安全機構は絶対に AI に任せない。物理ストップボタン、ウォッチドッグタイマー、フェイルセーフ、ハードウェアリミッタは MCU と物理回路の組み合わせで、AI を一切経由せずに成立させる。

「危険な状況になったら止めて」を AI に頼むと、応答に数秒かかった瞬間に事故が起きる。

まとめ: AI に電気を触らせる最短ルート

5 プロトコルを MCP ツール化して分かったのは、tool のシグネチャは物理層の事故を防ぐバリア層 だということ。

ボーレートを引数に押し込む、SPI トランザクションを不可分にする、CS の上げ下げを内側に閉じる、Base64 でバイナリを text-safe にする、これらは全部「LLM の視界に上げるべきものと隠すべきものを分ける」設計。そして応答時間の桁が違う LLM と MCU を、非同期のバッファ層 (USB-Serial、MCP tool、Modbus のレジスタ) を必ず挟んで繋ぐ。制御ループには AI を入れない。安全機構は AI に触らせない。この 2 つを守れば、AI エージェントは工場のインバータ 20 台の異常検知から車載 CAN ログの事後分析まで、幅広く仕事をしてくれる。

AI に電気を触らせるときは、まず 1 バイトから。

ser.write(b'1') が LED を光らせる、その 1 バイトの抽象化を丁寧に積み上げていけば、5 プロトコルはあっという間に MCP tool の中に畳み込める。

MCP でハードウェアを扱う設計原則をもっと掘り下げた続きは、『Claude Code Mastery』で書いている。組み込みと AI の分業設計、MCP の実装パターン、実務で使えるチェックリストまで揃えた。