OPC UA 前編 ―― 情報モデルとサーバを立てる(4/5)
- opcua
- python
- 産業用通信
- iot
- asyncio
Modbus では、読めたのは数字の列でした。意味はベンダのPDFの中にありました。
CANopen では、番号に名前と型が付きました。ただしその対応表(EDS)はファイルで、事前にPCにコピーしておくものでした。
今回の OPC UA は、そこをもう一歩進めます。サーバに繋いで「あなたは何を持っていますか」と聞くと、答えが返ってきます。
この記事で作るもの
PC の中に OPC UA サーバを立て、クライアントから接続してアドレス空間を歩き回ります。
$ ./.venv/bin/python 03_opcua/client.py
Connected to opc.tcp://127.0.0.1:4840/freeopcua/server/
namespace http://example.com/industrial-demo/ -> ns=2
Objects
└ Locations
└ Server
└ Aliases
└ MyDevice
MyDevice
├ Temperature = 23.5 (Double ) StatusCode: Good
├ Pressure = 101.3 (Double ) StatusCode: Good
├ MotorEnabled = False (Boolean ) StatusCode: Good
--> Temperature の型定義を辿る
BaseDataVariableType (ns=0;i=63)
Temperature と Pressure はサーバが毎秒書き換えるので、上は起動直後の値です。実行するたびに変わります。
最後の行がこの回の要点です。変数の「型」そのものも、サーバの中のノードとして置いてあり、実行時に辿れます。
Objects の下には Locations / Server / Aliases も並んでいます。これは規格が最初から用意しているノードで、こちらが作ったものではありません。「サーバに何があるか聞く」のはこういうことです。
使うもの(すべてオープンソース・費用ゼロ・ハード不要)
| ライセンス | ||
|---|---|---|
| asyncua (opcua-asyncio) | 2.0.1 | LGPL-3.0-or-later |
./.venv/bin/pip install asyncua==2.0.1
① なぜフィールドバスの上に、もう一層要るのか
OPC の歴史(短く)
1996年、Windows の DCOM を使った OPC(OLE for Process Control)が生まれました。ベンダごとにバラバラだった PLC のドライバを共通のインターフェースに統一するためです。これは普及しましたが、DCOM は Windows 専用でファイアウォールを通せず、設定が難解で、ネットワーク越しでは不安定でした。
OPC UA(Unified Architecture、後に IEC 62541 として国際規格化)は、そこを作り直したものです。DCOM をやめて独自のバイナリプロトコルにし、OS 非依存にし、セキュリティを最初から仕様に入れました。仕様の全文は OPC Foundation が HTML で無料・登録不要で公開しています。
フィールドバスに足りなかった3つ
前回までで、CANopen のサーボが制御盤の中で動くところまで来ました。それを外から使おうとすると、3つのものが無いことに気づきます。
- 誰が読んでよくて、誰が書いてよいのか — CANopen にも Modbus にも認証・認可はありません
- この値は何なのか — 0x6064 が「現在位置」で単位が何かは、EDS ファイルかPDFの中
- そこに何がいるのか — ネットワークを繋いだだけでは、何台何がいるか分かりません
OPC UA はこの3つを正面から扱います。その代わり、Client/Server ではフィールドバスのような周期制御はしません。役割が違います。
② 仕組み
アドレス空間は「木」ではなく「グラフ」
OPC UA のサーバはノードの集まりで、ノード同士は参照(Reference)で繋がっています。
graph LR
Objects(["Objects"]) -->|Organizes| MyDevice(["MyDevice"])
MyDevice -->|HasComponent| Temperature(["Temperature"])
Temperature -->|HasTypeDefinition| BaseType["BaseDataVariableType"]
class BaseType typedef
classDef typedef fill:#e8f0ff,stroke:#3060c0,stroke-width:2px
アドレス空間は木ではなく有向グラフです。同じノードが複数の親を持てるので、「このノードへのパス」は一意に決まりません。フォルダ構造だと思って読み進めると、後で辻褄が合わなくなります。
ノードは8種類しかない
Object(入れ物)、Variable(値を持つもの)、Method(呼べる関数)、ObjectType / VariableType(それぞれの型)、ReferenceType(参照の種類そのもの)、DataType、View の8つです。
面白いのはここからで、型定義もノードですし、参照の種類(HasComponent や Organizes)もノードです。「このサーバはどんな型を扱えるのか」「どんな種類の関係があるのか」を、実行時にブラウズして調べられます。
Web の言葉で言えば、JSONスキーマがデータと同じエンドポイントに置いてあって、実行時に取りに行ける格好です。似ているのは「意味をどこに置くか」の一点だけですが。
NodeId — ハードコードしないほうがよい
ノードの識別子は2つ組です。
ns=2;i=1001
│ └ Identifier(数値・文字列・GUID・バイト列)
└ NamespaceIndex
値は「値」ではない — DataValue
ここが、前2回との大きな差です。OPC UA の変数を読むと、返ってくるのは数値ではなく、いくつかのフィールドの組です。実務でまず見るのは次の4つです。
| Value | 値そのもの |
| StatusCode | この値は信用できるか(Good / BadCommunicationError / Uncertain… など) |
| SourceTimestamp | 現場でその値が確定した時刻 |
| ServerTimestamp | サーバがそれを持った時刻 |
Modbus には「この値は今信用できない」を表現する手段がありません。例外応答は「この要求は処理できない」というやりとりへの返事であって、値に付随する属性ではないからです。読めた数字が生きているセンサの値なのか、最後に読めた値の残りなのか、区別する場所がありません。
この4つ組が、OPC UA を挟んで得られる実利のひとつだと思います。次回のゲートウェイで戻ってきます。
セキュリティは3つの軸(混同されがち)
私も最初はこの3つを混ぜて覚えていたので、分けて書いておきます。
| 軸 | 選択肢 | 何を決めるか |
|---|---|---|
| MessageSecurityMode | None / Sign / SignAndEncrypt | 何を保護するか |
| SecurityPolicy | Basic256Sha256 など(URI) | どのアルゴリズムで |
| UserIdentityToken | Anonymous / UserName / X509 | 誰として |

セキュリティは直交する3つの軸で決まる。MessageSecurityMode(何を保護するか)、SecurityPolicy(どのアルゴリズムで)、UserIdentityToken(誰として)の3つです。
上2つは通信路(SecureChannel)をどう守るかの設定で、その過程でサーバとクライアントのアプリケーション証明書が使われます。3つ目だけがユーザーの認証です。別のレイヤです。
「証明書を入れたのに誰でも繋がってしまう」という事故は、この混同から起きます。証明書で守られるのは通信路と相手アプリの身元であって、「誰がログインしているか」ではありません。
なお Basic128Rsa15 と Basic256 は SHA-1 に依存しているため 1.04 で非推奨です。新規に選ぶなら Basic256Sha256 以降になります。
SecurityPolicy = None かつ Anonymous も、仕様上は正当な設定です。この記事のサーバもその構成です。安全がデフォルトなのではなく、安全のための場所が標準の中に用意されている、というのが正確なところです。
Subscription はサーバプッシュではない
値の変化を受け取る仕組みを Subscription と呼びます。「サーバがプッシュしてくる」と説明されがちですが、実際は逆です。
クライアントが Publish 要求を先に複数キューしておき、サーバは変化が起きたときに、そのキューされた要求へ応答を返します。ロングポーリングと同じ形です。通信の向きは常にクライアントからサーバなので、NAT やファイアウォールを越えられます。
Client/Server と PubSub
OPC UA には配送方式が2つあります。
- Client/Server — 1対1。ブラウズ・読み書き・Subscription。今回扱うのはこちら
- PubSub — 1対多。UDP マルチキャスト(ブローカなし)または MQTT / AMQP(ブローカ経由)
③ 手を動かす
サーバを立てる
03_opcua/server.py:
import asyncio
from asyncua import Server
ENDPOINT = "opc.tcp://127.0.0.1:4840/freeopcua/server/"
NAMESPACE = "http://example.com/industrial-demo/"
async def main():
server = Server()
await server.init()
server.set_endpoint(ENDPOINT)
# 自分の名前空間を登録する。恒久的な識別子はこの URI のほうで、
# 返ってくる idx はこのサーバのこの時点での短縮番号にすぎない。
idx = await server.register_namespace(NAMESPACE)
device = await server.nodes.objects.add_object(idx, "MyDevice")
temperature = await device.add_variable(idx, "Temperature", 23.5)
pressure = await device.add_variable(idx, "Pressure", 101.3)
motor = await device.add_variable(idx, "MotorEnabled", False)
await motor.set_writable()
async with server:
t = 0.0
while True:
await asyncio.sleep(1.0)
t += 1.0
# 値が動いていたほうが、クライアント側で見て分かりやすい
await temperature.write_value(round(23.5 + 0.5 * (t % 4 - 1.5), 2))
await pressure.write_value(round(101.3 + 0.1 * (t % 3 - 1), 2))
asyncio.run(main()) # これを忘れると、実行しても何も起きずに終了します
このサーバは止めるまで動き続けます(Ctrl-C)。クライアントは別のターミナルから実行します。
./.venv/bin/python 03_opcua/server.py # ターミナル1(起動したまま)
./.venv/bin/python 03_opcua/client.py # ターミナル2
クライアントから歩く
03_opcua/client.py:
import asyncio
from asyncua import Client
ENDPOINT = "opc.tcp://127.0.0.1:4840/freeopcua/server/"
NAMESPACE = "http://example.com/industrial-demo/"
async def main():
async with Client(ENDPOINT) as client:
# URI から index を引く。ns=2 のような番号を直書きしない。
idx = await client.get_namespace_index(NAMESPACE)
for child in await client.nodes.objects.get_children():
print((await child.read_browse_name()).Name)
device = await client.nodes.root.get_child(["0:Objects", f"{idx}:MyDevice"])
for var in await device.get_children():
name = await var.read_browse_name()
# 値ではなく DataValue を読む。値・信頼度・時刻がまとめて返る。
dv = await var.read_data_value(raise_on_bad_status=False)
print(name.Name, dv.Value.Value, dv.StatusCode.name)
# 型定義もアドレス空間の中のノードなので、実行時に辿れる
temp = await device.get_child([f"{idx}:Temperature"])
type_id = await temp.read_type_definition()
type_node = client.get_node(type_id) # get_node は同期メソッド
print((await type_node.read_browse_name()).Name)
asyncio.run(main())
read_value()ではなくread_data_value()— 値だけでなく StatusCode と時刻が付いてきますraise_on_bad_status=False— これを付けないと、値が信用できないときに例外になって StatusCode を見られませんclient.get_node()はawaitしません — Node オブジェクトを同期的に返すメソッドです。awaitを付けるとTypeError: object Node can't be used in 'await' expressionになります
コード
この連載で動かすコードは、5回ぶんまとめて GitHub に置いてあります。今回の 03_opcua/ のほか、Modbus と CANopen も同じリポジトリです。
https://github.com/logicia32/industrial-networks-lab
④ 次回
これで、サーバに繋いで「何を持っているか」を聞けるようになりました。Modbus のときのように、PDFを開いて番地を探す必要はありません。
次回は最終回です。このシリーズで作った3つを、1つに繋ぎます。
- 第1回の Modbus 機器
- 第3回の CANopen サーボ
- 今回の OPC UA サーバ
現場の2つのプロトコルを1台のゲートウェイが束ね、OPC UA として外に出します。OPC UA のクライアントから位置指令を書くと、CAN の PDO まで貫通してサーボが動きます。
そこで StatusCode が実際に役に立ちます。
今回使ったもののライセンス
| ライセンス | |
|---|---|
| asyncua 2.0.1 | LGPL-3.0-or-later |
仕様書(HTML/Markdown 版は無料・登録不要)
- Part 1 Overview and Concepts:
https://reference.opcfoundation.org/specs/OPC-10000-1 - Part 3 Address Space Model:
https://reference.opcfoundation.org/specs/OPC-10000-3 - Part 4 Services:
https://reference.opcfoundation.org/specs/OPC-10000-4 - Part 14 PubSub:
https://reference.opcfoundation.org/specs/OPC-10000-14
この記事は Zenn に初出したものを加筆・補足したものです ── Zenn の元記事を見る