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)

TemperaturePressure はサーバが毎秒書き換えるので、上は起動直後の値です。実行するたびに変わります。

最後の行がこの回の要点です。変数の「型」そのものも、サーバの中のノードとして置いてあり、実行時に辿れます。

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つのものが無いことに気づきます。

  1. 誰が読んでよくて、誰が書いてよいのか — CANopen にも Modbus にも認証・認可はありません
  2. この値は何なのか — 0x6064 が「現在位置」で単位が何かは、EDS ファイルかPDFの中
  3. そこに何がいるのか — ネットワークを繋いだだけでは、何台何がいるか分かりません

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 誰として

OPC UA のセキュリティ3軸
セキュリティは直交する3つの軸で決まる。MessageSecurityMode(何を保護するか)、SecurityPolicy(どのアルゴリズムで)、UserIdentityToken(誰として)の3つです。

上2つは通信路(SecureChannel)をどう守るかの設定で、その過程でサーバとクライアントのアプリケーション証明書が使われます。3つ目だけがユーザーの認証です。別のレイヤです。

「証明書を入れたのに誰でも繋がってしまう」という事故は、この混同から起きます。証明書で守られるのは通信路と相手アプリの身元であって、「誰がログインしているか」ではありません。

なお Basic128Rsa15Basic256 は 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())
  1. read_value() ではなく read_data_value() — 値だけでなく StatusCode と時刻が付いてきます
  2. raise_on_bad_status=False — これを付けないと、値が信用できないときに例外になって StatusCode を見られません
  3. 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-labhttps://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 の元記事を見る