Claude CodeでModbus通信を書く — pymodbusのシミュレータで検証する
実機のPLCや計測器をつながずに、Claude CodeへModbus TCPのクライアントを書かせる手順です。pymodbusで模擬機器を立て、書き込みは許可しない構成で検証します。
Claude CodeでModbus通信を書く — pymodbusのシミュレータで検証する
Modbusの通信コードをClaude Codeに書かせるときの最大の問題は、試す相手が実機になりやすいことです。レジスタの読み出しなら害はありませんが、書き込みを誤ると設備が動きます。結論から言うと、Pythonライブラリのpymodbusで模擬機器をテストの中に立て、クライアントは「接続先の許可リスト」と「書き込みの既定拒否」を持つ薄いラッパー越しに使わせます。Claude Codeは実機に一度もつながないまま、読み出し・書き込み・異常応答までテストで確かめられます。
この記事の例は、pymodbus 3.16.1とPython 3.12で手元で動かしたものです。対象は温度などを保持レジスタに持つ機器1台のModbus TCPで、実機のものではありません。ログCSVの解析はPLCログ解析の記事にあり、ここでは通信コードそのものを扱います。
実機を使わず模擬機器で確かめる理由
Modbusは、クライアントが要求を送り、機器が応答する単純な仕組みです。機器側のレジスタ番号と意味は機器ごとに違い、書き込み要求は設定値や出力をそのまま変えます。
pymodbusのドキュメントは、サーバーを「アプリの動作確認に便利」と位置づけています。シミュレータやサーバーで実機を模して試験する使い方も、利用例として挙げています。サーバーは通常のPCのほか、Raspberry Piのような小さな機器でも動くと書かれています。
Claude Codeに任せる検証ループとの相性もよい構成です。テストが数秒で終わり、結果が合否で返るため、Claudeは直して再実行を繰り返せます。同じ考え方はHono APIをapp.requestで検証する記事や、Pygameをヘッドレスで検証する記事でも使っています。
模擬機器を2つの方法から選ぶ
pymodbusには、機器を模す方法が大きく2つあります。
模擬機器の立て方
コード内のサーバー
ModbusTcpServerにレジスタ定義を渡し、テストの中で起動と停止をします。ポートを固定して外部プロセスを持たないので、再現性が高い方法です。
Web画面付きのシミュレータ
pymodbus.simulatorコマンドで起動し、画面でレジスタ値や異常応答を操作します。壊れたリクエストや応答の模擬もできると説明されています。
Claude Codeに自動で回させるなら、前者が扱いやすい方法です。後者は目視で挙動を確かめるときに役立ちますが、バージョンによって状況が違う点に注意が必要です。最新版のドキュメントではシミュレータの新ページに「WORK IN PROGRESS, do NOT use」と書かれ、v4.0.0で完成させるまでは3.x版を使うよう案内されています。その3.x版のページにも、v3.9.0からv4.0.0にかけて新しい実装に置き換えられる旨の警告があります。
なお、トップページにはシミュレータのWeb画面のポートが既定8081と書かれ、サーバーのクラス説明ではhttp_portの既定が8080と書かれています。どちらを使うかは、pymodbus.simulator --helpの出力と起動ログで確かめるのが確実です。
この記事では、バージョンに左右されにくい前者を中心にします。
手順1: レジスタ定義を持つ模擬機器を作る
Claudeには、まず模擬機器のモジュールだけを書かせます。機器のレジスタマップを仕様書から転記した値にしておくと、テストが実機の仕様に近づきます。
# sim_device.py
from pymodbus.server import ModbusTcpServer
from pymodbus.simulator import DataType, SimData, SimDevice
TEMP_ADDR = 0 # 温度(0.1度単位。215 = 21.5度)
SETPOINT_ADDR = 10 # 設定値(書き込み対象)
def build_device() -> SimDevice:
return SimDevice(
id=1,
simdata=[
SimData(address=TEMP_ADDR, values=[215, 480],
datatype=DataType.REGISTERS),
SimData(address=SETPOINT_ADDR, values=[200],
datatype=DataType.REGISTERS),
],
)
async def start(port: int = 5020) -> ModbusTcpServer:
server = ModbusTcpServer(build_device(), address=("127.0.0.1", port))
await server.serve_forever(background=True)
return serverポイントは3つあります。
SimDataのaddressは、リクエストと同じ0始まりの番号で指定します- 定義していないアドレスは無効扱いになり、読むと異常応答が返ります。「機器にないレジスタを読んだとき」のテストがそのまま作れます
serve_forever(background=True)でバックグラウンド起動し、テストの終了時にshutdown()で止めます
落とし穴
古いデータストアは0始まりで指定すると落ちる
ModbusSequentialDataBlock(0, [...])のような旧来のデータストアは、先頭アドレスを0にすると手元の3.16.1で0 <= address < 65535のTypeErrorになりました。内部でアドレスを1つ引いてSimDataに渡すためです。ドキュメントも、旧データストアはv4.0.0でSimData/SimDeviceに置き換わると述べています。Claudeが古い記事の書き方を持ち出したら、SimDeviceを使うよう指示します。
手順2: 書き込みを既定で拒否するクライアントを作る
次に、アプリが使うクライアントを薄いラッパーにします。ここが、実機を守る本体の層です。
# safe_client.py
from pymodbus.client import AsyncModbusTcpClient
ALLOWED_HOSTS = {"127.0.0.1", "localhost"}
class WriteBlocked(RuntimeError):
pass
class SafeClient:
def __init__(self, host, port=502, *, allow_write=False):
if host not in ALLOWED_HOSTS:
raise ValueError(f"接続先 {host} は許可リストにありません")
self._client = AsyncModbusTcpClient(
host, port=port, timeout=1, retries=1)
self._allow_write = allow_write
async def connect(self):
return await self._client.connect()
def close(self):
self._client.close()
async def read_holding(self, address, count=1, device_id=1):
rr = await self._client.read_holding_registers(
address, count=count, device_id=device_id)
if rr.isError():
raise IOError(f"read 失敗: {rr}")
return rr.registers
async def write_register(self, address, value, device_id=1):
if not self._allow_write:
raise WriteBlocked("allow_write=True のときだけ書き込める")
wr = await self._client.write_register(
address, value, device_id=device_id)
if wr.isError():
raise IOError(f"write 失敗: {wr}")pymodbusのクライアントは、結果を読み書きの種類によらず同じ形で返します。ドキュメントは、アプリ側でisError()を見て判定するよう案内しています。デバイス側の異常は例外にならず、戻り値のisError()で分かります。例外が出るのは主にライブラリ内部の問題です。そのため、isError()を見ずにregistersを読むコードは、機器のエラーを黙って流してしまいます。Claudeにはこの確認を必ず入れさせます。
アドレス指定にはdevice_id=を使います。TCPでは機器のIPアドレスをオブジェクト作成時に決め、論理機器をdevice_id=で選びます。
timeoutとretriesは、既定の3秒・3回のままだと失敗テストが長引きます。テスト用に短くしておくと、ループが速く回ります。非同期クライアントは、応答のない要求をretries回まで再送し、3回続けて応答がないと接続を切る仕様です。この挙動を知らないと、再接続まわりの不具合を追い続けることになります。
手順3: pytestで読み出し・書き込み・異常を確かめる
模擬機器とラッパーができたら、テストをまとめて書かせます。
# test_safe_client.py
import pytest
import pytest_asyncio
from safe_client import SafeClient, WriteBlocked
from sim_device import SETPOINT_ADDR, start
@pytest_asyncio.fixture
async def server():
s = await start(5021)
yield s
await s.shutdown()
@pytest.mark.asyncio
async def test_read(server):
c = SafeClient("127.0.0.1", 5021)
await c.connect()
assert await c.read_holding(0, 2) == [215, 480]
c.close()
@pytest.mark.asyncio
async def test_write_is_blocked_by_default(server):
c = SafeClient("127.0.0.1", 5021)
await c.connect()
with pytest.raises(WriteBlocked):
await c.write_register(SETPOINT_ADDR, 250)
c.close()
@pytest.mark.asyncio
async def test_write_roundtrip_on_simulator(server):
c = SafeClient("127.0.0.1", 5021, allow_write=True)
await c.connect()
await c.write_register(SETPOINT_ADDR, 250)
assert await c.read_holding(SETPOINT_ADDR) == [250]
c.close()
@pytest.mark.asyncio
async def test_illegal_address_is_reported(server):
c = SafeClient("127.0.0.1", 5021)
await c.connect()
with pytest.raises(IOError, match="exception_code=2"):
await c.read_holding(500)
c.close()
def test_real_host_is_rejected():
with pytest.raises(ValueError):
SafeClient("192.168.0.10")python -m pytest -q手元ではこの5件がすべて通りました(pytest-asyncio使用)。存在しないアドレス500を読むと、応答はExceptionResponseで、exception_code=2でした。これはModbusの「不正なデータアドレス」にあたる例外コードです。
書き込みの往復(250を書いて250が読める)は、allow_write=Trueのテストの中だけで行います。実機向けの本番コードは、allow_writeを既定のFalseのままにしておきます。
手順4: Claude Codeに渡す指示と拒否ルール
仕組みができたら、Claude Codeが守る約束をCLAUDE.mdに書きます。
## Modbus通信のルール
- 接続先は `127.0.0.1` の模擬機器(`sim_device.py`)のみ。実機のIPを書かない
- 通信は必ず `SafeClient` 経由。`allow_write=True` はテストの中だけ
- 変更後は `python -m pytest -q` を実行し、結果を読んでから完了と報告する
- 実機への接続は人間が手元で行う。Claudeは実機向けのスクリプトを実行しないあわせて、実機の接続先を書いた設定ファイルと、実機向けスクリプトの実行を拒否ルールで止めておきます。.claude/settings.jsonの例です。
{
"permissions": {
"deny": [
"Read(./.env*)",
"Read(./config/real_device.toml)",
"Bash(python scripts/poll_real_device.py *)"
]
}
}Bash(python scripts/poll_real_device.py *)のように、末尾に空白と*を付けた拒否ルールは、引数付きの呼び出しも、引数なしの呼び出しも止めます。複合コマンド(&&や;でつないだもの)も、どれか1つが一致すれば拒否の対象です。
ただし、Bashの拒否ルールは「Claudeが普段書く形」を止めるもので、境界線そのものではありません。別の書き方で同じプログラムを呼べば、ルールをすり抜けます。このため、実機を守る本体は手順2のコード側の許可リストに置き、拒否ルールとCLAUDE.mdは補助の層と考えます。本当に実機のネットワークから切り離すなら、Claude Codeを動かす端末自体を実機のネットワークにつながない構成が確実です。
模擬機器の異常を作って確かめる
読み出しが通るだけでは、通信コードの検証として不十分です。実機でよく起きる失敗を、模擬機器側で作ります。
通信が失敗するときの切り分け
- Q接続自体が確立できているか?答えが接続できない答えがはい
ホストとポート、サーバーの起動を確かめます。
connect()はタイムアウト内に接続できないと例外を投げます。 - Q`device_id`は機器の設定と一致しているか?答えが一致しない答えがはい
一致しない場合、機器は応答しないことがあります。手元の模擬機器では、定義にない
device_id=9で読むとExceptionResponse(isError()が真)が返りました。実機は応答なしになる場合もあるため、タイムアウトの扱いもテストします。 - Qアドレスは機器のレジスタマップにあるか?答えがない答えがはい
exception_code=2が返ります。0始まりか1始まりかの食い違いは、機器の仕様書と見比べて確かめます。
応答の形式を確認する
isError()が偽なら、registersの値が仕様書の単位と一致しているかを確かめます。
pymodbusのシミュレータのドキュメントには、壊れた応答や、実機では起こしにくい異常応答を作れる、と説明されています。通信エラーを意図的に起こしたい場合は、Web画面付きのシミュレータも使えます。
つまずきやすい点
落とし穴
アドレスの0始まりと1始まりを混同する
機器の仕様書は「40001」のように1始まりの番号で書かれていることがあります。pymodbusのリクエストは0始まりで、模擬機器も同じ番号で作ると、仕様書とずれます。Claudeには、仕様書の番号をそのまま使うのでなく、変換した値であることをコードのコメントに残させます。
落とし穴
Claudeが書いた模擬機器が、実機の癖を再現していない
テストが全部通っても、模擬機器が実機と同じ振る舞いをしている保証はありません。レジスタのバイト順(エンディアン)、32ビット値が2レジスタにまたがる順序、応答の遅さなどは、実機の仕様書か手元の測定で確かめた内容を模擬機器に反映します。実機に最初につなぐ日は、読み出しだけで挙動を観察する段階を設けると安全です。
完了条件をテストの合格にそろえたいときは、agent hookでテストが通るまで止めない設定が使えます。
まとめ
Modbusの通信コードは、実機の代わりにSimDeviceで作る模擬機器と、書き込みを既定で拒否するクライアントのラッパーを組み合わせて検証します。Claude Codeに与える検証コマンドはpython -m pytest -qの1本で足り、実機の接続先はコードの許可リストと拒否ルールで二重に閉じられます。実機での初回接続は読み出しだけから始め、書き込みの許可は人が手元で判断する運用が、設備を動かすコードには合っています。
関連する記事
Claude Code をもっと見る →Claude Codeとは — できること・料金・始め方と使い方の全体像
Claude CodeでScrapyのスクレイパーを作る — robots.txt遵守とcontracts検証
Claude CodeでROS 2パッケージを作る — colcon buildとlaunch_testの反復
Claude CodeでPySparkのジョブを書く — local[2]のpytestで検証する手順
Claude CodeでPower Platformのソリューションを編集する手順 — pac solution
Claude CodeでOpenTofuを使う — validateとplanだけ許可する権限設計