更新履歴(初版のみ・2026年11月01日公開)
- 初版公開
この記事を引用する(DOI: 10.5281/zenodo.23904614)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「OPC UA 入門 ── PLCや装置のデータをWindowsアプリで集める」合同会社小村ソフト. https://doi.org/10.5281/zenodo.23904614 https://comcomponent.com/blog/opc-ua-windows-data-collection/
- DOI(最新版)
- 10.5281/zenodo.23904614
- DOI(この版)
- 10.5281/zenodo.23904615
PLCや装置のデータをWindowsアプリで集めるとき、装置がOPC UAのサーバーを持っていれば、メーカーごとの通信を作らずに済むことがあります。ただし、つないで値を読むだけでは足りません。値と一緒に返る状態とタイムスタンプ、変化を通知してもらう購読、証明書による接続の許可、切断からの戻り方を知っておかないと、集めたデータを後から信用できなくなります。
この記事ではOPC UAの仕組みのうちデータ収集に要る部分を、C#のクライアントで実際に動かした結果とともに説明します。クライアントにはOPC Foundationの.NETのライブラリを使い、相手には模擬の装置サーバーを使いました。特定のPLCや装置での動作は試していません。装置ごとの設定はその装置の資料で確かめてください。
GigE VisionやUSB3 Visionのカメラから画像を集める話は「GigE Vision・USB3 Visionカメラの取りこぼし」に、シリアル通信の装置の話は「シリアル通信アプリの落とし穴」にまとめています。
OPC UAとOPC Classicの違い
OPC Classicの仕様はWindowsのCOM/DCOMを使ってソフトウェア部品の間でデータをやり取りします。工程のデータを扱うOPC DA、アラームを扱うOPC A&E、履歴を扱うOPC HDAが、別々の仕様として定められています1。
OPC Foundationは2008年にOPC UA(Unified Architecture)を公開しました。OPC UAは特定のプラットフォームに依存しないサービス指向のアーキテクチャで、Classicの各仕様の機能をまとめて扱います。OPC Foundationは作り直した理由として、MicrosoftがCOM/DCOMよりクロスプラットフォームの仕組みを重視するようになったこと、Microsoft以外のシステムや組み込み機器でも動かす必要があったこと、ファイアウォールを越えて安全に接続する必要があったことなどを挙げています1。
OPC UAの仕様はIECでもIEC 62541のシリーズとして規格になっています2。通信のポートは、IANAに4840/TCPが opcua-tcp として登録されています3。
flowchart TB
accTitle: OPC ClassicとOPC UAの違い
accDescr: OPC ClassicはWindowsのCOMとDCOMを土台にDA、A&E、HDAの別々の仕様に分かれ、OPC UAはプラットフォームに依存しない一つの仕様にそれらの機能をまとめたことを示す図。
C["OPC Classic"] --> D["COM/DCOM(Windows)の上で動く"]
D --> DA["DA・A&E・HDAに分かれた仕様"]
DA -->|"機能をまとめる"| U["OPC UA(2008年)"]
U --> P["特定のプラットフォームに依存しない"]
P --> S["IEC 62541として規格化"]
図1: OPC ClassicはCOM/DCOMの上の別々の仕様で、OPC UAはそれらを一つにまとめた。
既存の設備にOPC DAのサーバーしかない場合は、COM/DCOMを使う側の仕組みが要ります。この記事はOPC UAだけを扱います。
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全13件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
試した構成
動かしたのは次の構成です。
| 役割 | 使ったもの |
|---|---|
| クライアント | C#、.NET 10 SDK 10.0.114、NuGetパッケージ OPCFoundation.NetStandard.Opc.Ua.Client 2.0.1 |
| サーバー | Python 3.13 と asyncua 2.1.0 で作った模擬の装置(同じPCの4840番ポート) |
| OS | Linux(Windowsでの実行は試していない) |
クライアントのライブラリはOPC Foundationが公開している.NETの実装です。2.0系の使い方は同じリポジトリの Getting started の書き方に合わせました4。
flowchart TB
accTitle: 試した構成
accDescr: C#のクライアントがOPC Foundationの.NETライブラリを使い、4840番ポートで待ち受けるasyncuaの模擬装置サーバーに暗号化した接続でつなぐ構成を示す図。
A["C#のクライアント(LineCollector)"] -->|"opc.tcp・SignAndEncrypt"| B["模擬の装置サーバー(asyncua)"]
A -.-> L["OPC Foundationの.NETライブラリ 2.0.1"]
B -.-> V["Line1の変数4つ"]
図2: 同じPCの中で、C#のクライアントから模擬の装置サーバーへつないだ。
模擬の装置サーバー
装置の代わりに、asyncuaで小さなサーバーを作りました5。Line1 というオブジェクトの下に、温度(Temperature)、生産数(PartCount)、運転中(Running)、設定値(Setpoint)の4つの変数を置いています。温度は0.5秒ごと、生産数は2秒ごとに変わります。セキュリティは暗号化なし(None)と、署名と暗号化(Basic256Sha256 の SignAndEncrypt)の2つを用意しました。
# 動作確認用の模擬装置。本番の装置やPLCの代わりではない。
import asyncio, math, random, socket
from pathlib import Path
from cryptography.x509.oid import ExtendedKeyUsageOID
from asyncua import Server, ua
from asyncua.crypto.cert_gen import setup_self_signed_certificate
HERE = Path(__file__).parent
APP_URI = "urn:komurasoft:demo-plc"
async def main():
key, cert = HERE / "server_key.pem", HERE / "server_cert.der"
await setup_self_signed_certificate(
key, cert, APP_URI, socket.gethostname(),
[ExtendedKeyUsageOID.SERVER_AUTH], {"commonName": "DemoPlcServer"})
server = Server()
await server.init()
server.set_endpoint("opc.tcp://0.0.0.0:4840/demo/")
server.set_server_name("DemoPlcServer")
await server.set_application_uri(APP_URI)
server.set_security_policy([
ua.SecurityPolicyType.NoSecurity,
ua.SecurityPolicyType.Basic256Sha256_SignAndEncrypt,
])
await server.load_certificate(str(cert))
await server.load_private_key(str(key))
ns = await server.register_namespace("urn:komurasoft:demo-plc:line1")
line1 = await server.nodes.objects.add_object(ua.NodeId("Line1", ns), "Line1")
temperature = await line1.add_variable(ua.NodeId("Line1.Temperature", ns), "Temperature", 25.0)
part_count = await line1.add_variable(
ua.NodeId("Line1.PartCount", ns), "PartCount", ua.Variant(0, ua.VariantType.UInt32))
await line1.add_variable(ua.NodeId("Line1.Running", ns), "Running", True)
setpoint = await line1.add_variable(ua.NodeId("Line1.Setpoint", ns), "Setpoint", 30.0)
await setpoint.set_writable()
async with server:
print("listening", flush=True)
t, count = 0, 0
while True:
await asyncio.sleep(0.5)
t += 1
value = 25.0 + 2.0 * math.sin(t / 4) + random.uniform(-0.05, 0.05)
await temperature.write_value(round(value, 2))
if t % 4 == 0:
count += 1
await part_count.write_value(ua.Variant(count, ua.VariantType.UInt32))
asyncio.run(main())
このサーバーはつないできたクライアントの証明書を検査せずに受け入れます。試験用の設定で、本番の装置の振る舞いとは違います。
C#のクライアント
プロジェクトファイルには次の2つのパッケージを入れました。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="2.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
</ItemGroup>
</Project>
クライアントは接続、ブラウズ、読み取り、書き込み、購読を順に行います。各部分の意味は次の節から順に説明します。
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Client.Subscriptions;
using Opc.Ua.Client.Subscriptions.Streaming;
using MonitoredItemOptions = Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions;
string url = args.Length > 0 ? args[0] : "opc.tcp://localhost:4840/demo/";
int notifications = args.Length > 1 ? int.Parse(args[1]) : 6;
const string LineNamespace = "urn:komurasoft:demo-plc:line1";
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
builder.Logging.SetMinimumLevel(LogLevel.Warning);
builder.Services
.AddOpcUa()
.AddClient(options =>
{
options.ApplicationName = "LineCollector";
options.ApplicationUri = "urn:localhost:LineCollector";
options.ProductUri = "urn:komurasoft:LineCollector";
options.PkiRoot = Path.Combine(AppContext.BaseDirectory, "pki");
})
.AddDiscoveryAndConnect(options =>
{
options.DiscoveryUrl = url;
options.SecurityMode = MessageSecurityMode.SignAndEncrypt;
options.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
});
using IHost host = builder.Build();
await host.StartAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));
CancellationToken ct = timeout.Token;
try
{
var connect = host.Services
.GetRequiredService<Func<CancellationToken, Task<ManagedSession>>>();
ManagedSession session = await connect(ct);
await using (session)
{
Console.WriteLine($"接続: {session.ConfiguredEndpoint?.EndpointUrl}");
// 名前空間インデックスはサーバーが決める。URIから引いてNodeIdを作る
ushort ns = (ushort)session.NamespaceUris.GetIndex(LineNamespace);
var line1 = new NodeId("Line1", ns);
var temperature = new NodeId("Line1.Temperature", ns);
var partCount = new NodeId("Line1.PartCount", ns);
var setpoint = new NodeId("Line1.Setpoint", ns);
// ブラウズ: Line1 の下にある変数を一覧する
var browser = new Browser(session)
{
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)NodeClass.Variable,
};
Console.WriteLine("Line1 の変数:");
foreach (ReferenceDescription child in await browser.BrowseAsync(line1, ct))
{
Console.WriteLine($" {child.DisplayName} {child.NodeId}");
}
// 読み取り: 値だけでなく、状態と2つのタイムスタンプを見る
DataValue t = await session.ReadValueAsync(temperature, ct);
Console.WriteLine($"Temperature = {t.WrappedValue} 状態 {t.StatusCode}");
Console.WriteLine($" Source {Hms(t.SourceTimestamp)} / Server {Hms(t.ServerTimestamp)}");
// 存在しないノードを読むと、値ではなくBadの状態が返る
try
{
await session.ReadValueAsync(new NodeId("Line1.Pressure", ns), ct);
}
catch (ServiceResultException ex)
{
Console.WriteLine($"Line1.Pressure の読み取り: {ex.StatusCode}");
}
// 書き込み: Setpoint を 32.5 にする
var write = new WriteValue
{
NodeId = setpoint,
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(32.5)),
};
WriteResponse wr = await session.WriteAsync(null, new[] { write }, ct);
Console.WriteLine($"Setpoint の書き込み: {wr.Results[0]}");
// 購読: サーバーに500msごとのサンプリングを頼み、変化を通知してもらう
Console.WriteLine($"購読({notifications}件まで):");
await foreach (DataValueChange change in session.DefaultStreaming
.SubscribeDataChangesAsync(new[] { temperature, partCount }, options: new MonitoredItemOptions
{
SamplingInterval = TimeSpan.FromMilliseconds(500),
QueueSize = 5,
}, ct: ct)
.TakeAsync(notifications)
.WithCancellation(ct))
{
DataValue v = change.Value;
string name = change.MonitoredItem?.Name.Split("s=")[^1] ?? "?";
Console.WriteLine($" {Hms(v.SourceTimestamp)} {name,-17} {v.WrappedValue,6} {v.StatusCode}");
}
}
}
catch (ServiceResultException ex)
{
Console.Error.WriteLine($"OPC UA エラー {ex.StatusCode}: {ex.Message}");
Environment.ExitCode = 1;
}
finally
{
await host.StopAsync();
}
static string Hms(DateTimeUtc time) => time.ToDateTime().ToString("HH:mm:ss.fff");
初めて実行したときは接続できませんでした。理由は「証明書で接続を許可する」の節で説明します。サーバーの証明書を信頼したあとの出力は次のとおりです。ライブラリの警告ログは省いています。
接続: opc.tcp://localhost:4840/demo/
Line1 の変数:
Temperature ns=2;s=Line1.Temperature
PartCount ns=2;s=Line1.PartCount
Running ns=2;s=Line1.Running
Setpoint ns=2;s=Line1.Setpoint
Temperature = 23.09 状態 Good [0x00000000]
Source 13:40:51.302 / Server 13:40:51.302
Line1.Pressure の読み取り: BadNodeIdUnknown [0x80340000]
Setpoint の書き込み: Good [0x00000000]
購読(6件まで):
13:40:51.302 Line1.Temperature 23.09 Good [0x00000000]
13:40:50.801 Line1.PartCount 67 Good [0x00000000]
13:40:51.802 Line1.Temperature 23.05 Good [0x00000000]
13:40:52.304 Line1.Temperature 23.07 Good [0x00000000]
13:40:52.806 Line1.Temperature 23.2 Good [0x00000000]
13:40:52.806 Line1.PartCount 68 Good [0x00000000]
時刻はUTCです。
装置のデータはアドレス空間のノードとして見える
OPC UAのサーバーは持っているデータをノードの集まりとして公開します。この集まりをアドレス空間と呼びます。ノードにはフォルダーや装置を表すオブジェクト、値を持つ変数などの種類があり、ノード同士は参照でつながります。クライアントはBrowseのサービスで参照をたどり、どんなノードがあるかを調べます6。
上の出力では Line1 から階層の参照をたどって、4つの変数を見つけています。
flowchart TB
accTitle: 模擬装置のアドレス空間
accDescr: Objectsフォルダーの下にLine1オブジェクトがあり、その下にTemperature、PartCount、Running、Setpointの4つの変数があることを示す図。
O["Objects"] --> L["Line1(オブジェクト)"]
L --> T["Temperature(変数)"]
L --> P["PartCount(変数)"]
L --> R["Running(変数)"]
L --> S["Setpoint(変数・書き込み可)"]
図3: 模擬装置のアドレス空間。クライアントはBrowseで参照をたどってノードを見つける。
NodeIdは名前空間と識別子でできている
ノードはNodeIdで識別します。出力の ns=2;s=Line1.Temperature は、名前空間のインデックスが2で、識別子が文字列の Line1.Temperature であることを表しています。
名前空間の正体はURIです。仕様では名前空間のURIをサービスの中では数値で表し、その数値はサーバーのNamespaceArrayの位置に当たると定めています。インデックス0はOPC UAの名前空間 http://opcfoundation.org/UA/ です7。
そのため、インデックスの数値をコードに書いておくのではなく、URIからインデックスを引きます。上のコードでは session.NamespaceUris.GetIndex(LineNamespace) でURIから2を求め、そこからNodeIdを作っています。
flowchart TB
accTitle: 名前空間URIからNodeIdを作る流れ
accDescr: クライアントは名前空間のURIをサーバーのNamespaceArrayで引いてインデックスを得て、そのインデックスと識別子を組み合わせてNodeIdを作ることを示す図。
U["名前空間のURI"] --> A["サーバーのNamespaceArray"]
A --> I["インデックス(この例では2)"]
I --> N["NodeId ns=2;s=Line1.Temperature"]
K["識別子 Line1.Temperature"] --> N
図4: 名前空間のインデックスはURIから引き、識別子と組み合わせてNodeIdにする。
読み取りと書き込みでは値と一緒に状態とタイムスタンプが返る
変数の値はReadとWriteのサービスで読み書きします8。読み取りで返るのは値だけではありません。DataValueという構造で、値、状態コード、ソースタイムスタンプ、サーバータイムスタンプが返ります9。
| 項目 | 意味 |
|---|---|
| 値 | 変数の値。状態コードがエラーを示すときは値を無視し、サーバーはnullにする |
| 状態コード(StatusCode) | サーバーがその値を取得・提供できたか |
| SourceTimestamp | データの発生元が値に付けた時刻 |
| ServerTimestamp | サーバーが値を受け取った、または正しいと分かった時刻 |
状態コードの上位2ビットはGood(成功)、Uncertain(一部成功で、用途によっては使えない)、Bad(失敗)を表します10。出力の Good [0x00000000] はGoodです。
存在しない Line1.Pressure を読んだときは、値の代わりに BadNodeIdUnknown [0x80340000] が返りました。このライブラリの ReadValueAsync は、状態がBadのときに例外を出します4。値を記録する仕組みでは値と並べて状態コードも残しておくと、Badの間に記録された値を後から区別できます。
flowchart TB
accTitle: DataValueの中身
accDescr: 読み取りや通知で返るDataValueが、値、状態コード、ソースタイムスタンプ、サーバータイムスタンプの4つを持ち、状態コードがBadなら値を無視することを示す図。
D["DataValue"] --> V["値"]
D --> S["状態コード"]
D --> TS["SourceTimestamp(発生元の時刻)"]
D --> TV["ServerTimestamp(サーバーの時刻)"]
S -->|"Badなら"| X["値を無視する"]
図5: 読み取りの結果は値だけでなく、状態コードと2つのタイムスタンプを持つ。
書き込みでは Setpoint に32.5を書き、結果の状態コードとして Good が返りました。書き込める変数かどうかはサーバーが決めます。この模擬サーバーでは Setpoint だけを書き込み可にしています。装置の設定値を外から書き換えられるようにするかどうかは、装置の安全の設計に関わるので、この記事の範囲では扱いません。
変化を通知してもらう:サブスクリプションとMonitoredItem
値の変化を追うとき、クライアントが一定間隔でReadを繰り返す方法もあります。OPC UAにはそれとは別に、サーバーに値の監視を任せて変化を通知してもらう仕組みがあります。それがサブスクリプションとMonitoredItemです。
MonitoredItemは監視する1つの対象で、サンプリング間隔を持ちます。サンプリング間隔は、サーバーが発生元から値を標本化する最も速い間隔で、仕様では「ベストエフォート」の周期とされています11。標本がフィルターの条件を満たすと通知が作られます。フィルターを指定しなければ、値か状態が変わったときに通知します12。
通知はいったんMonitoredItemのキューに入ります。キューがあふれたときは、古いものと新しいもののどちらを捨てるかを決めておき、キューの大きさが2以上なら、捨てたことを示すOverflowのビットが状態コードに立ちます13。
サブスクリプションは複数のMonitoredItemをまとめ、パブリッシング間隔ごとに、まだ送っていない通知をクライアントへ送ります。送る通知がない周期が続くと、サブスクリプションが生きていることを伝えるキープアライブを送ります14。
flowchart TB
accTitle: サブスクリプションとMonitoredItemの流れ
accDescr: MonitoredItemがサンプリング間隔で発生元の値を標本化し、フィルターを通った通知をキューに入れ、サブスクリプションがパブリッシング間隔でまとめてクライアントへ送り、通知がなければキープアライブを送ることを示す図。
S["発生元(装置の値)"] --> M["MonitoredItem"]
M -->|"サンプリング間隔"| F["フィルター(既定は値か状態の変化)"]
F --> Q["キュー(あふれたらOverflow)"]
Q --> SUB["サブスクリプション"]
SUB -->|"パブリッシング間隔"| C["クライアント"]
SUB -.->|"通知がなければ"| K["キープアライブ"]
図6: MonitoredItemが標本化してキューにため、サブスクリプションがまとめてクライアントへ送る。
上のコードでは Temperature と PartCount をサンプリング間隔500ミリ秒、キューの大きさ5で購読しました15。出力では、温度はおよそ0.5秒ごと、生産数はおよそ2秒ごとに届いています。最初の2件は、購読を始めた時点でそれぞれの変数が持っていた値でした。生産数の 13:40:50.801 は、購読を始める前に値が変わった時刻です。通知の時刻にはソースタイムスタンプを表示しているので、通知を受け取った時刻ではなく値が変わった時刻が出ています。
読み取りの繰り返しと購読の使い分け
読み取りを繰り返す方法は、クライアントの側で間隔を決められ、仕組みが単純です。そのかわり、値が変わっていなくても毎回要求と応答が行き来し、間隔より短い変化は見逃します。購読では、サーバーが標本化して変化だけを送るので、変化の少ない値でも通信が増えにくくなります。ただし購読でも、サンプリング間隔より短い変化は通知されないことがあります。仕様は指定より速く標本化することを許していますが、求めてはいません11。
flowchart TB
accTitle: 読み取りの繰り返しと購読の違い
accDescr: 読み取りを繰り返す方法はクライアントが間隔を決めて毎回要求し、購読はサーバーがサンプリング間隔で標本化して変化だけを送り、どちらも間隔より短い変化を取れないことがあることを示す図。
A["値の変化を追う"] --> R["読み取りを繰り返す"]
A --> S["購読する"]
R --> R1["クライアントが間隔を決め毎回要求"]
S --> S1["サーバーが標本化し変化だけを送る"]
R1 --> X["間隔より短い変化は取れないことがある"]
S1 --> X
図7: どちらの方法でも、間隔より短い変化は取れないことがある。
証明書で接続を許可する
OPC UAの接続ではメッセージの保護の度合いをMessageSecurityModeで選びます。値は保護しない None、署名だけする Sign、署名して暗号化する SignAndEncrypt です16。署名や暗号化に使うアルゴリズムの組み合わせはセキュリティポリシーで決め、この記事では Basic256Sha256 を使いました。
署名と暗号化には、アプリケーションごとの証明書を使います。クライアントとサーバーは互いの証明書を確かめ、信頼していない相手との接続を拒否します。OPC Foundationの.NETのライブラリが作るサーバーは、既定では暗号化しない None のポリシーを提供しません4。
最初の接続はBadCertificateUntrustedで止まった
最初の実行では、クライアントがサーバーの証明書を信頼していなかったので、接続が拒否されました。出力の一部です。
fail: Opc.Ua.CertificateValidationCore[91]
Certificate [Subject=CN=DemoPlcServer, CN=urn:komurasoft:demo-plc, Thumbprint=4E236DDA629FE2AB32D185BF681609FD6C525363, NotBefore=2026-11-01, NotAfter=2027-11-01, KeyAlgorithm=1.2.840.113549.1.1.1] validation failed with suppressible errors but was rejected. Reason=Id: BadCertificateUntrusted
...
OPC UA エラー BadCertificateUntrusted [0x801A0000]: [801A0000] (BadCertificateUntrusted) 'Could not verify security on OpenSecureChannel response.'
このライブラリは拒否した証明書をrejectedのストアに保存します。管理者がそこからtrustedのストアへ移すと、その相手を信頼するようになります17。今回は pki/rejected/certs にあったサーバーの証明書を pki/trusted/certs へ移し、もう一度実行して接続できました。移す前に、ファイル名にも入っている拇印をサーバーの管理者と確かめます4。
flowchart TB
accTitle: 証明書を信頼するまでの流れ
accDescr: 最初の接続でクライアントがサーバーの証明書を拒否してrejectedのストアに保存し、拇印を確かめてからtrustedのストアへ移すと、次の接続で信頼して接続できることを示す図。
A["最初の接続"] --> B["BadCertificateUntrusted"]
B --> C["証明書をrejectedのストアに保存"]
C --> D["拇印をサーバーの管理者と確かめる"]
D --> E["trustedのストアへ移す"]
E --> F["次の接続で信頼して接続できる"]
図8: 拒否された証明書を確かめてからtrustedへ移すと、その相手を信頼する。
信頼していない証明書を自動で受け入れる AutoAcceptUntrustedCertificates という設定もあります。既定はfalseで、ライブラリの資料は開発環境では役立つが本番では使わないようにと書いています17。
ログの NotAfter=2027-11-01 は模擬サーバーの証明書の有効期限です。asyncuaの関数で作った自己署名の証明書で、作った日から1年後が期限になっていました。装置の証明書にも期限があるので、期限が切れる前に入れ替えて相手に信頼させる手順を、運用の中に決めておきます。Windowsの証明書ストアの扱いは「Windows証明書ストア実務ガイド」にまとめています。
利用者の認証
証明書で確かめるのはアプリケーションどうしです。それとは別に、セッションを開くときに利用者を示すトークンを渡します。仕様には、匿名、ユーザー名とパスワード、X.509証明書、外部で発行されたトークンの4種類があります18。今回のクライアントは匿名で接続しました。サーバーがどのトークンを受け付けるかは、サーバーのエンドポイントの情報に含まれています19。
現場のデータ収集でつまずく所
ここからは、装置のデータを集める仕組みで起きやすい問題を、今回の実験と仕様に基づいて挙げます。
サーバーが止まっていた間の値は届かない
購読の途中でサーバーを止め、約15秒後に起動し直しました。このライブラリの ManagedSession は、接続が切れると自動で再接続します20。止めている間、クライアントは再接続を繰り返していました。
warn: Opc.Ua.ChannelManager[6]
Reconnect failed. Endpoint=opc.tcp://localhost:4840/demo/, Attempt=3, Outcome=transient-failure, Status=BadNotConnected, ErrorMessage=Could not connect to the remote endpoint.
サーバーが戻ると、通知も戻りました。購読の出力の一部です。
13:41:13.364 Line1.Temperature 25.61 Good [0x00000000]
13:41:13.865 Line1.Temperature 25.12 Good [0x00000000]
13:41:34.285 Line1.Temperature 26.55 Good [0x00000000]
13:41:33.784 Line1.PartCount 2 Good [0x00000000]
13:41:34.786 Line1.Temperature 26.16 Good [0x00000000]
13時41分13秒から34秒までの値は1件も届いていません。生産数は78から2に戻っています。これは模擬サーバーを起動し直して、数を0から数え直したためです。
この例ではサーバー自体が止まっていたので、その間の値はどこにも残っていません。再接続の仕組みは接続を戻しますが、止まっていた間の値を後から埋めるわけではありません。
通信だけが切れてサーバーが動き続けていた場合は事情が違います。仕様には、届かなかった通知のメッセージをサーバーの再送用のキューからもう一度送ってもらうRepublishのサービスがあります21。MonitoredItemのキューにも通知がたまりますが、キューの大きさを超えた分は捨てられます13。今回の実験では、この場合は試していません。どちらの場合も、値が来なかった時間帯が分かるように、切断と再接続の時刻を記録に残しておきます。シリアル通信の装置での再接続とログの設計は「シリアル通信アプリの落とし穴」でも扱っています。
flowchart TB
accTitle: サーバーを止めたときの記録
accDescr: 購読中にサーバーが止まると、クライアントは再接続を繰り返し、サーバーが戻ると通知も戻るが、止まっていた間の値は届かないので、切断と再接続の時刻を記録に残すことを示す図。
A["購読中"] --> B["サーバーが止まる"]
B --> C["クライアントが再接続を繰り返す"]
C --> D["サーバーが戻り通知も戻る"]
B -.-> E["止まっていた間の値は届かない"]
E -.-> F["切断と再接続の時刻を記録する"]
図9: 再接続で接続は戻るが、止まっていた間の値は届かない。
どちらのタイムスタンプを記録するか
仕様では、SourceTimestampはデータの発生元が値に付けた時刻で、値が変わった時刻を示すべきだとされています。ServerTimestampはサーバーが値を受け取った、または正しいと分かった時刻です9。今回の模擬サーバーは自分で値を作っているので、2つの時刻は同じでした。
装置で値が変わった時刻を知りたいならSourceTimestampを使います。ただしその時刻は発生元の時計で付けられるので、装置やサーバーの時計がずれていれば記録もずれます。集めたデータを他の記録と突き合わせるなら、受け取ったPCの時刻も並べて残すと、後でずれを確かめられます。Windows側の時刻同期は「Windowsの時刻同期(w32time)と業務システム」にまとめています。
状態がGood以外の値を、そのまま数値として扱わない
状態コードがBadのとき、値は無視するものとされています9。Uncertainのときは、用途によっては使えない値です10。グラフや集計に入れる前に状態コードで振り分け、Good以外の値をどう扱うかを決めておきます。キューがあふれたときのOverflowのビットも、状態コードの中に立ちます13。装置の状態を画面にどう出すかは「外部機器の状態の確認と表示のベストプラクティス」で扱っています。
ファイアウォールでポートを開ける
IANAに登録されたOPC UAのポートは4840/TCPです3。ただし、すべてのサーバーがこの番号を使うわけではありません。OPC Foundationの.NETのサンプルは62541番などのポートを使っています4。装置のエンドポイントのURLでポート番号を確かめ、そのポートへの接続を許可します。サーバーがWindowsで動いていればそのWindowsファイアウォールで、途中にネットワーク機器があればその機器で許可します。受信の規則の作り方は「Windowsファイアウォールと業務アプリ」にまとめています。
flowchart TB
accTitle: データ収集でつまずく所
accDescr: 装置のデータ収集でつまずく所を、記録の中身(サーバーが止まっていた間の値が届かないこと、どちらのタイムスタンプを記録するか、Good以外の値の扱い)と、接続の準備(ファイアウォールのポート、証明書の期限)に分けて示す図。
A["データ収集でつまずく所"] --> R["記録の中身"]
A --> N["接続の準備"]
R --> B["停止中の値は届かない"]
B ~~~ C["どちらのタイムスタンプか"]
C ~~~ D["Good以外の値の扱い"]
N --> E["ファイアウォールのポート"]
E ~~~ F["証明書の期限"]
図10: 接続できたあとも、記録の欠け、時刻、状態、ポート、証明書を確かめる。
Modbus/TCPやメーカー独自の通信との使い分け
PLCや装置との通信には、OPC UAのほかにModbus/TCPや、PLCのメーカーが用意する通信もあります。
Modbusの仕様はデータを4つの表で扱います。読み取り専用の1ビットのDiscrete Input、読み書きできる1ビットのCoil、読み取り専用の16ビットのInput Register、読み書きできる16ビットのHolding Registerです22。IANAには、Modbusのアプリケーションプロトコル(mbap)として502/TCPが登録されています3。
この表に入るのはビットと16ビットの語なので、どのアドレスに何の値がどの形式で入っているかは、装置の資料で確かめてクライアントの側に書きます。OPC UAではここまで見たように、サーバーが名前の付いた変数をアドレス空間に公開し、クライアントはBrowseでそれを調べられます。値には状態コードとタイムスタンプが付き、接続には証明書による許可があります。
どれを使うかは、装置が何を提供しているかで決まることが多いでしょう。装置がOPC UAのサーバーを持っていればそれを使い、Modbus/TCPしかなければアドレスの対応表を作って読みます。メーカーの通信やSDKしかない場合は、その資料に従います。USB機器との通信方法の選び方は「WindowsアプリでUSB機器を扱う方法」にまとめています。
flowchart TB
accTitle: 装置との通信の選び方
accDescr: 装置がOPC UAのサーバーを持っていればOPC UAで変数をブラウズして集め、Modbus/TCPしかなければ資料でアドレスと形式を確かめてレジスタを読み、メーカーの通信しかなければその資料に従うことを示す図。
A["装置が提供する通信"] --> B{"何がある?"}
B -->|"OPC UAのサーバー"| C["変数をブラウズして集める"]
B -->|"Modbus/TCPだけ"| D["資料でアドレスと形式を確かめる"]
B -->|"メーカーの通信だけ"| E["メーカーの資料とSDKに従う"]
D --> F["レジスタを読んで値に直す"]
図11: 何で集めるかは、装置が何を提供しているかで決まることが多い。
関連記事
- GigE Vision・USB3 Visionカメラの取りこぼし ── 原因の切り分けとWindows側の設定
- シリアル通信アプリの落とし穴 - 再接続とログ設計まで
- 外部機器の状態の確認と表示のベストプラクティス
- WindowsアプリでUSB機器を扱う方法
- Windowsファイアウォールと業務アプリ
- Windows証明書ストア実務ガイド
- Windowsの時刻同期(w32time)と業務システム
参考リンク
-
OPC Foundation, Classic. OPC ClassicがCOM/DCOMに基づくこと、DA・A&E・HDAの各仕様、2008年のOPC UAの公開と作り直した理由。 ↩ ↩2
-
IEC, IEC TR 62541-1:2020 OPC Unified Architecture - Part 1: Overview and concepts. OPC UAの概念と概要を扱うIEC 62541シリーズの第1部(後継版に置き換え済み)。 ↩
-
IANA, Service Name and Transport Protocol Port Number Registry. 4840/TCPの
opcua-tcpと、502/TCPのmbapの登録。 ↩ ↩2 ↩3 -
OPC Foundation, UA-.NETStandard 2.0.1: Getting started. 2.0系のクライアントの書き方、
ReadValueAsyncがBadで例外を出すこと、拒否と信頼のストア、サーバーが既定でNoneを提供しないこと、サンプルのポート番号。 ↩ ↩2 ↩3 ↩4 ↩5 -
FreeOpcUa, asyncua(PyPI). 模擬の装置サーバーに使ったPythonのOPC UAライブラリ。 ↩
-
OPC Foundation, OPC 10000-4 v1.05, 5.9.2 Browse. ノードの参照をたどるサービス。 ↩
-
OPC Foundation, OPC 10000-3 v1.05, 8.2.2 NamespaceIndex. 名前空間のURIをサービスの中では数値で表し、その数値がNamespaceArrayの位置に当たること。 ↩
-
OPC Foundation, OPC 10000-4 v1.05, 5.11 Attribute Service Set. ReadとWriteのサービス。 ↩
-
OPC Foundation, OPC 10000-4 v1.05, 7.11 DataValue. 値、状態コード、SourceTimestamp、ServerTimestampの定義。 ↩ ↩2 ↩3
-
OPC Foundation, OPC 10000-4 v1.05, 7.38.1 StatusCode General. 上位2ビットが示すGood・Uncertain・Bad。 ↩ ↩2
-
OPC Foundation, OPC 10000-4 v1.05, 5.13.1.2 Sampling interval. サンプリング間隔がベストエフォートの周期であること。 ↩ ↩2
-
OPC Foundation, OPC 10000-4 v1.05, 7.22.2 DataChangeFilter. フィルターがなければ値か状態の変化で通知すること。 ↩
-
OPC Foundation, OPC 10000-4 v1.05, 5.13.1.5 Queue parameters. キューの大きさ、捨て方、Overflowのビット。 ↩ ↩2 ↩3
-
OPC Foundation, OPC 10000-4 v1.05, 5.14.1.1 Subscription model. パブリッシング間隔とキープアライブ。 ↩
-
OPC Foundation, UA-.NETStandard 2.0.1: Subscriptions and Monitored Items Service Set.
SubscribeDataChangesAsyncとMonitoredItemOptionsのサンプリング間隔・キューの大きさ。 ↩ -
OPC Foundation, OPC 10000-4 v1.05, 7.20 MessageSecurityMode. None・Sign・SignAndEncryptの意味。 ↩
-
OPC Foundation, UA-.NETStandard 2.0.1: Certificates. rejectedのストアから信頼するストアへ移す運用と、
AutoAcceptUntrustedCertificatesを本番で使わないこと。 ↩ ↩2 -
OPC Foundation, OPC 10000-4 v1.05, 7.40 UserIdentityToken parameters. 匿名、ユーザー名、X.509、発行済みトークンの4種類。 ↩
-
OPC Foundation, OPC 10000-4 v1.05, 7.14 EndpointDescription. エンドポイントが受け付ける利用者のトークンの一覧(userIdentityTokens)。 ↩
-
OPC Foundation, UA-.NETStandard 2.0.1: Sessions, Reconnection, and Subscription Engines.
ManagedSessionが再接続の仕組みを内蔵していること。 ↩ -
OPC Foundation, OPC 10000-4 v1.05, 5.14.6.1 Republish. サブスクリプションの再送用のキューから通知のメッセージをもう一度送ってもらうサービス。 ↩
-
Modbus-IDA, MODBUS Application Protocol Specification V1.1b(シカゴ大学のサーバーに置かれた写し). 4.3節のデータモデル(Discrete Input・Coil・Input Register・Holding Register)。 ↩
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
引数はなぜ壊れるか ── Windowsのコマンドライン引数の規則
Windowsでは引数の配列は存在せず、CreateProcessに渡るのは1本の文字列で、分割は受け取り側が行います。CommandLineToArgvW・CRT・.NETの分割規則と、.NETのArgumentList・C++での正しい組み立て方を解説します。
マルチスレッドの実務ベストプラクティス .NET編 ── スレッドを増やす前に決めておくこと
「スレッドを立てたら、たまに落ちる・固まる」を防ぐ設計の定石を.NET/C#向けに整理。スレッドを自分で作らずTaskに乗る、共有可変状態を減らす、ロックの規律、CancellationTokenでの停止設計、UIスレッドの扱いまで解説します。
WMI/CIMをC#・PowerShellから使う ── ハードウェア情報取得・プロセス監視・リモート照会の実務ガイド
PCのシリアル番号取得、ディスク空き監視、プロセス起動検知の定番がWMI/CIMです。Get-CimInstance等のCIMコマンドレットの使い方と旧Get-WmiObjectからの移行、C#のSystem.ManagementとCIM APIの使い分け、実例レシピと落と...
MSMQはいつまで使えるのか ── 「非推奨ですらない」レガシーキューの移行判断
MSMQは公式の非推奨リストに載っていない一方、System.Messagingは.NET Frameworkにしか存在せず、.NETへの移行を妨げます。廃止の噂と実際の現在地を事実で整理し、使い続ける・移行するの判断基準と移行先の選び方をまとめます。
Windowsのセッション分離をどう理解するか ── Session 0・RDP・複数ユーザー同時実行
Windowsアプリ開発者が混乱しがちな「セッション」の概念を整理します。サービスがUIを出せないSession 0分離の理由、RDP接続時のセッションの挙動、名前付きオブジェクトのセッション分離、共有PC・RDS環境でありがちな設計ミスまでを実務目線で解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
装置からデータを集めるWindowsアプリを、購読の設定、再接続、タイムスタンプと状態の記録まで含めて作る場合に、Windowsアプリ開発として扱えます。
技術相談・設計レビュー
OPC UAで集めるか、Modbus/TCPやメーカーの通信を使うか、証明書をどう管理するかを決めたい場合に、技術相談・設計レビューとして扱えます。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- OPC UAとOPC Classic(OPC DA)は何が違いますか?
- OPC ClassicはWindowsのCOM/DCOMでデータをやり取りする仕様です。OPC UAは2008年に公開された、特定のOSに依存しない仕様で、Classicの機能をまとめて扱います。OPC UAのクライアントはWindows以外でも動き、DCOMの設定も要りません。
- OPC UAで値を集めるとき、一定間隔で読み取ればよいですか?
- 数が少なく間隔が長ければ読み取りでも集められます。値の変化を追うなら、サブスクリプションでサーバーに標本化を任せ、変化を通知してもらう方法があります。どちらでも、値と一緒に返る状態コードとタイムスタンプを記録します。
- 接続するとBadCertificateUntrustedになるのはなぜですか?
- クライアントがサーバーの証明書をまだ信頼していないためです。OPC Foundationの.NETのライブラリは、拒否した証明書をrejectedのストアに保存します。証明書の拇印をサーバーの管理者と確かめてから、trustedのストアへ移します。証明書を自動で受け入れる設定は、開発環境に限ります。
- OPC UAのポート番号は何番ですか?
- IANAには4840/TCPがopcua-tcpとして登録されています。ただし実際のサーバーは別のポートを使うこともあるので、装置の設定で確かめます。サーバーがWindowsで動いていればWindowsファイアウォールで、途中にネットワーク機器があればその機器で、そのポートへの通信を許可する必要があります。