🍸

OPC UAでRingBufferを公開する

に公開

はじめに

本記事は、主にPLC(Programmable Logic Controller)向けのソフトウェア開発に従事、または関心のある方で、Structured Text(ST)による開発に興味がある方向けです。OMRON社のSysmac Studioとコントローラ(NX1またはNX5)を使用します。また、PowerShellを使用します。

今回は、RingBufferをOPC UAで公開します。OPC UAは、データ交換手段として使用するに留まり、情報モデルは関係しません。一般にユーザーがOPC UAを採用するメリットは、コンパニオン仕様の採用とその定義に対応する制御状態の実装による相互運用性の向上にあるため、今回の用途は一般的ではありません。しかし、OPC UAには比較的ライブラリが充実し、柔軟なアクセス手段としての手軽さもあります。

RingBufferの公開は、Sysmac Studioのシミュレータ及びNX/NJコントローラのOPC UAサーバが持つ "ユーザ定義ファンクションブロックの変数の OPC UA 通信への公開" 機能の応用です。この機能は、端的に以下をユーザーに提供するものです。

"FBインスタンスを、そのPOU定義と同じ階層構造を持つオブジェクトノードとして公開し、FBの入出力と内部変数を、各オブジェクトノードの直下にOPC UA変数公開の制約を前提とする変数ノードとして公開する。"

現状の機能は、これだけです。これだけの機能なのですが、グローバル変数の公開に比べ、OPC UAの思想を適用し易くなります。構造としての表現が可能になるからです。特に、OPC UAを素朴なデータ公開または、交換のためのプロトコルではなく、情報モデルによる情報交換を目的として使用するのであれば尚更です。

RingBufferの公開で定義する"オブジェクト"は、OPC UAの情報モデルとしての"オブジェクト"ではありません。データアクセスの指向が強く、かつ、機能の提供が目的だからです。しかし、仮に情報モデルとして受け入れられる"オブジェクト"を合わせて公開したとしてもそれと実質的に区別することも出来なければ、同じ手段によって使用する対象でもあります。そのため、情報モデルではないのですが、実質的な取り扱いは同じです。

公開したRingBufferの使用に、PwshOpcUaClientによるクライアントも合わせて作成しましたが、内容が"OPC UAで公開したファンクションブロックを疑似UA Methodとして使う"と同じであるため詳細は扱いません。また、作成したクライアントとPesterによるテストも面白いのですが、テストスクリプトを確認すれば十分なので同様に詳細は扱いません。

Sysmacプロジェクト

Sysmacプロジェクトは、以下にあります。

https://github.com/kmu2030/RingBufferOpcUaExtensionLib

実装

RingBufferの公開は、実装が主なのでそれを確認します。対象は、いずれもファンクションブロック(以下、FB)です。RingBufferへの操作を"メソッド"として持つ"オブジェクト"であるFBと、その"メソッド"であるFBです。

オブジェクト FB

RingBuffer操作を提供する"オブジェクト"として、OpcUaRingBufferFBを定義します。RingBufferのエンティティは外部から与える(FBの入出力として渡す)とし、任意のRingBufferエンティティのOPC UA公開を可能にします。RingBuffer操作は、個別にFBを定義し、"オブジェクト"の内部変数として定義します。FBインスタンスの階層公開により、それら操作FBインスタンスは、"オブジェクト"下のノード、メンバーと読み取れる構造で公開されます。現在は、変数公開に限定されているので、メンバーは実質的に"プロパティ"だけです。

実装は、以下になります。操作FBを保持するだけです。

入出力

名称 入出力 データ型
Enable 入力 BOOL
BufferContext 入出力 RingBufferContext
Buffer 入出力 ARRAY[*] OF BYTE
Busy 出力 BOOL

内部変数

名称 データ型
Read OpcUaRingBuffer_read
ReadFully OpcUaRingBuffer_readFully
Peek OpcUaRingBuffer_peek
PeekFully OpcUaRingBuffer_peekFully
Write OpcUaRingBuffer_write
WriteFully OpcUaRingBuffer_writeFully
Consume OpcUaRingBuffer_consume
ClearBuffer OpcUaRingBuffer_clear
GetReadableSize OpcUaRingBuffer_getReadableSize
GetWritableSize OpcUaRingBuffer_getWritableSize
GetStat OpcUaRingBuffer_getStat
IsOverflow OpcUaRingBuffer_isOverflow
IsReadable OpcUaRingBuffer_isReadable
IsWritable OpcUaRingBuffer_isWritable
GetContext OpcUaRingBuffer_getContext
DiffWrite OpcUaRingBuffer_diffWrite
DiffRead OpcUaRingBuffer_diffRead
Diff OpcUaRingBuffer_diff
GetDiffStat OpcUaRingBuffer_getDiffStat

コード

RingBufferOpcUaExtensionLib.smc2/POU/ファンクションブロック/OpcUaRingBuffer
IF NOT (Enable OR Busy) THEN
    RETURN;
END_IF;

IF Enable <> Busy THEN
    Read.Execute := FALSE;
    ReadFully.Execute := FALSE;
    Peek.Execute := FALSE;
    PeekFully.Execute := FALSE;
    Write.Execute := FALSE;
    WriteFully.Execute := FALSE;
    Consume.Execute := FALSE;
    ClearBuffer.Execute := FALSE;
    GetReadableSize.Execute := FALSE;
    GetWritableSize.Execute := FALSE;
    GetStat.Execute := FALSE;
    IsOverflow.Execute := FALSE;
    IsReadable.Execute := FALSE;
    IsWritable.Execute := FALSE;
    
    GetContext.Execute := FALSE;
    DiffWrite.Execute := FALSE;
    DiffRead.Execute := FALSE;
    Diff.Execute := FALSE;
    GetDiffStat.Execute := FALSE;
END_IF;
Busy := Enable;

Read(BufferContext:=BufferContext, Buffer:=Buffer);
ReadFully(BufferContext:=BufferContext, Buffer:=Buffer);
Peek(BufferContext:=BufferContext, Buffer:=Buffer);
PeekFully(BufferContext:=BufferContext, Buffer:=Buffer);
Write(BufferContext:=BufferContext, Buffer:=Buffer);
WriteFully(BufferContext:=BufferContext, Buffer:=Buffer);
Consume(BufferContext:=BufferContext, Buffer:=Buffer);
ClearBuffer(BufferContext:=BufferContext, Buffer:=Buffer);
GetReadableSize(BufferContext:=BufferContext);
GetWritableSize(BufferContext:=BufferContext);
GetStat(BufferContext:=BufferContext);
IsOverflow(BufferContext:=BufferContext);
IsReadable(BufferContext:=BufferContext);
IsWritable(BufferContext:=BufferContext);
    
GetContext(BufferContext:=BufferContext);
DiffWrite(BufferContext:=BufferContext, Buffer:=Buffer);
DiffRead(BufferContext:=BufferContext, Buffer:=Buffer);
Diff(BufferContext:=BufferContext, Buffer:=Buffer);
GetDiffStat(BufferContext:=BufferContext);

メソッド FB

RingBuffer操作は、OpcUaRingBufferの"メソッド"として操作ごとにFBを定義します。UA Methodは使用できないので、疑似UA Methodとして定義します。RingBuffer操作はいずれもファンクション(FUN)かつ、実行直後に処理が完了します。そのため、操作FBはそれらをラップする素朴なExecute型FBです。

メソッドFBとしてRingBufferからバイト列読み出しを行うFUNをラップするFBを確認します。実装は、以下です。

入出力

名称 入出力 データ型
Execute 入力 BOOL
BufferContext 入出力 RingBufferContext
Buffer 入出力 ARRAY[*] OF BYTE
Size 入力 UINT
Out 出力 ARRAY[0..8191] OF BYTE
ReadSize 出力 UINT
Overflow 出力 BOOL
Done 出力 BOOL

入出力は、FUNの入出力にExecute型FBの入出力を追加したものを定義します。FBの入出力と内部変数はいずれも変数ノードとして公開されますが、それらに区別はありません。そのため、FUNに渡す引数を内部変数に定義することもできますが、このFBの意味合いとテストを考えれば、入出力に配置するのが適当です。現状は、公開変数の指定ができません。そのため、公開を意図しない変数も公開されます。

外部からの操作を意図しない変数は、クライアント側が触らないようにするか、変数公開の制約を使って公開できないようにして対応します。下手に詳細を決めないという判断だとは思いますが、何故、公開をFBの入出力に限らなかったのか、入出力属性をノードのアクセスレベルにマップしなかったのかは疑問です。今後の機能的な改変と設定ツールの展開が見込まれるので、クライアント側が対応するのが無難です。

定義した入出力のうち、Bufferは公開されません。制約により可変長配列は公開できないためです。Outを固定長配列としているのは、その制約を避けるためです。また、要素数が8192であるのは、使用を想定するコントローラで共通して制約を受けない範囲で適当な値とするためです。

コード

RingBufferOpcUaExtensionLib.smc2/POU/ファンクションブロック/OpcUaRingBuffer_read
IF NOT Execute THEN
    IF Done THEN
        ReadSize := 0;
        Overflow := FALSE;
        Done := FALSE;
    END_IF;
    
    RETURN;
END_IF;

IF Execute AND NOT Done THEN
    \\stfreakjp\buf\RingBuffer_read(
                        Context:=BufferContext,
                        Buffer:=Buffer,
                        Size:=Size,
                        Out:=Out,
                        Head:=0,
                        ReadSize=>ReadSize,
                        Overflow=>Overflow);
    Done := TRUE;
END_IF;

FUNの素朴なラッパーです。ExecuteがTrueになったとき、FUNを必ず1回だけ実行することを保証し、ExecuteがFalseになったとき、出力をクリアします。FBからOPC UAの公開状態を把握する手段は、変数の値を参照する以外にありません。そのため、細かなアクセス制御をFBで行うことはできません。

複数クライアントからの実行を許容するには、サーバとクライアントの双方でユーザーによる実装が必要です。サーバであるコントローラのプログラムは、処理を"タスクセーフ"にし、OPC UA経由の呼び出しを同期的に扱う手続きの検討が必要です。公開されるノード自体が、共有リソースであるためです。クライアントは、その手続きに対応した実装が必要です。これらは、UA Methodを使用できたとしても同様です。

しかし、そのような要件がある場合、Pub/Subを基盤として全体を構築することが、サーバ、クライアントの双方にとって良い結果になるかもしれません。手続き的な複雑さは好ましくなく、まして自作ともなれば、OPC UAにおける手段選択、将来的な要件を含めての認識を誤っている可能性があるからです。OPC UAは、その広範さを活かし、価値のあることに最も注力できる手段を選択できることに意味があります。

サンプルプログラム

サンプルプログラムは、OpcUaRingBufferFBのインスタンスをプログラムPOUに定義し、OPC UAサーバで公開するサーバプログラムと、PwshOpcUaClientによるリファレンスである、OpcUaRingBuffer.ps1を使用したクライアントプログラムで構成します。

サーバプログラム

ここに挙げるプログラムPOUは、ExampleOpcUaRingBuffer.smc2のプログラムPOUです。RingBufferをロガーのバッファとして公開しています。

実装は、以下です。各サイクルのタイムスタンプをロガーに書き込み続けます。

内部変数

名称 データ型
LoggerBuffer OpcUaRingBuffer
iBuffer ARRAY[0..65534] OF BYTE
iBufferContext RingBufferContext

コード

examples/ExampleOpcUaRingBuffer/プログラム/Models
IF P_First_Run THEN
    LoggerBuffer.Enable := TRUE;
    
    \\stfreakjp\buf\RingBuffer_init(
                        Context:=iBufferContext,
                        Buffer:=iBuffer);
    
    iWaitTick := 1;
    iInterval := iWaitTick;
END_IF;

Dec(iWaitTick);
IF iWaitTick < 1 THEN
    iBinStrSize
        := StringToAry(In:=CONCAT('server=', DtToString(GetTime()), '$L'),
                       AryOut:=iBinStr[0]);
    \\stfreakjp\buf\RingBuffer_write(
                        Context:=iBufferContext,
                        Buffer:=iBuffer,
                        In:=iBinStr,
                        Head:=0,
                        Size:=iBinStrSize,
                        AllowOverWrite:=TRUE);
    iWaitTick := iInterval;
END_IF;

LoggerBuffer(BufferContext:=iBufferContext,
             Buffer:=iBuffer);

クライアントプログラム

クライアントプログラムが使用するOpcUaRingBuffer.ps1は、操作FBの呼び出し処理を、操作FBのインスタンス名と同じ名前のメソッドとして持つクラスを定義しています。メソッドは、OPC UAクライアントのセッションと、操作FBの公開を意図する入力を引数とし、戻り値が1つである場合はそのまま、複数である場合はハッシュマップにして返します。

公開したRingBufferを使用するクライアントプログラムは、以下です。

examples/ExampleOpcUaRingBufferRead.ps1
using namespace Opc.Ua
param(
    [bool]$UseSimulator = $true,
    [string]$ServerUrl = 'opc.tcp://localhost:4840',
    [bool]$UseSecurity = $true,
    [string]$UserName = 'taker',
    [string]$UserPassword = 'chocolatepancakes',
    [double]$Interval = 0.05
)
. "$PSScriptRoot/../PwshOpcUaClient/PwshOpcUaClient.ps1"
. "$PSScriptRoot/../OpcUaRingBuffer.ps1"

function Main () {
    try {
        $AccessUserIdentity = [string]::IsNullOrEmpty($UserName) `
                                ? (New-Object UserIdentity) `
                                : (New-Object UserIdentity -ArgumentList $UserName, $UserPassword)
        $clientParam = @{
            ServerUrl = $ServerUrl
            UseSecurity = $UseSecurity
            SessionLifeTime = 60000
            AccessUserIdentity = $AccessUserIdentity
        }
        $client = New-PwshOpcUaClient @clientParam

        # Create the RingBuffer object.
        $separator = $UseSimulator ? '.' : '/'
        $loggerBuffer = [RingBuffer]::new(
            "ns=$($UseSimulator ? '2;Programs.' : '4;')Models${separator}LoggerBuffer",
            $separator
        )

        $decoder = [System.Text.Encoding]::UTF8.GetDecoder()
        While ($true) {
            # Read from the buffer.
            # This contains this client wrotes.
            $read = $loggerBuffer.ReadFully($client.Session)
            if ($read.ReadSize -gt 0) {
                $chars = [char[]]::new($decoder.GetCharCount($read.Out, 0, $read.ReadSize, $false))
                $decoder.GetChars($read.Out, 0, $read.ReadSize, $chars, 0, $false)
                    | Out-Null
                $chars -join "" | Write-Host -NoNewline
            }

            # Write a client timestamp to the buffer
            $msg = "client=$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fffffff00')`n"
            $binMsg = [System.Text.Encoding]::UTF8.GetBytes($msg)
            $loggerBuffer.Write($client.Session, $binMsg, $true)
                | Out-Null

            Start-Sleep -Seconds $Interval
        }
    }
    catch {
        $_.Exception
    }
    finally {
        Dispose-PwsOpcUaClient -Client $client
    }
}

Main

実行

コントローラでプロジェクトを動作させ、クライアントプログラムを実行します。実行手順は、リポジトリに記述があります。コントローラのOPC UAサーバとプログラムが動作した状態でクライアントプログラムを実行すると、以下のようにバッファの読み書きを行います。

コントローラのOPC UAサーバを介したRingBuffer操作
コントローラのOPC UAサーバを介したRingBuffer操作

RingBufferはそれなりにテストをしているので、OPC UAサーバと操作FBに問題が生じなければ、クライアントが頻繁に接続と切断、操作を繰り返しても問題ありません。また、クライアントが使用開始時にOpcUaRingBufferオブジェクトのEnableプロパティを操作して操作FBをリセットするようにすると、意図しないコネクションの喪失によって生じる中途半端な状態にも対応できるようになります。

まとめ

今回は、コントローラのOPC UAサーバ機能とOPC UAをデータ交換プロトコルとして使用し、RingBufferを公開しました。また、公開したRingBufferに符合するクライアントも作成しました。これにより、サーバとクライアント双方が類似の概念で対象を扱えるようになりました。OPC UAプロトコルレイヤーの上にRingBufferというモデルを設けたと見なすこともできます。但し、データ交換を目的としたバッファのモデルなのでOPC UAの情報モデルではありません。

OPC UAは、データ交換のためのプロトコルとしての使用について消極的です。情報モデルが構成する抽象的、仮想的な場とその交換、相互作用によって意図した状態を構築する統合的な仕様を指向しているためです。しかし、その抽象的な指向を支える手段として、データ交換としてのプロトコルを有しているのも事実であり、今回は、それを利用しました。

コントローラと外部デバイスとのやり取りには、様々な選択肢があります。OPC UAサーバ機能を備えるデバイスは十分に流通しており、OPC UAは良好な選択肢の一つです。OPC UAの内容は広範かつ抽象的で、とっつきにくい印象もありますが、しかし、それ故に用途として多くの使い道があります。ツールとしてのOPC UAの利用でも効果はありますが、抽象的ではあってもOPC UAの思想に則ると、より広範な効果を得られるようになります。

OPC UAは、その仕様の上に設ける抽象レイヤーに概念や振る舞いを定義して共有することで、細かなすり合わせや調整、それらの漏れによる手戻りの低減を目指すことができます。一方、抽象レイヤーの不備、例えばモデルの欠陥は大きな修正につながります。コンパニオン仕様を骨子にすることで、致命的な不備を低減することはできますが、それでも注意が必要です。

また、そのような状況になると、アジャイル開発の適用を考えるかもしれません。そうなると、シミュレーションやソフトウェアテストの適用を指向することになります。実物とシミュレーション、実際の状況とソフトウェアテストが想定する前提が一致することはないので、完璧を目指すことはできません。しかし、現合や調整のみによる手法の不確かさ、そして、その不確かさがもたらす保守性の不安定さに比べれば、不完全さを問えることすら利点です。ツールと環境整備に取り組めるのであれば、根本的な変化をもたらす可能性があります。

OPC UAにも課題はあります。作業の大半に高い抽象性が伴うことです。抽象性がOPC UAの機能性を支えているため仕方がないことですが、OPC UAの対象領域の現状とのギャップはかなりの程度であるように思われます。また、OPC UAとしての設計と実装のギャップにも注意が必要です。例えば、OPC UAの情報モデルとしての"オブジェクト"の認識と、実装に使用する言語の"オブジェクト"的な特性とその特性を利用した設計思想における"オブジェクト"の認識とのギャップを意識し、混同を避ける必要があります。

Discussion