0%

c# 讀取 OPC UA 筆記

 

莫名其妙被抓來稿 OPC Server 恰巧之前有在 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}"
}
}
]
}
}
關閉