Jetson Orin Nano で動かしている物体検出の結果を、キーエンスのPLCへ渡して電磁弁を動かしています。
PLC側にライブラリを入れる必要はなく、Python標準の socket だけで書けました。ただ、動くまでにポート番号で丸一日溶かしたので、その辺りも含めて残します。
構成
| 型式・値 | |
|---|---|
| 判定側 | Jetson Orin Nano(YOLOv8 + TensorRT) |
| PLC | KV-N24DT(トランジスタ出力・シンク) |
| 通信ユニット | KV-NC1EP(EtherNet/IP) |
| 接続 | TCP 192.168.0.10:8501(上位リンク) |
| PLC側の受け | DM100(クラスコード)、DM101(開放時間ms) |
Jetson から渡すのは 「種類」と「バルブを開ける時間」の2ワードだけです。遅延を数えるのも、出力を切るのもPLC側でやります。
先に、ポートの話
ここが最大の落とし穴でした。同じPLCに、用途の違う入口が3つ空いています。
| ポート | 用途 |
|---|---|
| 8500 | KV STUDIO からのプログラム転送 |
| 8501 | 上位リンク(外部機器がデバイスを読み書き) |
| 8502 | タッチパネル(VT)専用 |
番号を1つ間違えると、ping は通る、TCP接続もできる、それなのに応答だけが返ってきません。
外から見て切り分けにくいのもここです。nc -z でポートが開いていることを確認しても、プロトコルが違えば会話は成立しません。上位リンクでデバイスを読み書きするなら 8501 です。
# 疎通だけなら通ってしまう。これで安心しないこと
nc -zv 192.168.0.10 8501
最小コード
改行コードは \r です。\n ではありません。
import socket
class KeyenceUplink:
def __init__(self, host="192.168.0.10", port=8501, timeout=1.0):
self.sock = socket.create_connection((host, port), timeout=timeout)
def cmd(self, line: str) -> str:
self.sock.sendall((line + "\r").encode("ascii"))
return self.sock.recv(256).decode("ascii", "ignore").strip()
def close(self):
self.sock.close()
plc = KeyenceUplink()
print(plc.cmd("RD DM100")) # 読み出し → "0" など
print(plc.cmd("WR DM101 250")) # 書き込み → "OK"
-
RD <デバイス>で読み出し、値が返る -
WR <デバイス> <値>で書き込み、OKが返る - 複数ワードをまとめるなら
WRS DM100.U 2 1 250のように書ける
応答が E0 や E1 で返る場合は、コマンドかデバイス指定の側の間違いです。無応答で固まる場合は、たいていポートが違います。
書き込む順番が大事
クラスコードを書いた瞬間にPLCが動き出す作りにしているので、開放時間を先に書きます。
def send(plc, class_code: int, duration_ms: int):
plc.cmd(f"WR DM101 {duration_ms}") # 先に開放時間
plc.cmd(f"WR DM102 0") # 上位ワードをクリア
plc.cmd(f"WR DM100 {class_code}") # 最後にクラスコード(これがトリガ)
逆にすると、前回の開放時間のままバルブが開きます。1回だけ変な動きをして再現しない、という一番厄介な形のバグになります。
PLC側は DM100 ≠ 0 を見つけたら1スキャンだけ受信フラグを立て、クラス別の固定遅延タイマを起動して、満了したら出力をONにします。処理が終わったら DM100 を0に戻します。
判定側が落ちた時に、出力を残さない
AI側が落ちても電磁弁が開きっぱなしにならないように、切るのはPLC側の仕事にしています。
出力をONにするのと同時にタイマを起動して、満了したら自分でOFFにする。Jetsonから「閉じろ」という指示は送りません。送信が途絶えたら、PLCは何もしないだけです。
この分け方にしておくと、Jetsonを再起動しても、LANケーブルを抜いても、装置側は安全側に倒れます。
送信はワーカスレッドに逃がす
推論ループの中で直接 sendall すると、PLC側の応答待ちでフレーム処理が止まります。キューに積んで別スレッドで送ると、検出の周期に影響しません。
import queue, threading
class Sender:
def __init__(self, plc):
self.plc, self.q = plc, queue.Queue()
threading.Thread(target=self._loop, daemon=True).start()
def _loop(self):
while True:
job = self.q.get()
try:
job()
except OSError as e:
print("PLC送信失敗:", e)
finally:
self.q.task_done()
def post(self, fn):
self.q.put(fn)
例外を握りつぶさずログに出しておくと、断線した時に「いつから届いていないか」が分かります。
ハマったところ
- 8501 以外に繋いでいた — pingもTCP接続も通るので気づきにくい。無応答なら真っ先にポートを疑う
-
改行を
\nにしていた —\rでないと応答が返らない - 書き込む順番が逆だった — 先にトリガを書くと、前回の値で動く
- タッチパネルがポートを専有していた — VT用は8502。ここを外部機器で使わない
- 設定値0のタイマ — PLC側でタイマのプリセットが0だと、即タイムアップして一瞬で閉じる
- 推論ループ内で同期送信していた — FPSが落ちる。キューへ逃がす
まとめ
- 上位リンクは標準
socketだけで書ける。ライブラリ不要 - ポートは 8501。8500はプログラム転送、8502はタッチパネル
- 改行は
\r - 渡すのは最小限(種類と時間)。判断はAI側、動作と安全はPLC側
- 出力を切るのはPLCの仕事にしておくと、AI側が落ちても装置が安全側に倒れる
この構成で選別装置を1台組んでいます。盤の中身や結線、I/O割付まで含めた話は note に書きました。