0%

c# 讀取 OPC UA 筆記

 

莫名其妙被抓來搞 OPC Server, 在工廠做 MES 沒遇到, 離職才遇到也是很妙 LOL
恰巧之前有在 Udemy 上面買過相關課程, 順手筆記下免得忘光, 對他還是很陌生

首先要安裝模擬用的 Prosys OPC UA Simulation Server 特別注意需要用公司信箱或機構信箱申請才能安裝, 我用學校驗證也失敗 Orz
接著安裝 Client UaExpert 這樣才能監控數值, 還有測試看看連線之類的問題
程式碼則可以到 OPCFoundation 下載範例來看, 整個非常複雜.. 還好現在有 ChatGPT 不然要搞起來真的會看到發瘋

我最終目的是希望把數值讀出來寫入 localdb 需要安裝的套件如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.1" />

<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua" Version="1.5.376.244" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.376.244" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.376.244" />
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.376.244" />

<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.3" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
</ItemGroup>

他程式啟動的時候會去讀 xml 這裡最好參考官方的 xml 複製來修改, 不要相信 ChatGPT 不然會鬼打牆很久
這邊最噁心的就是憑證這塊 %CommonApplicationData% 這個變數表示 C:\ProgramData
所以憑證會放在 C:\ProgramData\OPC Foundation\pki 底下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<ApplicationConfiguration
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:ua="http://opcfoundation.org/UA/2008/02/Types.xsd"
xmlns="http://opcfoundation.org/UA/SDK/Configuration.xsd"
>
<ApplicationName>OPC UA Client Demo</ApplicationName>
<ApplicationUri>urn:localhost:OPCUAClientDemo</ApplicationUri>
<ProductUri>urn:opcua:client:demo</ProductUri>
<!-- 正確的 Enum 值 -->
<ApplicationType>Client_1</ApplicationType>

<SecurityConfiguration>

<!-- Where the application instance certificate is stored (MachineDefault) -->
<ApplicationCertificate>
<StoreType>Directory</StoreType>
<StorePath>%CommonApplicationData%\OPC Foundation\pki\own</StorePath>
<SubjectName>CN=OPC UA Client Demo, C=US, S=Arizona, O=SomeCompany, DC=localhost</SubjectName>
</ApplicationCertificate>

<!-- Where the issuer certificate are stored (certificate authorities) -->
<TrustedIssuerCertificates>
<StoreType>Directory</StoreType>
<StorePath>%CommonApplicationData%\OPC Foundation\pki\issuer</StorePath>
</TrustedIssuerCertificates>

<!-- Where the trust list is stored -->
<TrustedPeerCertificates>
<StoreType>Directory</StoreType>
<StorePath>%CommonApplicationData%\OPC Foundation\pki\trusted</StorePath>
</TrustedPeerCertificates>

<!-- The directory used to store invalid certficates for later review by the administrator. -->
<RejectedCertificateStore>
<StoreType>Directory</StoreType>
<StorePath>%CommonApplicationData%\OPC Foundation\pki\rejected</StorePath>
</RejectedCertificateStore>
</SecurityConfiguration>


<ClientConfiguration>
<DefaultSessionTimeout>60000</DefaultSessionTimeout>
</ClientConfiguration>


<TraceConfiguration>
<OutputFilePath>Logs\OpcUaClientDemo.log.txt</OutputFilePath>
<DeleteOnLoad>true</DeleteOnLoad>
<!-- Show Only Errors -->
<!-- <TraceMasks>1</TraceMasks> -->
<!-- Show Only Security and Errors -->
<!-- <TraceMasks>513</TraceMasks> -->
<!-- Show Only Security, Errors and Trace -->
<TraceMasks>515</TraceMasks>
<!-- Show Only Security, COM Calls, Errors and Trace -->
<!-- <TraceMasks>771</TraceMasks> -->
<!-- Show Only Security, Service Calls, Errors and Trace -->
<!-- <TraceMasks>523</TraceMasks> -->
<!-- Show Only Security, ServiceResultExceptions, Errors and Trace -->
<!-- <TraceMasks>519</TraceMasks> -->
</TraceConfiguration>


</ApplicationConfiguration>

接著看程式碼比較重要的部分

LoadApplicationConfiguration 會去讀你的 xml 設定, 記得要把 csproj 裡面的 xml 設定成 copy always

1
2
3
4
5
6
7
8
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Test.Config.xml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>

AutoAcceptUntrustedCertificates 跟憑證有關, 開發可以設定 true 他應該會略過, 不過這裡就直接設定 false 吧
然後 CheckApplicationInstanceCertificate 會幫你生出憑證來

1
2
3
4
5
6
7
8
9
10
11
// 上線環境請改成 false
application.ApplicationConfiguration.SecurityConfiguration.AutoAcceptUntrustedCertificates = false;

// 確保 Client 憑證存在
bool haveCert = await application.CheckApplicationInstanceCertificate(false, 0);
if (!haveCert)
{
Console.WriteLine("無法建立憑證,請檢查寫入權限或路徑設定。");
return;
}
Console.WriteLine("憑證已確認或自動生成。");

接著比較詭異的就是訂閱的部分, 訂閱後一定要用 Console.ReadKey 他才會正常跑, 不然就要寫個 Task.Delay 一直擺著

1
2
3
4
5
6
7
session.AddSubscription(subscription);
subscription.Create();

//這裡如果用 ReadKey 也可以, 不然就要用下面的 Delay
Console.WriteLine("開始監控 Simulation Folder 下所有數值...");
//Console.WriteLine("按任意鍵退出...");
//Console.ReadKey();

最後就是寫入 localdb 的部分, 如果每次取得數值時都寫入的話, 就會變成只有當下該數值欄位才會有 data, 其他欄位都是 null

1
2
3
4
5
| id | counter | Square | Random | Sawtooth |
|----|---------|--------|--------|----------|
| 1 | 1 | null | null | null |
| 2 | null | 3 | null | null |
| 3 | null | null | -3.3 | null |

所以用 ConcurrentDictionary 去保存那段期間內的數值, 最後寫入時才會像是平時我們預期的 data

1
2
3
4
5
6
item.Notification += (monItem, args) =>
{
//關鍵, 這樣才能保證每次把一票資料寫入 db, 不然會每次有很多欄位是 null
foreach (var value in monItem.DequeueValues())
latestValues[displayName] = value.Value;
};

另外還有個詭異的點, 就是拿 MonitoredItem 名稱有可能拿到這種 1001 1002 1003 的數字, 搞了半天發現要這樣寫才能拿到想要的變數名稱

1
2
3
//這樣才可以拿到真正名稱
var node = session.ReadNode(nodeId);
string displayName = node.DisplayName.Text;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
using Dapper;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Serilog;
using System.Collections.Concurrent;


IConfiguration configuration = null;
//設定 appsettings 用
configuration = new ConfigurationBuilder()
.SetBasePath(AppDomain.CurrentDomain.BaseDirectory) // 設定根目錄
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.Build();

// 設定 Serilog
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.Enrich.FromLogContext()
.CreateLogger();


// 1. 建立 OPC UA Application
var application = new ApplicationInstance
{
ApplicationName = "OPC UA Client Demo",
ApplicationType = ApplicationType.Client
};

await application.LoadApplicationConfiguration("Test.Config.xml", false);

// 上線環境請改成 false
application.ApplicationConfiguration.SecurityConfiguration.AutoAcceptUntrustedCertificates = false;

// 確保 Client 憑證存在
bool haveCert = await application.CheckApplicationInstanceCertificate(false, 0);
if (!haveCert)
{
Console.WriteLine("無法建立憑證,請檢查寫入權限或路徑設定。");
return;
}
Console.WriteLine("憑證已確認或自動生成。");

// 連線 OPCServer
var opcServerUrl = configuration["OPCServerUrl"];
var endpointURL = opcServerUrl;

var endpoint = CoreClientUtils.SelectEndpoint(endpointURL, false);
var config = EndpointConfiguration.Create(application.ApplicationConfiguration);
var endpointConfig = new ConfiguredEndpoint(null, endpoint, config);

using var session = await Session.Create(
application.ApplicationConfiguration,
endpointConfig,
false,
"MySession",
60000,
null,
null
);

Console.WriteLine("已連線到 OPC UA Server");

// 找到 Simulation Folder NodeId
NodeId simulationFolderId = null;
ReferenceDescriptionCollection references;
byte[] continuationPoint;

session.Browse(
null,
null,
ObjectIds.ObjectsFolder,
0u,
BrowseDirection.Forward,
ReferenceTypeIds.Organizes,
true,
(uint)NodeClass.Object,
out continuationPoint,
out references
);

foreach (var rd in references)
{
if (rd.DisplayName.Text == "Simulation")
{
simulationFolderId = (NodeId)rd.NodeId;
Console.WriteLine("找到 Simulation Folder: " + simulationFolderId);
break;
}
}

if (simulationFolderId == null)
{
Console.WriteLine("找不到 Simulation Folder");
return;
}

// 保存最後數值用
var latestValues = new ConcurrentDictionary<string, object>();

// 建立 Subscription 持續監控
var subscription = new Subscription(session.DefaultSubscription)
{
PublishingInterval = 1000,
PublishingEnabled = true
};

var variableNodes = new List<NodeId>();
FindVariables(session, simulationFolderId, variableNodes);

foreach (var nodeId in variableNodes)
{
//這樣才可以拿到真正名稱
var node = session.ReadNode(nodeId);
string displayName = node.DisplayName.Text;

var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = nodeId,
AttributeId = Attributes.Value,
DisplayName = nodeId.Identifier.ToString(),
SamplingInterval = 500
};

item.Notification += (monItem, args) =>
{
//關鍵, 這樣才能保證每次把一票資料寫入 db, 不然會每次有很多欄位是 null
foreach (var value in monItem.DequeueValues())
latestValues[displayName] = value.Value;
};

subscription.AddItem(item);
}

session.AddSubscription(subscription);
subscription.Create();

//這裡如果用 ReadKey 也可以, 不然就要用下面的 Delay
Console.WriteLine("開始監控 Simulation Folder 下所有數值...");
//Console.WriteLine("按任意鍵退出...");
//Console.ReadKey();

_ = Task.Run(async () =>
{
while (true)
{
await Task.Delay(1000); // 每秒寫一次

if (latestValues.IsEmpty) continue;

var sim = new Simulation
{
Timestamp = DateTime.Now,
Counter = latestValues.ContainsKey("Counter") ? Convert.ToInt32(latestValues["Counter"]) : 0,
Random = latestValues.ContainsKey("Random") ? Convert.ToDouble(latestValues["Random"]) : (double?)null,
Sawtooth = latestValues.ContainsKey("Sawtooth") ? Convert.ToDouble(latestValues["Sawtooth"]) : (double?)null,
Sinusoid = latestValues.ContainsKey("Sinusoid") ? Convert.ToDouble(latestValues["Sinusoid"]) : (double?)null,
Square = latestValues.ContainsKey("Square") ? Convert.ToDouble(latestValues["Square"]) : (double?)null,
Triangle = latestValues.ContainsKey("Triangle") ? Convert.ToDouble(latestValues["Triangle"]) : (double?)null
};

using var connection = new SqlConnection(configuration.GetConnectionString("SimulationDatabase"));
string sql = @"
INSERT INTO Simulation
(Timestamp, Counter, Random, Sawtooth, Sinusoid, Square, Triangle)
VALUES (@Timestamp, @Counter, @Random, @Sawtooth, @Sinusoid, @Square, @Triangle)";
connection.Execute(sql, sim);

Log.Information("寫入資料 {@Simulation}", sim);
}
});


// 使用 CancellationToken 等待 Ctrl+C 退出
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (s, e) =>
{
e.Cancel = true;
cts.Cancel();
};

try
{
await Task.Delay(-1, cts.Token);
}
catch (TaskCanceledException) { }

Console.WriteLine("程式已結束。");

// 遞迴找 Variable NodeId
void FindVariables(Session session, NodeId nodeId, List<NodeId> variableNodes)
{
session.Browse(
null, null, nodeId, 0u,
BrowseDirection.Forward,
ReferenceTypeIds.HierarchicalReferences,
true,
(uint)NodeClass.Object | (uint)NodeClass.Variable,
out byte[] cp,
out ReferenceDescriptionCollection refs
);

foreach (var rd in refs)
{
var childId = (NodeId)rd.NodeId;
if (rd.NodeClass == NodeClass.Variable)
{
variableNodes.Add(childId);
}
else if (rd.NodeClass == NodeClass.Object)
{
FindVariables(session, childId, variableNodes);
}
}
}

appsettings.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
"OPCServerUrl": "opc.tcp://yourserver:53530/OPCUA/SimulationServer",
"ConnectionStrings": {
"SimulationDatabase": "Server=(localdb)\\MSSQLLocalDB;Database=Test;Trusted_Connection=True;"
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
}

},
{
"Name": "File",
"Args": {
"path": "logs/log-.txt",
"rollingInterval": "Day",
"outputTemplate": "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
}
}
]
}
}

後面發現實際在跟 OPC UA Server 串的時候會有憑證問題, 可以參考以下解法

如果遇到嚴格一點的連線需要先在 server 的 users 設定帳號密碼, 並且在 Endpoint 的頁籤 Security ModesNone 取消勾選, 並重啟
連線程式碼則需要調整如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var opcServerUrl = configuration["OPCServerUrl"];
var endpointURL = opcServerUrl;

var endpoint = CoreClientUtils.SelectEndpoint(endpointURL, true);
var config = EndpointConfiguration.Create(application.ApplicationConfiguration);
var endpointConfig = new ConfiguredEndpoint(null, endpoint, config);

var userIdentity = new UserIdentity("test", "test");

using var session = await Session.Create(
application.ApplicationConfiguration,
endpointConfig,
false,
"MySession",
60000,
userIdentity,
null
);

接著把 server 的憑證 SimulationServer@yourpcname.der 搬進去 client 信任的資料夾 C:\ProgramData\OPC Foundation\pki\trusted\certs
server 憑證 SimulationServer@yourpcname.der 路徑可以在 certificate 頁籤找到

接著嘗試第一次連線 client 之憑證會出現在以下資料夾內 C:\Users\yourusername\.prosysopc\prosys-opc-ua-simulation-server\PKI\CA\rejected
可以在 certificate 頁籤對著憑證按右鍵改為 trust 就搞定了

後來又遇到更機車的 server 支援的 endpoint 比較少, 只好先一一列出, 且憑證建立還要選 2048 bit, 所以改用以下方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// ---------------------- 建立 OPC UA Session ----------------------
var application = new ApplicationInstance
{
ApplicationName = "OPC UA Client Demo",
ApplicationType = ApplicationType.Client
};

await application.LoadApplicationConfiguration("Test.Config.xml", false);

// 自動接受憑證(可開發用,正式環境請改為手動匯入)
application.ApplicationConfiguration.SecurityConfiguration.AutoAcceptUntrustedCertificates = true;

// 自訂憑證驗證事件(允許 SHA1 憑證,視情況使用)
application.ApplicationConfiguration.CertificateValidator.CertificateValidation += (s, e) =>
{
Console.WriteLine($"⚠️ 憑證驗證: {e.Error.StatusCode}, Subject: {e.Certificate.Subject}");
e.Accept = true;
};


// 檢查並建立應用程式憑證(至少 2048 bits)
bool haveCert = await application.CheckApplicationInstanceCertificate(false, 2048);
if (!haveCert)
{
Console.WriteLine("無法建立有效憑證");
return;
}

// 取得所有 endpoint,篩選符合 Basic256Sha256 + Sign
string opcServerUrl = configuration["OPCServer:OPCServerUrl"]; // 例如 "opc.tcp://192.168.1.10:4840"
// 建立 DiscoveryClient 並取得所有 endpoint
using var discoveryClient = DiscoveryClient.Create(new Uri(opcServerUrl));
var endpoints = discoveryClient.GetEndpoints(null).ToList();

// 篩選 Basic256Sha256 + Sign 的 endpoint
var selectedEndpoint = endpoints.Find(e =>
e.SecurityPolicyUri == SecurityPolicies.Basic256 &&
e.SecurityMode == MessageSecurityMode.Sign);

if (selectedEndpoint == null)
{
Console.WriteLine("找不到符合 Basic256Sha256 + Sign 的 endpoint");
foreach (var ep in endpoints)
{
Console.WriteLine($"🔎 {ep.EndpointUrl} | {ep.SecurityPolicyUri} | {ep.SecurityMode}");
}
return;
}

Console.WriteLine($"選擇 endpoint: {selectedEndpoint.EndpointUrl} | {selectedEndpoint.SecurityPolicyUri} | {selectedEndpoint.SecurityMode}");

var endpointConfig = new ConfiguredEndpoint(null, selectedEndpoint,
EndpointConfiguration.Create(application.ApplicationConfiguration));

// 設定連線的用戶認證,改成你的帳密或 UserIdentity.Anonymous
var userIdentity = new UserIdentity("admin", "admin");

// 建立 Session 連線
using var session = await Session.Create(
application.ApplicationConfiguration,
endpointConfig,
false,
"MySession",
60000,
userIdentity,
null);

Console.WriteLine("成功連線 OPC UA Server");
關閉