OPC UA 後編 ―― 3つを1つのゲートウェイに合流させる(5/5)
- opcua
- modbus
- canopen
- python
- iot
最終回です。ここまでで作ったものを繋ぎます。
- 第1回 Modbus TCP の機器
- 第3回 CANopen の仮想サーボ(CiA 402)
- 第4回 OPC UA サーバ
この3つを1台のゲートウェイが束ねます。OPC UA のクライアントから目標位置を書き込むと、それが CAN の RPDO まで貫通して、サーボが動きます。
ここまでと同じく、ハードウェアがなくてもPCの中だけで再現できます。
この記事で作るもの
$ ./.venv/bin/python 03_opcua/gw_client.py
browse OK. namespace idx = 2
Temperature 23.5 StatusCode = Good
ActualPosition 0 StatusCode = Good
Statusword 0x0237 StatusCode = Good
--> OPC UA から TargetPosition = 10000 を書く
ActualPosition = 8031 Statusword = 0x0237
ActualPosition = 9828 Statusword = 0x0237
ActualPosition = 9987 Statusword = 0x0237
ActualPosition = 9999 Statusword = 0x0237
ActualPosition = 10000 Statusword = 0x0237
ActualPosition = 10000 Statusword = 0x0237
--> 最終状態
Temperature 23.5 StatusCode = Good
ActualPosition 10000 StatusCode = Good
Statusword 0x0237 StatusCode = Good
OPC UA から書いた数字が AxisX/TargetPosition という名前の変数を通って CAN の RPDO まで届き、サーボが追従しています。番地でもオブジェクトインデックスでもなく、名前で書いています。
Modbus デバイスだけを止めてから、同じクライアントをもう一度動かすと、最初の3行がこう変わります。
Temperature None StatusCode = BadCommunicationError
ActualPosition 10000 StatusCode = Good
Statusword 0x0237 StatusCode = Good
機器が1つ落ちても他は生き続け、落ちたものだけが「この値は信用できない」と自己申告します。Modbus と CANopen にも異常を知らせる仕組みはありますが(CANopen なら EMCY や SDO abort)、値そのものに品質を付けて同じ形で運ぶ場所はありません。
5回ぶんの部品が1枚にどう収まるかを先に示します。
graph TB
modbus["Modbus 機器<br/>(温度センサ)"]
servo["CANopen サーボ<br/>(CiA 402)"]
subgraph gw["ゲートウェイ / OPC UA アドレス空間"]
tsensor["Plant/TemperatureSensor"]
axis["Plant/AxisX<br/>ActualPosition / TargetPosition"]
end
client["OPC UA クライアント"]
modbus -->|温度を取り込む| tsensor
servo -->|現在位置を取り込む| axis
tsensor --> client
axis --> client
client -->|TargetPosition を書く| axis
axis -->|RPDO で指令| servo
linkStyle 4,5 stroke:#c0392b,stroke-width:2px,color:#c0392b
使うもの(すべてオープンソース・費用ゼロ・ハード不要)
前4回で入れたものだけです。
| ライセンス | ||
|---|---|---|
| pymodbus | 3.14.0 | BSD-3-Clause |
| python-can | 4.6.1 | LGPL-3.0-only |
| asyncua | 2.0.1 | LGPL-3.0-or-later |
使うファイルは、第1回と同じリポジトリの 01_modbus/device.py(温度計を模した Modbus スレーブ)、02_canopen/servo.py(第3回の仮想サーボ)、03_opcua/gateway.py(この回の主役)、03_opcua/gw_client.py(動作確認用)です。
① 構成 — asyncio 2本 + スレッド1本
3つのライブラリを素直に並べて書いてみたら、動きませんでした。
| ライブラリ | 非同期対応 |
|---|---|
asyncua |
async ネイティブ |
pymodbus |
AsyncModbusTcpClient があり await で書ける |
python-can |
✕ bus.recv() はブロッキング |
python-can にも Notifier のような仕組みはありますが、ここで使っている bus.recv() はブロッキングです。イベントループの中で呼ぶと、OPC UA サーバも Modbus クライアントも一緒に止まります。
したがって構成はこうなります。
graph TB
subgraph loop["asyncio イベントループ"]
opcua["OPC UA サーバ<br/>(asyncua)"]
modbus["Modbus クライアント<br/>(pymodbus / await)"]
end
shared["threading.Lock<br/>+ 共有 dict"]
subgraph thread["別スレッド"]
can["CAN マスタ<br/>bus.recv() ループ<br/>SYNC / PDO の周期処理"]
end
opcua <--> shared
modbus <--> shared
shared <--> can
CAN のスレッドは 10ms 周期で回り続け、最新の状態を共有 dict に書きます。OPC UA 側はそれを読むだけです。書き込みは逆向きに dict へ置いて、CAN スレッドが次の周期で拾います。
② マッピングの設計判断
ゲートウェイを作るというのは、現場の番地を、意味のある名前に翻訳する作業です。コードの行数よりも、この対応表をどう決めるかのほうが効いてきます。
アドレス空間の設計
Objects
└ Plant (Object)
├ TemperatureSensor (Object) ← Modbus 機器
│ ├ Temperature (Variable, Double)
│ └ Setpoint (Variable, Double, writable)
└ AxisX (Object) ← CANopen サーボ
├ ActualPosition (Variable, Int32)
├ TargetPosition (Variable, Int32, writable)
└ Statusword (Variable, UInt16)
この時点で、第1回の 40001 番地も、第3回の 0x6064 も、消えています。外から見えるのは AxisX/ActualPosition という名前です。
書ける変数が2つあり、どちらも逆向きに現場へ抜けます。TargetPosition は CAN スレッド経由で RPDO になり、Setpoint は Modbus の保持レジスタ0(目標温度 [0.1 degC])への書き込みになります。読み出しだけなら値を運ぶ層で済みますが、書き込みを通すとゲートウェイは両方向の翻訳器になります。Setpoint 側は毎周期ではなく、値が変わったときだけ書きに行きます。
決めなければならないこと
- 32bit 値のワード順
第1回でやり残した宿題がここに戻ってきます。Modbus の32bit値には ABCD / CDAB / BADC / DCBA の4通りがあり、仕様は沈黙していました。
ゲートウェイを書く人は、どれかに決めなければ先に進めません。そして決めた根拠は、コードのコメントか設定ファイルにしか残りません。
# 対象機器(○○社 △△)のマニュアル p.42 より、32bit は下位ワード先(CDAB)
value = (regs[1] << 16) | regs[0]
「意味が仕様書の中にしかない」という第1回の結論は、ここで具体的な作業として現れます。
- NodeId の付け方
数値 ID を機械的に振るか、文字列で Plant.AxisX.ActualPosition のようにするか。今回のコードは、この決定をしていません。add_object(idx, "Plant") や add_variable(idx, "Temperature", ...) と名前空間インデックスだけを渡す書き方をすると、asyncua が作成順の連番を割り当てます。実際に繋いで NodeId を読むとこうなっています。
2:Plant ns=2;i=1
2:TemperatureSensor ns=2;i=2
2:Temperature ns=2;i=3
2:Setpoint ns=2;i=4
2:AxisX ns=2;i=5
2:ActualPosition ns=2;i=6
2:TargetPosition ns=2;i=7
2:Statusword ns=2;i=8
センサをもう1台足して並び順が変われば、後ろの番号はずれます。NodeId を控えて直接読みに行くクライアントは、その時点で別の変数を読みます。gw_client.py が NodeId ではなく get_child(["0:Objects", f"{idx}:Plant", ...]) とブラウズ名で辿っているのは、そこを踏まないためです。
NodeId を外に配る前提なら、ua.NodeId("Plant.AxisX.ActualPosition", idx) を作って add_variable() の第1引数に渡し、文字列 ID を自分で固定します。あるいは NamespaceUri とブラウズパスから毎回解決させます。ns=2 の 2 もサーバ起動時に割り振られる短縮番号にすぎず、恒久的な識別子は NamespaceUri の側です(第4回に書いたとおりです)。このデモを自動採番のまま進めたのは楽だからです。構成が固まった機器で同じことをすると、後で困ります。
- StatusCode をどう伝播させるか
この回の要点です。
| 現場で起きたこと | OPC UA での表現 |
|---|---|
| 正常に読めた | Good |
| Modbus が無応答(タイムアウト) | BadCommunicationError |
| Modbus が例外応答 02(アドレスが範囲外) | BadConfigurationError |
| サーボが Fault 状態 | BadDeviceFailure または Good + 別変数で通知 |
| 最後に読めた値を保持中 | UncertainLastUsableValue |
例外応答 02 に BadOutOfRange を当てたくなりますが、あれは「値が範囲外」の意味です。Modbus の 02 は「アドレスが不正」、つまりゲートウェイの設定が実機と合っていないという話です。だからBadConfigurationError の方が実態に近いと思います。この対応付けを決めるのがゲートウェイを書く人の仕事で、正解は規格には書いてありません。
表の最後の行、UncertainLastUsableValue は今回のコードでは使っていません。CAN の TPDO を5周期続けて取りこぼしたら、ActualPosition も Statusword も BadCommunicationError にしています。古い値を Uncertain 付きで出し続けるか、Bad にして値を消すかは選べるところで、古い値を出し続けると、動いている軸が止まって見えますし、切れたリンクの向こうのデータが生きているようにも見えます。それを避けたかったので後者にしました。
ゲートウェイのループのうち、Modbus 側を要点だけにするとこうなります。
# 現場の番地(この対応表がゲートウェイの本体)
MB_IR_TEMPERATURE = 0 # 入力レジスタ0 = 温度 [0.1 degC]
try:
# 時間を切らないと、繋がるが黙っている相手や再接続待ちでここが返らず、
# CAN 側の更新まで巻き込んで止まる
rr = await asyncio.wait_for(
modbus.read_input_registers(
MB_IR_TEMPERATURE, count=1, device_id=MODBUS_DEVICE_ID),
timeout=MODBUS_DEADLINE)
if rr.isError(): # 例外応答 = 相手は生きているが、この要求は通らない
await set_bad(n_temp, ua.StatusCodes.BadConfigurationError)
else:
await n_temp.write_value(rr.registers[0] / 10.0)
except Exception: # 無応答 = 相手が見えない
await set_bad(n_temp, ua.StatusCodes.BadCommunicationError)
async def set_bad(node, code):
# Value を省くと Null バリアントになる。0 を書くと
# 「本当に 0 だった」と区別がつかなくなるので、値は載せない
await node.write_value(ua.DataValue(StatusCode=ua.StatusCode(code)))
読みに行く先は保持レジスタではなく入力レジスタです。第1回で分けた4つの領域のうち、温度は読み取り専用の入力レジスタ0 にあり、単位は 0.1 degC です。だから生値を10で割ってから Double として書きます。デバイス側の 235 が OPC UA では 23.5 になるのは、この1行です。単位も小数点も線の上には流れていないので、この / 10.0 もゲートウェイを書く人が決めています。
書き込みの側にも同じことが要る
ここまでは読み出しの話です。Setpoint のように OPC UA から現場へ書く経路にも、同じ手当てが要ります。書けなかったのに Good のままだと、読み出し側でやったことが台無しになります。
if pending is not None and pending != last_setpoint:
try:
rq = await asyncio.wait_for(
modbus.write_register(MB_HR_SETPOINT, int(pending * 10),
device_id=MODBUS_DEVICE_ID),
timeout=MODBUS_DEADLINE)
# 例外応答は送出されず戻り値で返る。見ないと失敗が消える
code = ua.StatusCodes.BadConfigurationError if rq.isError() else None
except Exception:
code = ua.StatusCodes.BadCommunicationError
if code is None:
last_setpoint = pending # 書けたときだけ確定させる
await n_setpoint.write_value(pending)
pending = None
else:
await set_bad(n_setpoint, code) # pending は残るので次の周期で再送
引っかかったのは3つです。1つ目は、write_register の失敗が例外で来るとは限らないこと。Modbus の例外応答は戻り値として返るので、rq.isError() を見ないと素通りします。2つ目は、書く前に last_setpoint を更新してしまうと、失敗した指令が二度と再送されないこと。3つ目は、set_bad を打つとノードの値が消えるので、指令はノードではなく手元(pending)に持っておく必要があることです。
この形にすると、現場が落ちている間に書かれた値は Bad として見え、現場が戻ったときに送り直されます。実際に Modbus デバイスを止めて 18.0 を書き、デバイスを戻すと、保持レジスタが 180 になって Setpoint が Good に戻ります。
もうひとつ、起動直後の落とし穴があります。OPC UA のノードは初期値 0.0 で作られるので、素直に「値が変わったら書く」と実装すると、ゲートウェイを起動しただけで現場の目標温度が 0 に書き換わります。起動時に現場の値を読んでから追従を始める必要があります。翻訳する側が現場の設定を壊しに行く、というのは、ゲートウェイを名乗る以上いちばんやってはいけないことです。
第1回で「例外応答とタイムアウトは別物」と書きました。その区別が、ここで外の世界に伝わる形になります。
③ 動かす
3つのプロセスを、この順に起動します。
# ターミナル1: Modbus 機器(起動したまま)
./.venv/bin/python 01_modbus/device.py
# ターミナル2: CANopen サーボ + ゲートウェイ + OPC UA サーバ(起動したまま)
./.venv/bin/python 03_opcua/gateway.py
# ターミナル3: OPC UA クライアント
./.venv/bin/python 03_opcua/gw_client.py
最後にターミナル1だけを Ctrl-C で止めて、もう一度ターミナル3を実行すると、Temperature の StatusCode が BadCommunicationError に変わり、サーボ側は Good のままなのが見えます。
ゲートウェイ本体 gateway.py は285行、空行とコメントを除くと214行です。記事に載せたのは要点だけです。動かしてから gateway.py の該当箇所を読むと、前後が追いやすくなります。
セキュリティは有効にしていない
ここまで SecurityPolicy = None のまま動かしてきました。起動時に asyncua が暗号化ポリシーの無い旨を警告しますが、そのままです。サーバは 127.0.0.1 だけで待ち受けていて外から繋ぐ想定がないこと、証明書の準備がこの回の本筋から外れることが理由で、この制限はリポジトリの README にも「正直な境界」として書いてあります。
実運用で閉じるなら、前回の3つの軸それぞれに設定が要ります。MessageSecurityMode を SignAndEncrypt に、SecurityPolicy を Basic256Sha256 以降にし、UserIdentityToken を Anonymous 以外にします。asyncua での手順は、自己署名証明書と秘密鍵を作り、サーバに読み込ませ、受け付ける SecurityPolicy を宣言し、クライアント側にはその証明書を信頼させて同じポリシーを指定する、という順です。ここは私が動かしていないので、手順までにしておきます。
付録: C で書くなら(open62541)
組込み機器に載せるなら C です。open62541(MPL-2.0)はリリースに単一ファイルにまとめた open62541.c が同梱されているので、cmake なしで gcc -std=c99 -O0 -o server server.c open62541.c -lpthread だけでビルドできます。
ハマりどころ2つ
- 配布ビルドは mbedTLS 有効なので、
open62541.hのUA_ENABLE_ENCRYPTION_MBEDTLSを無効化しないとmbedtls/md.hが無いと言われます -O0を明示しておくのが無難です。32万行の単一ファイルなので、手元では-O1にすると2分経ってもコンパイルが終わりませんでした。-O0なら約40秒でした
手元で試した範囲では、asyncua から open62541 サーバに書き込むと BadWriteNotSupported が返り、タイムスタンプを外すと通りました。
④ シリーズのまとめ

5回で登った全体像です。下から現場の機器(第1回)、フィールドバス Modbus / CANopen(第1~3回)、OPC UA サーバとしてのゲートウェイ(第4~5回)、その上に IT / クラウドのクライアントが載ります。
ここまで全部、費用ゼロ・ハードゼロで動いた
5回で使ったものを並べます。
| ライセンス | 用途 | |
|---|---|---|
| pymodbus 3.14.0 | BSD-3-Clause | Modbus TCP / RTU |
| python-can 4.6.1 | LGPL-3.0-only | 仮想CANバス |
| asyncua 2.0.1 | LGPL-3.0-or-later | OPC UA |
| matplotlib 3.10.7 | PSF-based | グラフ |
| libmodbus / open62541 | LGPL-2.1+ / MPL-2.0 | C 実装(付録) |
pip install は4つ。行数は、空行とコメントを除いて gateway.py が214行、CiA 402 の仮想サーボ servo.py が197行、その状態機械とビット定義の cia402.py が105行です。この規模で、Modbus と CANopen を OPC UA に束ねるものが動きます。
使ったのはすべてオープンソースの実装です。仕様書も、Modbus と OPC UA は完全に無料、CANopen も通信プロファイル(CiA 301)は無料でした。
壁の向こうにあったのは CiA 402(サーボのプロファイル)と EtherCAT の仕様ですが、性質が違います。CiA 402 は有料の会員限定。EtherCAT の ETG は入会自体は無料で、法人単位の登録をすれば読めます。
3つの層で、何が分かって何が分からないか
| Modbus | CANopen | OPC UA | |
|---|---|---|---|
| 値が読めるか | ○ | ○ | ○ |
| 型が分かるか | ✕ | △(EDSファイル) | ○(実行時) |
| 単位・意味が分かるか | ✕ | △(EDSファイル) | △(Companion Spec 次第) |
| 何がいるか分かるか | ✕ | ✕ | ○(ブラウズ) |
| 値が信用できるか分かるか | ✕ | ✕ | ○(StatusCode) |
| 誰が書いてよいか | ✕ | ✕ | ○ |
| 周期制御に使えるか | △ | ○ | ✕(Client/Server) |
Companion Specification — まだ人間が決めている部分
OPC UA サーバに繋げば構造は分かります。AxisX の下に ActualPosition という Int32 の Variable がある、というところまでは実行時に読めます。ただし、それが何を意味するかは分かりません。単位はパルスなのかミリメートルなのか、原点はどこか、増える向きはどちらか。
これを機械同士で合意するのが Companion Specification です(PackML、Robotics、Machine Tool、PLCopen など、業界団体が作る情報モデルの取り決め)。素の OPC UA サーバ同士は「ブラウズはできるが意味は分からない」状態で、相互運用性が成り立つのは、このレベルの取り決めがあってからです。
触らなかったもの
- EtherCAT / PROFINET / CC-Link IE — 仕様が団体の会員限定で引用できないこと、ビルドに C++ 環境が要ることから外しました。なお EtherCAT は「PCの中では動かせない」わけではありません(KickCAT に ESC のソフトウェアエミュレータがあります)。第3回に書いたとおりです
- OPC UA PubSub / OPC UA FX — 「OPC UA over TSN」という単独の規格は存在しません。実体は PubSub(Part 14)を Ethernet/UDP に載せ、その下を IEEE 802.1 TSN で時間保証する組み合わせで、名前は OPC UA FX です。2026年時点でも一部の Part は Release Candidate 段階にあり、普及の途上です
次に触るなら
- もっと現場に近づきたいなら EtherCAT の評価ボードを1枚買う。CiA 402 の知識はそのまま使えます
- もっと上に行きたいなら自分の業界の Companion Specification を読む。この回で人間が決めていた対応表が、どこまで既製品になっているか分かります
- リアルタイム性を追いたいなら OPC UA PubSub と TSN
コード
この連載で動かすコードは、5回ぶんまとめて GitHub に置いてあります。今回の 03_opcua/ のほかに、Modbus と CANopen も同じリポジトリにあります。
https://github.com/logicia32/industrial-networks-lab
5回、お付き合いありがとうございました。
Modbus も CANopen も OPC UA も、手元のPC1台で、フレームの1バイト目から追えます。
この記事は Zenn に初出したものを加筆・補足したものです ── Zenn の元記事を見る