@tier0/opcua-client 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/BENCHMARK.md +53 -2
  2. package/README.md +180 -14
  3. package/THIRD_PARTY_NOTICES.md +20 -0
  4. package/binding.gyp +1 -1
  5. package/lib/client.js +48 -7
  6. package/lib/connection-registry.js +68 -0
  7. package/lib/native.js +25 -10
  8. package/lib/points.js +187 -21
  9. package/lib/prebuild.js +42 -0
  10. package/lib/security-file-store.js +119 -0
  11. package/lib/security.js +96 -0
  12. package/node_modules/node-addon-api/LICENSE.md +9 -0
  13. package/node_modules/node-addon-api/README.md +95 -0
  14. package/node_modules/node-addon-api/common.gypi +21 -0
  15. package/node_modules/node-addon-api/except.gypi +25 -0
  16. package/node_modules/node-addon-api/index.js +14 -0
  17. package/node_modules/node-addon-api/napi-inl.deprecated.h +186 -0
  18. package/node_modules/node-addon-api/napi-inl.h +7244 -0
  19. package/node_modules/node-addon-api/napi.h +3420 -0
  20. package/node_modules/node-addon-api/node_addon_api.gyp +42 -0
  21. package/node_modules/node-addon-api/node_api.gyp +9 -0
  22. package/node_modules/node-addon-api/noexcept.gypi +26 -0
  23. package/node_modules/node-addon-api/nothing.c +0 -0
  24. package/node_modules/node-addon-api/package-support.json +21 -0
  25. package/node_modules/node-addon-api/package.json +481 -0
  26. package/node_modules/node-addon-api/tools/README.md +73 -0
  27. package/node_modules/node-addon-api/tools/check-napi.js +99 -0
  28. package/node_modules/node-addon-api/tools/clang-format.js +71 -0
  29. package/node_modules/node-addon-api/tools/conversion.js +301 -0
  30. package/nodes/browse.html +27 -2
  31. package/nodes/connection.html +293 -6
  32. package/nodes/connection.js +160 -23
  33. package/nodes/read.html +42 -3
  34. package/nodes/read.js +51 -12
  35. package/nodes/subscribe.html +57 -5
  36. package/nodes/subscribe.js +386 -99
  37. package/nodes/write.html +29 -2
  38. package/nodes/write.js +2 -1
  39. package/package.json +15 -4
  40. package/prebuilds/linux-x64-musl/opcua_open62541.node +0 -0
  41. package/scripts/build-offline-prebuild.sh +15 -0
  42. package/scripts/build-open62541.sh +14 -4
  43. package/scripts/build-test-server.sh +23 -0
  44. package/scripts/install.js +28 -0
  45. package/scripts/verify-offline-package.js +20 -0
  46. package/scripts/verify-runtime.js +20 -0
  47. package/src/addon.cc +707 -119
  48. package/vendor/open62541-LICENSE +373 -0
  49. package/vendor/open62541-v1.4.16.tar.gz +0 -0
@@ -2,27 +2,76 @@
2
2
  .opcua-advanced { margin: 8px 0 12px; }
3
3
  .opcua-advanced summary { cursor: pointer; font-weight: 600; padding: 6px 0; }
4
4
  .opcua-standard-note { margin: 0 0 10px 105px; color: var(--red-ui-form-text-color, #666); font-size: 0.9em; }
5
+ .opcua-discovery-result { margin: 6px 0 8px 105px; max-width: 70%; }
6
+ .opcua-discovery-result select { width: 100%; }
7
+ .opcua-security-path { width: calc(70% - 88px) !important; }
8
+ .opcua-security-list { display: inline-block; width: 70%; vertical-align: top; }
9
+ .opcua-security-list textarea { width: 100%; box-sizing: border-box; }
10
+ .opcua-security-upload { margin-top: 4px; }
11
+ .opcua-hidden-file { display: none !important; }
5
12
  </style>
6
13
  <script type="text/html" data-template-name="tier0-opcua-connection">
7
14
  <div class="form-row"><label for="node-config-input-endpoint">Endpoint</label><input type="text" id="node-config-input-endpoint" placeholder="opc.tcp://127.0.0.1:4840"></div>
15
+ <div class="form-row"><label></label><button type="button" class="red-ui-button" id="node-config-input-discoverEndpoints">Discover endpoints</button> <button type="button" class="red-ui-button" id="node-config-input-testConnection">Test connection</button></div>
16
+ <div class="opcua-discovery-result" id="node-config-input-discoveryResult" style="display:none"><select id="node-config-input-discoveredEndpoint"></select></div>
17
+ <div class="opcua-standard-note" id="node-config-input-testResult"></div>
8
18
  <div class="form-row"><label for="node-config-input-securityMode">Security</label><select id="node-config-input-securityMode"><option value="None">None</option><option value="Sign">Sign</option><option value="SignAndEncrypt">SignAndEncrypt</option></select></div>
9
- <div class="opcua-standard-note">Standard defaults include automatic reconnect, connectivity checks, and a bounded NodeId cache.</div>
19
+ <div class="opcua-standard-note">Standard defaults include automatic reconnect, connectivity checks, and bounded command/NodeId queues.</div>
10
20
  <details class="opcua-advanced">
11
21
  <summary>Security and advanced connection settings</summary>
12
- <div class="form-row"><label for="node-config-input-securityPolicy">Policy</label><select id="node-config-input-securityPolicy"><option value="None">None</option><option value="Basic256Sha256">Basic256Sha256</option><option value="Aes128Sha256RsaOaep">Aes128Sha256RsaOaep</option><option value="Aes256Sha256RsaPss">Aes256Sha256RsaPss</option></select></div>
22
+ <div class="form-row"><label for="node-config-input-securityPolicy">Policy</label><select id="node-config-input-securityPolicy"><option value="None">None</option><option value="Basic256Sha256">Basic256Sha256</option></select></div>
13
23
  <div class="form-row"><label for="node-config-input-user">User</label><input type="text" id="node-config-input-user"></div>
14
24
  <div class="form-row"><label for="node-config-input-password">Password</label><input type="password" id="node-config-input-password"></div>
15
- <div class="form-row"><label for="node-config-input-certificate">Certificate</label><input type="text" id="node-config-input-certificate"></div>
16
- <div class="form-row"><label for="node-config-input-privateKey">Private key</label><input type="text" id="node-config-input-privateKey"></div>
25
+ <div class="form-row opcua-secure-field"><label for="node-config-input-certificate">Client certificate</label><input type="text" class="opcua-security-path" id="node-config-input-certificate" placeholder="Upload or enter /path/to/client.der"> <button type="button" class="red-ui-button" id="node-config-upload-certificate"><i class="fa fa-upload"></i> Upload</button><input type="file" class="opcua-hidden-file" id="node-config-file-certificate" accept=".der,.pem,.crt,.cer"></div>
26
+ <div class="form-row opcua-secure-field"><label for="node-config-input-privateKey">Client private key</label><input type="text" class="opcua-security-path" id="node-config-input-privateKey" placeholder="Upload or enter /path/to/client-key.pem"> <button type="button" class="red-ui-button" id="node-config-upload-privateKey"><i class="fa fa-upload"></i> Upload</button><input type="file" class="opcua-hidden-file" id="node-config-file-privateKey" accept=".der,.pem,.key"></div>
27
+ <div class="form-row opcua-secure-field"><label for="node-config-input-privateKeyPassword">Key password</label><input type="password" id="node-config-input-privateKeyPassword"></div>
28
+ <div class="form-row opcua-secure-field"><label for="node-config-input-applicationUri">Application URI</label><input type="text" id="node-config-input-applicationUri" placeholder="Must match the client certificate"></div>
29
+ <div class="form-row opcua-secure-field"><label for="node-config-input-trustedCertificates">Trusted certificates</label><div class="opcua-security-list"><textarea id="node-config-input-trustedCertificates" rows="3" placeholder="Upload files or enter one Server/CA path per line"></textarea><button type="button" class="red-ui-button opcua-security-upload" id="node-config-upload-trustedCertificates"><i class="fa fa-upload"></i> Upload files</button><input type="file" multiple class="opcua-hidden-file" id="node-config-file-trustedCertificates" accept=".der,.pem,.crt,.cer"></div></div>
30
+ <div class="form-row opcua-secure-field"><label for="node-config-input-revocationList">Revocation list</label><div class="opcua-security-list"><textarea id="node-config-input-revocationList" rows="2" placeholder="Upload files or enter one optional CRL path per line"></textarea><button type="button" class="red-ui-button opcua-security-upload" id="node-config-upload-revocationList"><i class="fa fa-upload"></i> Upload files</button><input type="file" multiple class="opcua-hidden-file" id="node-config-file-revocationList" accept=".der,.pem,.crl"></div></div>
31
+ <div class="opcua-standard-note opcua-secure-field">Uploaded files are stored under the Node-RED user directory. Runtime-visible paths may still be entered directly.</div>
17
32
  <div class="form-row"><label for="node-config-input-requestedSessionTimeout">Session timeout</label><input type="number" id="node-config-input-requestedSessionTimeout"></div>
18
33
  <div class="form-row"><label for="node-config-input-requestTimeout">Request timeout</label><input type="number" id="node-config-input-requestTimeout"></div>
19
34
  <div class="form-row"><label for="node-config-input-connectivityCheckInterval">Connectivity check</label><input type="number" id="node-config-input-connectivityCheckInterval"></div>
20
35
  <div class="form-row"><label for="node-config-input-reconnectInterval">Reconnect interval</label><input type="number" id="node-config-input-reconnectInterval"></div>
21
36
  <div class="form-row"><label for="node-config-input-maxReconnectInterval">Max reconnect</label><input type="number" id="node-config-input-maxReconnectInterval"></div>
22
37
  <div class="form-row"><label for="node-config-input-nodeIdCacheSize">NodeId cache</label><input type="number" id="node-config-input-nodeIdCacheSize"></div>
38
+ <div class="form-row"><label for="node-config-input-commandQueueSize">Command queue</label><input type="number" id="node-config-input-commandQueueSize"></div>
39
+ <div class="form-row"><label for="node-config-input-allowParallelSession">Parallel Session</label><input type="checkbox" id="node-config-input-allowParallelSession" style="width:auto"></div>
40
+ <div class="opcua-standard-note">Leave disabled and reuse one Connection configuration for one Session. Enable only when a separate Session with equivalent settings is intentional.</div>
23
41
  </details>
24
42
  <div class="form-row"><label for="node-config-input-name">Name</label><input type="text" id="node-config-input-name"></div>
25
43
  </script>
44
+ <script type="text/html" data-help-name="tier0-opcua-connection">
45
+ <section lang="zh-CN">
46
+ <h3>连接(Connection)</h3>
47
+ <p>创建并维护共享的 OPC UA SecureChannel 和 Session,供 Browse、Read、Write、Subscribe 节点复用。配置节点本身没有消息输入或输出。</p>
48
+ <h4>主要配置</h4>
49
+ <ul>
50
+ <li><b>Endpoint</b>:服务器地址,例如 <code>opc.tcp://192.168.1.10:4840</code>。</li>
51
+ <li><b>Discover endpoints</b>:读取服务器公开的安全模式、安全策略和用户认证方式。</li>
52
+ <li><b>Test connection</b>:使用当前配置建立临时连接并验证认证。</li>
53
+ <li><b>Security</b>:支持 None、Sign、SignAndEncrypt;安全模式、策略和认证方式必须与服务器 Endpoint 匹配。</li>
54
+ <li><b>证书</b>:安全连接可上传或指定客户端证书、私钥、信任证书和 CRL。Application URI 应与客户端证书一致。</li>
55
+ </ul>
56
+ <h4>建议</h4>
57
+ <p>一个 Connection 配置对应一条客户端 Session。多个业务节点应复用同一个 Connection,避免为相同 Endpoint 重复创建 Session。节点内置自动重连、连通性检查以及有界命令队列。</p>
58
+ </section>
59
+ <hr>
60
+ <section lang="en">
61
+ <h3>Connection</h3>
62
+ <p>Creates and maintains a shared OPC UA SecureChannel and Session for Browse, Read, Write, and Subscribe nodes. The configuration node itself has no message input or output.</p>
63
+ <h4>Main settings</h4>
64
+ <ul>
65
+ <li><b>Endpoint</b>: Server URL, for example <code>opc.tcp://192.168.1.10:4840</code>.</li>
66
+ <li><b>Discover endpoints</b>: Reads advertised security modes, policies, and user token types.</li>
67
+ <li><b>Test connection</b>: Opens a temporary connection with the current settings and validates authentication.</li>
68
+ <li><b>Security</b>: Supports None, Sign, and SignAndEncrypt. Mode, policy, and identity must match an advertised endpoint.</li>
69
+ <li><b>Certificates</b>: Secure connections can upload or reference a client certificate, private key, trust list, and CRLs. The Application URI should match the client certificate.</li>
70
+ </ul>
71
+ <h4>Recommendation</h4>
72
+ <p>One Connection configuration owns one client Session. Reuse it across nodes targeting the same server instead of creating duplicate Sessions. Automatic reconnect, connectivity checks, and a bounded command queue are enabled.</p>
73
+ </section>
74
+ </script>
26
75
  <script type="text/javascript">
27
76
  RED.nodes.registerType('tier0-opcua-connection', {
28
77
  category: 'config',
@@ -30,15 +79,253 @@
30
79
  endpoint: { value: '', required: true },
31
80
  securityMode: { value: 'None' }, securityPolicy: { value: 'None' },
32
81
  user: { value: '' }, certificate: { value: '' }, privateKey: { value: '' },
82
+ applicationUri: { value: '' }, trustedCertificates: { value: '' }, revocationList: { value: '' },
33
83
  requestedSessionTimeout: { value: 60000, validate: RED.validators.number() },
34
84
  requestTimeout: { value: 10000, validate: RED.validators.number() },
35
85
  connectivityCheckInterval: { value: 5000, validate: RED.validators.number() },
36
86
  reconnectInterval: { value: 5000, validate: RED.validators.number() },
37
87
  maxReconnectInterval: { value: 30000, validate: RED.validators.number() },
38
88
  nodeIdCacheSize: { value: 100000, validate: RED.validators.number() },
89
+ commandQueueSize: { value: 256, validate: RED.validators.number() },
90
+ allowParallelSession: { value: false },
39
91
  name: { value: '' }
40
92
  },
41
- credentials: { password: { type: 'password' } },
42
- label: function () { return this.name || this.endpoint || 'tier0-opcua-connection'; }
93
+ credentials: {
94
+ password: { type: 'password' },
95
+ privateKeyPassword: { type: 'password' }
96
+ },
97
+ label: function () { return this.name || this.endpoint || 'tier0-opcua-connection'; },
98
+ oneditprepare: function () {
99
+ const connectionId = this.id;
100
+ const mode = $('#node-config-input-securityMode');
101
+ const policy = $('#node-config-input-securityPolicy');
102
+ const result = $('#node-config-input-discoveryResult');
103
+ const discovered = $('#node-config-input-discoveredEndpoint');
104
+ const policyName = function (uri) {
105
+ return String(uri || '').split('#').pop() || 'None';
106
+ };
107
+ const updateSecurityFields = function () {
108
+ const secure = mode.val() !== 'None';
109
+ $('.opcua-secure-field').toggle(secure);
110
+ if (!secure) policy.val('None');
111
+ else if (policy.val() === 'None') policy.val('Basic256Sha256');
112
+ };
113
+ mode.on('change', updateSecurityFields);
114
+ updateSecurityFields();
115
+
116
+ const maxSecurityFileSize = 2 * 1024 * 1024;
117
+ const uploadSecurityFile = function (file, kind) {
118
+ return new Promise(function (resolve, reject) {
119
+ if (!connectionId) {
120
+ reject(new Error('Save the Connection node once before uploading security files'));
121
+ return;
122
+ }
123
+ if (file.size > maxSecurityFileSize) {
124
+ reject(new Error(`${file.name} exceeds the 2 MiB security file limit`));
125
+ return;
126
+ }
127
+ const reader = new FileReader();
128
+ reader.onerror = function () {
129
+ reject(new Error(`Cannot read ${file.name}`));
130
+ };
131
+ reader.onload = function () {
132
+ const encoded = String(reader.result || '');
133
+ const separator = encoded.indexOf(',');
134
+ if (separator < 0) {
135
+ reject(new Error(`Cannot encode ${file.name}`));
136
+ return;
137
+ }
138
+ $.ajax({
139
+ url: 'opcua-client/security-files',
140
+ method: 'POST',
141
+ contentType: 'application/json',
142
+ data: JSON.stringify({
143
+ connectionId,
144
+ kind,
145
+ fileName: file.name,
146
+ contentBase64: encoded.slice(separator + 1)
147
+ })
148
+ }).done(function (response) {
149
+ resolve(response.file);
150
+ }).fail(function (xhr) {
151
+ reject(new Error(xhr.responseJSON?.error || `Cannot upload ${file.name}`));
152
+ });
153
+ };
154
+ reader.readAsDataURL(file);
155
+ });
156
+ };
157
+
158
+ const bindSecurityUpload = function (buttonSelector, fileSelector, targetSelector, kind, append) {
159
+ const button = $(buttonSelector);
160
+ const fileInput = $(fileSelector);
161
+ const target = $(targetSelector);
162
+ button.on('click', function () { fileInput.trigger('click'); });
163
+ fileInput.on('change', function () {
164
+ const input = this;
165
+ const files = Array.from(input.files || []);
166
+ if (!files.length) return;
167
+ button.prop('disabled', true);
168
+ files.reduce(function (chain, file) {
169
+ return chain.then(function () {
170
+ return uploadSecurityFile(file, kind);
171
+ }).then(function (uploaded) {
172
+ if (!append) {
173
+ target.val(uploaded.path);
174
+ return;
175
+ }
176
+ const paths = String(target.val() || '').split(/\r?\n|;/)
177
+ .map(function (entry) { return entry.trim(); })
178
+ .filter(Boolean);
179
+ if (paths.indexOf(uploaded.path) < 0) paths.push(uploaded.path);
180
+ target.val(paths.join('\n'));
181
+ });
182
+ }, Promise.resolve()).then(function () {
183
+ RED.notify(`${files.length} OPC UA security file(s) uploaded`, 'success');
184
+ }).catch(function (error) {
185
+ RED.notify(error.message || String(error), 'error');
186
+ }).then(function () {
187
+ input.value = '';
188
+ button.prop('disabled', false);
189
+ });
190
+ });
191
+ };
192
+
193
+ bindSecurityUpload('#node-config-upload-certificate', '#node-config-file-certificate',
194
+ '#node-config-input-certificate', 'clientCertificate', false);
195
+ bindSecurityUpload('#node-config-upload-privateKey', '#node-config-file-privateKey',
196
+ '#node-config-input-privateKey', 'privateKey', false);
197
+ bindSecurityUpload('#node-config-upload-trustedCertificates',
198
+ '#node-config-file-trustedCertificates', '#node-config-input-trustedCertificates',
199
+ 'trustedCertificate', true);
200
+ bindSecurityUpload('#node-config-upload-revocationList',
201
+ '#node-config-file-revocationList', '#node-config-input-revocationList',
202
+ 'revocationList', true);
203
+
204
+ const connectionSettings = function () {
205
+ return {
206
+ connectionId,
207
+ endpoint: String($('#node-config-input-endpoint').val() || '').trim(),
208
+ securityMode: mode.val(),
209
+ securityPolicy: policy.val(),
210
+ user: $('#node-config-input-user').val(),
211
+ password: $('#node-config-input-password').val(),
212
+ certificate: $('#node-config-input-certificate').val(),
213
+ privateKey: $('#node-config-input-privateKey').val(),
214
+ privateKeyPassword: $('#node-config-input-privateKeyPassword').val(),
215
+ applicationUri: $('#node-config-input-applicationUri').val(),
216
+ trustedCertificates: $('#node-config-input-trustedCertificates').val(),
217
+ revocationList: $('#node-config-input-revocationList').val(),
218
+ requestedSessionTimeout: $('#node-config-input-requestedSessionTimeout').val(),
219
+ requestTimeout: $('#node-config-input-requestTimeout').val(),
220
+ connectivityCheckInterval: $('#node-config-input-connectivityCheckInterval').val(),
221
+ reconnectInterval: $('#node-config-input-reconnectInterval').val(),
222
+ maxReconnectInterval: $('#node-config-input-maxReconnectInterval').val(),
223
+ nodeIdCacheSize: $('#node-config-input-nodeIdCacheSize').val(),
224
+ commandQueueSize: $('#node-config-input-commandQueueSize').val()
225
+ };
226
+ };
227
+
228
+ $('#node-config-input-discoverEndpoints').on('click', function () {
229
+ const button = $(this);
230
+ const endpoint = String($('#node-config-input-endpoint').val() || '').trim();
231
+ if (!endpoint) {
232
+ RED.notify('Enter an OPC UA endpoint first', 'warning');
233
+ return;
234
+ }
235
+ button.prop('disabled', true).text('Discovering...');
236
+ $.ajax({
237
+ url: 'opcua-client/endpoints',
238
+ method: 'POST',
239
+ contentType: 'application/json',
240
+ data: JSON.stringify({
241
+ endpoint,
242
+ requestTimeout: Number($('#node-config-input-requestTimeout').val()) || 10000
243
+ })
244
+ }).done(function (response) {
245
+ const allEndpoints = Array.isArray(response.endpoints) ? response.endpoints : [];
246
+ const identityType = String($('#node-config-input-user').val() || '').trim()
247
+ ? 'Username' : 'Anonymous';
248
+ const supported = allEndpoints.filter(function (item) {
249
+ const itemPolicy = policyName(item.securityPolicyUri);
250
+ return itemPolicy === 'None' || itemPolicy === 'Basic256Sha256';
251
+ });
252
+ const matchingIdentity = supported.filter(function (item) {
253
+ return (item.userIdentityTokens || []).some(function (token) {
254
+ return token.tokenType === identityType;
255
+ });
256
+ });
257
+ const endpoints = matchingIdentity.length ? matchingIdentity : supported;
258
+ discovered.empty();
259
+ endpoints.sort(function (left, right) {
260
+ return Number(right.securityLevel || 0) - Number(left.securityLevel || 0);
261
+ }).forEach(function (item, index) {
262
+ const identities = (item.userIdentityTokens || []).map(function (token) {
263
+ return token.tokenType;
264
+ }).filter(function (value, tokenIndex, values) {
265
+ return values.indexOf(value) === tokenIndex;
266
+ }).join(', ');
267
+ $('<option></option>')
268
+ .val(String(index))
269
+ .text(`${item.securityMode} / ${policyName(item.securityPolicyUri)} / ${identities || 'No identity token'}`)
270
+ .data('endpoint', item)
271
+ .appendTo(discovered);
272
+ });
273
+ if (!endpoints.length) {
274
+ RED.notify('The server returned no supported None or Basic256Sha256 endpoint', 'warning');
275
+ result.hide();
276
+ return;
277
+ }
278
+ if (!matchingIdentity.length) {
279
+ RED.notify(`No endpoint advertises ${identityType}; showing other supported endpoints`, 'warning');
280
+ }
281
+ result.show();
282
+ discovered.trigger('change');
283
+ }).fail(function (xhr) {
284
+ RED.notify(xhr.responseJSON?.error || 'Endpoint discovery failed', 'error');
285
+ result.hide();
286
+ }).always(function () {
287
+ button.prop('disabled', false).text('Discover endpoints');
288
+ });
289
+ });
290
+
291
+ discovered.on('change', function () {
292
+ const selected = discovered.find(':selected').data('endpoint');
293
+ if (!selected) return;
294
+ const selectedPolicy = policyName(selected.securityPolicyUri);
295
+ mode.val(selected.securityMode);
296
+ if (selectedPolicy === 'None' || selectedPolicy === 'Basic256Sha256') {
297
+ policy.val(selectedPolicy);
298
+ }
299
+ updateSecurityFields();
300
+ });
301
+
302
+ $('#node-config-input-testConnection').on('click', function () {
303
+ const button = $(this);
304
+ const testResult = $('#node-config-input-testResult');
305
+ const settings = connectionSettings();
306
+ if (!settings.endpoint) {
307
+ RED.notify('Enter an OPC UA endpoint first', 'warning');
308
+ return;
309
+ }
310
+ button.prop('disabled', true).text('Testing...');
311
+ testResult.text('');
312
+ $.ajax({
313
+ url: 'opcua-client/test-connection',
314
+ method: 'POST',
315
+ contentType: 'application/json',
316
+ data: JSON.stringify(settings)
317
+ }).done(function (response) {
318
+ const connection = response.connection || {};
319
+ testResult.text(`Connected: ${connection.securityMode || settings.securityMode} / ${policyName(connection.securityPolicyUri)}`);
320
+ RED.notify('OPC UA connection test succeeded', 'success');
321
+ }).fail(function (xhr) {
322
+ const message = xhr.responseJSON?.error || 'OPC UA connection test failed';
323
+ testResult.text(message);
324
+ RED.notify(message, 'error');
325
+ }).always(function () {
326
+ button.prop('disabled', false).text('Test connection');
327
+ });
328
+ });
329
+ }
43
330
  });
44
331
  </script>
@@ -1,42 +1,178 @@
1
1
  'use strict';
2
2
 
3
- const { OpcUaClient } = require('../lib/client');
3
+ const { OpcUaClient, discoverEndpoints } = require('../lib/client');
4
+ const { ConnectionRegistry } = require('../lib/connection-registry');
5
+ const { SecurityFileStore } = require('../lib/security-file-store');
6
+
7
+ function pathList(value) {
8
+ const source = Array.isArray(value) ? value : String(value || '').split(/\r?\n|;/);
9
+ return source.map((entry) => String(entry || '').trim()).filter(Boolean);
10
+ }
11
+
12
+ function clientOptions(config, credentials = {}) {
13
+ return {
14
+ endpoint: config.endpoint,
15
+ securityMode: config.securityMode || 'None',
16
+ securityPolicy: config.securityPolicy || 'None',
17
+ user: config.user || '',
18
+ password: credentials.password || '',
19
+ certificate: config.certificate || '',
20
+ privateKey: config.privateKey || '',
21
+ privateKeyPassword: credentials.privateKeyPassword || '',
22
+ applicationUri: config.applicationUri || '',
23
+ trustedCertificates: pathList(config.trustedCertificates),
24
+ revocationList: pathList(config.revocationList),
25
+ requestedSessionTimeout: Number(config.requestedSessionTimeout) || 60000,
26
+ requestTimeout: Number(config.requestTimeout) || 10000,
27
+ connectivityCheckInterval: Number.isFinite(Number(config.connectivityCheckInterval))
28
+ ? Math.max(0, Math.floor(Number(config.connectivityCheckInterval)))
29
+ : 5000,
30
+ reconnectInterval: Number(config.reconnectInterval) || 5000,
31
+ maxReconnectInterval: Number(config.maxReconnectInterval) || 30000,
32
+ nodeIdCacheSize: Number.isFinite(Number(config.nodeIdCacheSize))
33
+ ? Math.max(0, Math.floor(Number(config.nodeIdCacheSize)))
34
+ : 100000,
35
+ commandQueueSize: Number.isFinite(Number(config.commandQueueSize))
36
+ ? Math.max(1, Math.floor(Number(config.commandQueueSize)))
37
+ : 256,
38
+ };
39
+ }
4
40
 
5
41
  module.exports = function registerConnectionNode(RED) {
42
+ const connectionRegistry = new ConnectionRegistry();
43
+ const securityFileStore = new SecurityFileStore({ userDir: RED.settings?.userDir });
44
+
45
+ RED.httpAdmin.post(
46
+ '/opcua-client/security-files',
47
+ RED.auth.needsPermission('opcua-client.security-files.write'),
48
+ async (req, res) => {
49
+ try {
50
+ const file = await securityFileStore.save(req.body || {});
51
+ res.send({ file });
52
+ } catch (error) {
53
+ const status = error?.code === 'OPCUA_SECURITY_FILE_TOO_LARGE'
54
+ ? 413
55
+ : String(error?.code || '').startsWith('OPCUA_SECURITY_FILE_') ? 400 : 500;
56
+ res.status(status).send({
57
+ error: error.message || String(error),
58
+ code: error.code || 'OPCUA_SECURITY_FILE_UPLOAD_FAILED',
59
+ });
60
+ }
61
+ }
62
+ );
63
+
64
+ RED.httpAdmin.post(
65
+ '/opcua-client/endpoints',
66
+ RED.auth.needsPermission('opcua-client.discovery'),
67
+ async (req, res) => {
68
+ try {
69
+ const endpoints = await discoverEndpoints({
70
+ endpoint: req.body?.endpoint,
71
+ requestTimeout: req.body?.requestTimeout,
72
+ });
73
+ res.send({ endpoints });
74
+ } catch (error) {
75
+ res.status(error?.code === 'OPCUA_RECONNECTING' ? 503 : 400).send({
76
+ error: error.message || String(error),
77
+ code: error.code || 'OPCUA_DISCOVERY_FAILED',
78
+ });
79
+ }
80
+ }
81
+ );
82
+
83
+ RED.httpAdmin.post(
84
+ '/opcua-client/test-connection',
85
+ RED.auth.needsPermission('opcua-client.test'),
86
+ async (req, res) => {
87
+ const body = req.body || {};
88
+ const deployed = body.connectionId ? RED.nodes.getNode(String(body.connectionId)) : null;
89
+ const deployedOptions = deployed?.client?.options || {};
90
+ const suppliedPassword = body.password && body.password !== '__PWRD__'
91
+ ? String(body.password) : deployedOptions.password;
92
+ const suppliedPrivateKeyPassword = body.privateKeyPassword &&
93
+ body.privateKeyPassword !== '__PWRD__'
94
+ ? String(body.privateKeyPassword) : deployedOptions.privateKeyPassword;
95
+ const options = {
96
+ ...deployedOptions,
97
+ ...clientOptions(body, {
98
+ password: suppliedPassword,
99
+ privateKeyPassword: suppliedPrivateKeyPassword,
100
+ }),
101
+ };
102
+ const client = new OpcUaClient(options);
103
+ client.on('fault', () => {});
104
+ try {
105
+ await client.connect();
106
+ const diagnostics = client.getDiagnostics();
107
+ const state = client.getState();
108
+ await client.disconnect();
109
+ res.send({
110
+ connected: true,
111
+ state,
112
+ connection: diagnostics.connection || {},
113
+ operationLimits: diagnostics.operationLimits || {},
114
+ });
115
+ } catch (error) {
116
+ try { await client.disconnect(); } catch {}
117
+ res.status(400).send({
118
+ connected: false,
119
+ error: error.message || String(error),
120
+ code: error.code || 'OPCUA_CONNECTION_TEST_FAILED',
121
+ });
122
+ }
123
+ }
124
+ );
125
+
6
126
  function OpcUaConnectionNode(config) {
7
127
  RED.nodes.createNode(this, config);
8
128
  const node = this;
9
129
 
10
- node.client = new OpcUaClient({
11
- endpoint: config.endpoint,
12
- securityMode: config.securityMode || 'None',
13
- securityPolicy: config.securityPolicy || 'None',
14
- user: config.user || '',
15
- password: node.credentials?.password || '',
16
- certificate: config.certificate || '',
17
- privateKey: config.privateKey || '',
18
- requestedSessionTimeout: Number(config.requestedSessionTimeout) || 60000,
19
- requestTimeout: Number(config.requestTimeout) || 10000,
20
- connectivityCheckInterval: Number.isFinite(Number(config.connectivityCheckInterval))
21
- ? Math.max(0, Math.floor(Number(config.connectivityCheckInterval)))
22
- : 5000,
23
- reconnectInterval: Number(config.reconnectInterval) || 5000,
24
- maxReconnectInterval: Number(config.maxReconnectInterval) || 30000,
25
- nodeIdCacheSize: Number.isFinite(Number(config.nodeIdCacheSize))
26
- ? Math.max(0, Math.floor(Number(config.nodeIdCacheSize)))
27
- : 100000,
130
+ const options = clientOptions(config, node.credentials);
131
+ const registration = connectionRegistry.register(options);
132
+ node.client = new OpcUaClient(options);
133
+ node.getConnectionGovernance = () => ({
134
+ ...registration.diagnostics(),
135
+ allowParallelSession: config.allowParallelSession === true,
28
136
  });
137
+ node.getDiagnostics = () => ({
138
+ ...node.client.getDiagnostics(),
139
+ connectionGovernance: node.getConnectionGovernance(),
140
+ });
141
+
142
+ if (registration.duplicate && config.allowParallelSession !== true) {
143
+ node.warn(
144
+ `Equivalent OPC UA Connection configuration detected for ${options.endpoint}; ` +
145
+ 'this creates an additional Session. Reuse the existing Connection configuration ' +
146
+ 'or enable "Allow parallel Session" when isolation is intentional.'
147
+ );
148
+ }
29
149
 
30
- node.client.on('fault', (error) => node.warn(`OPC UA connection fault: ${error.message}`));
31
- node.client.on('state', (state) => {
150
+ const onFault = (error) => node.warn(`OPC UA connection fault: ${error.message}`);
151
+ const onState = (state) => {
32
152
  if (state.state === 'failed') node.warn(`OPC UA connection failed: ${state.statusCode}`);
33
- });
34
- node.on('close', async (done) => {
153
+ };
154
+ node.client.on('fault', onFault);
155
+ node.client.on('state', onState);
156
+ node.on('close', async (removed, done) => {
157
+ if (typeof removed === 'function') {
158
+ done = removed;
159
+ removed = false;
160
+ }
161
+ node.client.off('fault', onFault);
162
+ node.client.off('state', onState);
163
+ registration.unregister();
35
164
  try {
36
165
  await node.client.disconnect();
37
166
  } catch (error) {
38
167
  node.warn(`OPC UA disconnect failed: ${error.message}`);
39
168
  }
169
+ if (removed) {
170
+ try {
171
+ await securityFileStore.removeConnection(config.id);
172
+ } catch (error) {
173
+ node.warn(`OPC UA managed security file cleanup failed: ${error.message}`);
174
+ }
175
+ }
40
176
  done();
41
177
  });
42
178
  }
@@ -44,6 +180,7 @@ module.exports = function registerConnectionNode(RED) {
44
180
  RED.nodes.registerType('tier0-opcua-connection', OpcUaConnectionNode, {
45
181
  credentials: {
46
182
  password: { type: 'password' },
183
+ privateKeyPassword: { type: 'password' },
47
184
  },
48
185
  });
49
186
  };
package/nodes/read.html CHANGED
@@ -36,14 +36,51 @@
36
36
  </div>
37
37
  <details class="opcua-advanced">
38
38
  <summary>Advanced settings</summary>
39
+ <div class="form-row"><label for="node-input-indexRange">Index range</label><input type="text" id="node-input-indexRange" placeholder="For example 0:9 or 0:1,2:3"></div>
39
40
  <div class="form-row"><label for="node-input-batchSize">Batch size</label><input type="number" id="node-input-batchSize"></div>
40
41
  <div class="form-row"><label for="node-input-outputMode">Output</label><select id="node-input-outputMode"><option value="batch">Batch</option><option value="split">One message per point</option></select></div>
42
+ <div class="form-row"><label for="node-input-resultDetail">Result detail</label><select id="node-input-resultDetail"><option value="compact">Compact</option><option value="value">Value only</option><option value="full">Full metadata</option></select></div>
41
43
  </details>
42
44
  <div class="form-row"><label for="node-input-name">Name</label><input type="text" id="node-input-name"></div>
43
45
  </script>
44
46
  <script type="text/html" data-help-name="tier0-opcua-read">
45
- <p>Add NodeIds to the point list or import JSON/CSV. Imported points are parsed and shown in the same list.</p>
46
- <p>CSV requires a <code>nodeId</code> header. A JSON/CSV point list may also arrive dynamically in <code>msg.payload</code>.</p>
47
+ <section lang="zh-CN">
48
+ <h3>读取(Read)</h3>
49
+ <p>每收到一条输入消息执行一次 OPC UA Read。它不是定时器;读取频率由上游 Inject、Function 或其他节点的触发频率决定。</p>
50
+ <h4>输入</h4>
51
+ <ul>
52
+ <li>可在编辑器中添加 NodeId,或导入 JSON/CSV 点表;CSV 必须包含 <code>nodeId</code> 列。</li>
53
+ <li>也可通过 <code>msg.payload</code> 或 <code>msg.items</code> 动态传入点表。</li>
54
+ <li>单点读取可使用节点配置、<code>msg.nodeId</code> 或 <code>msg.topic</code>。</li>
55
+ <li><code>indexRange</code> 可读取数组或矩阵的指定范围。</li>
56
+ </ul>
57
+ <h4>输出</h4>
58
+ <ul>
59
+ <li><b>Compact</b>:NodeId/alias、值、状态码和源时间戳,推荐用于采集。</li>
60
+ <li><b>Value only</b>:批量时按点表顺序输出值数组,单点时输出标量。</li>
61
+ <li><b>Full metadata</b>:同时输出完整点表描述和客户端诊断,适合排障。</li>
62
+ </ul>
63
+ <p>大点表会按照 Batch size 和服务器 OperationLimits 自动拆分为批量服务请求,不会逐点建立连接。</p>
64
+ </section>
65
+ <hr>
66
+ <section lang="en">
67
+ <h3>Read</h3>
68
+ <p>Executes one OPC UA Read for each input message. It is not a timer; the upstream Inject, Function, or other node controls the read frequency.</p>
69
+ <h4>Input</h4>
70
+ <ul>
71
+ <li>Add NodeIds in the editor or import a JSON/CSV point list. CSV requires a <code>nodeId</code> column.</li>
72
+ <li>A dynamic point list may arrive in <code>msg.payload</code> or <code>msg.items</code>.</li>
73
+ <li>A single point may come from the node setting, <code>msg.nodeId</code>, or <code>msg.topic</code>.</li>
74
+ <li>Use <code>indexRange</code> to read part of an array or matrix.</li>
75
+ </ul>
76
+ <h4>Output</h4>
77
+ <ul>
78
+ <li><b>Compact</b>: NodeId/alias, value, status code, and source timestamp. Recommended for collection flows.</li>
79
+ <li><b>Value only</b>: A value array in point-list order for batch reads, or a scalar for one point.</li>
80
+ <li><b>Full metadata</b>: Full point descriptors and client diagnostics for troubleshooting.</li>
81
+ </ul>
82
+ <p>Large point lists are split according to Batch size and server OperationLimits. The node does not create one connection per point.</p>
83
+ </section>
47
84
  </script>
48
85
  <script type="text/javascript">
49
86
  RED.nodes.registerType('tier0-opcua-read', {
@@ -51,8 +88,10 @@
51
88
  defaults: {
52
89
  connection: { value: '', type: 'tier0-opcua-connection', required: true },
53
90
  nodeId: { value: '' }, pointList: { value: '' }, pointListFileName: { value: '' },
91
+ indexRange: { value: '' },
54
92
  batchSize: { value: 500, validate: RED.validators.number() },
55
- outputMode: { value: 'batch' }, name: { value: '' }
93
+ outputMode: { value: 'batch' }, resultDetail: { value: 'compact' },
94
+ name: { value: '' }
56
95
  },
57
96
  oneditprepare: function () {
58
97
  const points = new Map();