@tier0/opcua-client 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -178,19 +178,34 @@ finishes.
178
178
 
179
179
  Node editors show only the standard settings by default. Connection security/timeouts/cache, Browse limits/paging, Read/Write batching/output, and Subscribe queue/deadband tuning remain available under a closed **Advanced settings** section. Existing flows retain their stored advanced values.
180
180
 
181
+ ## Server compatibility profiles
182
+
183
+ Connection nodes default to the standard OPC UA profile. Select **Siemens
184
+ WinCC** for classic WinCC or Unified servers that do not advertise usable
185
+ OperationLimits: Read, Write, and MonitoredItem service calls then use a
186
+ conservative fallback of 200 items per request, while advertised non-zero
187
+ server limits still take precedence. Subscribe nodes use at most 1000
188
+ MonitoredItems per Subscription and remove Deadband filters because WinCC does
189
+ not support `ModifyMonitoredItems`/Deadband behavior consistently. The selected
190
+ profile and effective subscription behavior are included in status diagnostics.
191
+
181
192
  ## Secure connections
182
193
 
183
194
  Connection nodes support `None/None` and `Sign` or `SignAndEncrypt` with
184
- `Basic256Sha256`. Secure connections require a client certificate, its private
185
- key, and at least one trusted Server or CA certificate. Trust is strict: an
195
+ `Basic256Sha256`, `Aes128_Sha256_RsaOaep`, or `Aes256_Sha256_RsaPss`. Secure
196
+ connections require a client certificate, its private key, and at least one
197
+ trusted Server or CA certificate. Trust is strict: an
186
198
  empty trust list is rejected instead of silently accepting every Server
187
199
  certificate. Optional CRL files can be supplied as a newline-separated list.
188
200
  Password-protected PEM private keys use the separate Node-RED credential field,
189
201
  so the password is not stored in the normal Flow configuration.
190
202
 
191
- The client Application URI can be configured when the URI embedded in the
192
- client certificate differs from the open62541 default. Username authentication
193
- uses the selected secure endpoint and its advertised UserTokenPolicy.
203
+ Before opening the native client, the package checks certificate validity,
204
+ certificate/private-key matching, RSA key type and minimum 2048-bit strength,
205
+ trusted-certificate validity, and Application URI consistency. When the client
206
+ certificate contains exactly one URI, leaving Application URI empty selects it
207
+ automatically. Username authentication uses the selected secure endpoint and
208
+ its advertised UserTokenPolicy.
194
209
 
195
210
  The Connection editor can call `GetEndpoints`, filters the response to the
196
211
  implemented policies, and selects an endpoint compatible with Anonymous or
@@ -212,7 +227,7 @@ Node-RED Alpine image, the pinned open62541 v1.4.16 source archive, and its
212
227
  not contact npm or GitHub and does not invoke a compiler:
213
228
 
214
229
  ```bash
215
- npm install ./tier0-opcua-client-0.5.0.tgz --offline
230
+ npm install ./tier0-opcua-client-0.6.0.tgz --offline
216
231
  ```
217
232
 
218
233
  The loader uses the bundled native addon directly. Set
package/lib/client.js CHANGED
@@ -3,7 +3,8 @@
3
3
  const { EventEmitter } = require('events');
4
4
  const { chunks } = require('./points');
5
5
  const { loadAddon } = require('./native');
6
- const { normalizeConnectionOptions } = require('./security');
6
+ const { preflightConnectionSecurity } = require('./security');
7
+ const { SIEMENS_WINCC_DEFAULTS, isSiemensWinCC } = require('./compatibility');
7
8
 
8
9
  function normalizeNotificationBatch(notification) {
9
10
  if (notification && Array.isArray(notification.changes)) {
@@ -16,13 +17,17 @@ function normalizeNotificationBatch(notification) {
16
17
  };
17
18
  }
18
19
 
19
- function effectiveBatchSize(requested, serverLimit) {
20
+ function effectiveBatchSize(requested, serverLimit, fallbackLimit) {
20
21
  const configured = Number.isFinite(Number(requested)) && Number(requested) > 0
21
22
  ? Math.floor(Number(requested))
22
23
  : 500;
23
24
  const limit = Number(serverLimit);
24
- return Number.isFinite(limit) && limit > 0
25
- ? Math.min(configured, Math.floor(limit))
25
+ if (Number.isFinite(limit) && limit > 0) {
26
+ return Math.min(configured, Math.floor(limit));
27
+ }
28
+ const fallback = Number(fallbackLimit);
29
+ return Number.isFinite(fallback) && fallback > 0
30
+ ? Math.min(configured, Math.floor(fallback))
26
31
  : configured;
27
32
  }
28
33
 
@@ -55,6 +60,7 @@ class OpcUaClient extends EventEmitter {
55
60
  operationLimits: {},
56
61
  nodeIdCache: {},
57
62
  connection: {},
63
+ compatibility: this.#compatibilityDiagnostics(),
58
64
  };
59
65
  }
60
66
 
@@ -85,7 +91,8 @@ class OpcUaClient extends EventEmitter {
85
91
  }
86
92
 
87
93
  async #connect() {
88
- const options = normalizeConnectionOptions(this.options);
94
+ const options = preflightConnectionSecurity(this.options);
95
+ this.diagnostics.securityPreflight = options.securityPreflight;
89
96
  const addon = loadAddon();
90
97
  const token = ++this.connectionToken;
91
98
  this.connectionState = 'connecting';
@@ -192,7 +199,8 @@ class OpcUaClient extends EventEmitter {
192
199
  const results = [];
193
200
  await this.connect();
194
201
  const effective = effectiveBatchSize(
195
- batchSize, this.diagnostics.operationLimits?.maxNodesPerRead
202
+ batchSize, this.diagnostics.operationLimits?.maxNodesPerRead,
203
+ this.#compatibilityBatchFallback()
196
204
  );
197
205
  for (const batch of chunks(points, effective)) {
198
206
  results.push(...await this.#invoke('readManyAsync', batch));
@@ -204,7 +212,8 @@ class OpcUaClient extends EventEmitter {
204
212
  const results = [];
205
213
  await this.connect();
206
214
  const effective = effectiveBatchSize(
207
- batchSize, this.diagnostics.operationLimits?.maxNodesPerWrite
215
+ batchSize, this.diagnostics.operationLimits?.maxNodesPerWrite,
216
+ this.#compatibilityBatchFallback()
208
217
  );
209
218
  for (const batch of chunks(points, effective)) {
210
219
  results.push(...await this.#invoke('writeManyAsync', batch));
@@ -222,7 +231,8 @@ class OpcUaClient extends EventEmitter {
222
231
  const results = [];
223
232
  await this.connect();
224
233
  const effective = effectiveBatchSize(
225
- batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall
234
+ batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall,
235
+ this.#compatibilityBatchFallback()
226
236
  );
227
237
  for (const batch of chunks(points, effective)) {
228
238
  results.push(...await this.#invoke(
@@ -236,7 +246,8 @@ class OpcUaClient extends EventEmitter {
236
246
  const results = [];
237
247
  await this.connect();
238
248
  const effective = effectiveBatchSize(
239
- batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall
249
+ batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall,
250
+ this.#compatibilityBatchFallback()
240
251
  );
241
252
  for (const batch of chunks(monitoredItemIds, effective)) {
242
253
  results.push(...await this.#invoke('removeMonitoredItemsAsync', subscriptionId, batch));
@@ -261,6 +272,22 @@ class OpcUaClient extends EventEmitter {
261
272
  return this.diagnostics;
262
273
  }
263
274
 
275
+ #compatibilityBatchFallback() {
276
+ return isSiemensWinCC(this.options.compatibilityProfile)
277
+ ? SIEMENS_WINCC_DEFAULTS.requestBatchSize
278
+ : 0;
279
+ }
280
+
281
+ #compatibilityDiagnostics() {
282
+ const siemensWincc = isSiemensWinCC(this.options.compatibilityProfile);
283
+ return {
284
+ profile: siemensWincc ? 'siemens-wincc' : 'standard',
285
+ requestBatchFallback: siemensWincc
286
+ ? SIEMENS_WINCC_DEFAULTS.requestBatchSize
287
+ : null,
288
+ };
289
+ }
290
+
264
291
  #refreshDiagnostics() {
265
292
  if (this.handle == null) return;
266
293
  const addon = loadAddon();
@@ -270,6 +297,8 @@ class OpcUaClient extends EventEmitter {
270
297
  this.diagnostics = {
271
298
  ...nativeDiagnostics,
272
299
  browse: this.diagnostics.browse,
300
+ securityPreflight: this.diagnostics.securityPreflight,
301
+ compatibility: this.#compatibilityDiagnostics(),
273
302
  connection: {
274
303
  ...(nativeDiagnostics.connection || {}),
275
304
  supervisor: { ...this.supervisorDiagnostics },
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ const COMPATIBILITY_PROFILES = Object.freeze({
4
+ Standard: 'standard',
5
+ SiemensWinCC: 'siemens-wincc',
6
+ });
7
+
8
+ const SIEMENS_WINCC_DEFAULTS = Object.freeze({
9
+ requestBatchSize: 200,
10
+ monitoredItemsPerSubscription: 1000,
11
+ });
12
+
13
+ function normalizeCompatibilityProfile(value) {
14
+ return value === COMPATIBILITY_PROFILES.SiemensWinCC
15
+ ? COMPATIBILITY_PROFILES.SiemensWinCC
16
+ : COMPATIBILITY_PROFILES.Standard;
17
+ }
18
+
19
+ function isSiemensWinCC(value) {
20
+ return normalizeCompatibilityProfile(value) === COMPATIBILITY_PROFILES.SiemensWinCC;
21
+ }
22
+
23
+ module.exports = {
24
+ COMPATIBILITY_PROFILES,
25
+ SIEMENS_WINCC_DEFAULTS,
26
+ isSiemensWinCC,
27
+ normalizeCompatibilityProfile,
28
+ };
package/lib/security.js CHANGED
@@ -1,18 +1,111 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
4
+ const crypto = require('node:crypto');
5
+
3
6
  const SECURITY_POLICY_URIS = Object.freeze({
4
7
  None: 'http://opcfoundation.org/UA/SecurityPolicy#None',
5
8
  Basic256Sha256: 'http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256',
9
+ Aes128_Sha256_RsaOaep: 'http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep',
10
+ Aes256_Sha256_RsaPss: 'http://opcfoundation.org/UA/SecurityPolicy#Aes256_Sha256_RsaPss',
6
11
  });
7
12
 
8
13
  const SECURITY_MODES = new Set(['None', 'Sign', 'SignAndEncrypt']);
9
14
 
10
- function configurationError(message) {
15
+ function configurationError(message, code = 'OPCUA_INVALID_SECURITY_CONFIGURATION', cause) {
11
16
  const error = new Error(message);
12
- error.code = 'OPCUA_INVALID_SECURITY_CONFIGURATION';
17
+ error.code = code;
18
+ if (cause) error.cause = cause;
13
19
  return error;
14
20
  }
15
21
 
22
+ function readSecurityFile(filePath, label, readFileSync = fs.readFileSync) {
23
+ try {
24
+ return readFileSync(filePath);
25
+ } catch (error) {
26
+ throw configurationError(
27
+ `Cannot read OPC UA ${label}: ${filePath}`,
28
+ 'OPCUA_SECURITY_FILE_UNREADABLE',
29
+ error
30
+ );
31
+ }
32
+ }
33
+
34
+ function parseCertificate(buffer, label, X509Certificate = crypto.X509Certificate) {
35
+ try {
36
+ return new X509Certificate(buffer);
37
+ } catch (error) {
38
+ throw configurationError(
39
+ `Invalid OPC UA ${label}`,
40
+ 'OPCUA_CERTIFICATE_INVALID',
41
+ error
42
+ );
43
+ }
44
+ }
45
+
46
+ function applicationUris(certificate) {
47
+ const output = [];
48
+ const expression = /(?:^|,\s*)URI:((?:"[^"]+")|[^,]+)/g;
49
+ for (const match of String(certificate.subjectAltName || '').matchAll(expression)) {
50
+ const value = match[1].replace(/^"|"$/g, '').trim();
51
+ if (value && !output.includes(value)) output.push(value);
52
+ }
53
+ return output;
54
+ }
55
+
56
+ function validateCertificateTime(certificate, label, now = Date.now()) {
57
+ const validFrom = Date.parse(certificate.validFrom);
58
+ const validTo = Date.parse(certificate.validTo);
59
+ if (Number.isFinite(validFrom) && now < validFrom) {
60
+ throw configurationError(
61
+ `OPC UA ${label} is not valid before ${certificate.validFrom}`,
62
+ 'OPCUA_CERTIFICATE_NOT_YET_VALID'
63
+ );
64
+ }
65
+ if (Number.isFinite(validTo) && now > validTo) {
66
+ throw configurationError(
67
+ `OPC UA ${label} expired at ${certificate.validTo}`,
68
+ 'OPCUA_CERTIFICATE_EXPIRED'
69
+ );
70
+ }
71
+ }
72
+
73
+ function loadPrivateKey(buffer, passphrase, createPrivateKey = crypto.createPrivateKey) {
74
+ const password = passphrase ? String(passphrase) : undefined;
75
+ const attempts = [
76
+ { key: buffer, passphrase: password },
77
+ { key: buffer, format: 'der', type: 'pkcs8', passphrase: password },
78
+ { key: buffer, format: 'der', type: 'pkcs1', passphrase: password },
79
+ ];
80
+ let lastError;
81
+ for (const options of attempts) {
82
+ try {
83
+ return createPrivateKey(options);
84
+ } catch (error) {
85
+ lastError = error;
86
+ }
87
+ }
88
+ throw configurationError(
89
+ 'Invalid OPC UA client private key or key password',
90
+ 'OPCUA_PRIVATE_KEY_INVALID',
91
+ lastError
92
+ );
93
+ }
94
+
95
+ function certificateSummary(certificate, uri) {
96
+ const publicKey = certificate.publicKey;
97
+ return {
98
+ subject: certificate.subject,
99
+ issuer: certificate.issuer,
100
+ validFrom: certificate.validFrom,
101
+ validTo: certificate.validTo,
102
+ fingerprint256: certificate.fingerprint256,
103
+ applicationUri: uri || null,
104
+ publicKeyType: publicKey?.asymmetricKeyType || null,
105
+ modulusLength: publicKey?.asymmetricKeyDetails?.modulusLength || null,
106
+ };
107
+ }
108
+
16
109
  function normalizePathList(value) {
17
110
  const source = Array.isArray(value)
18
111
  ? value
@@ -72,6 +165,92 @@ function normalizeConnectionOptions(options = {}) {
72
165
  return normalized;
73
166
  }
74
167
 
168
+ function preflightConnectionSecurity(options = {}, dependencies = {}) {
169
+ const normalized = normalizeConnectionOptions(options);
170
+ const runtime = {
171
+ readFileSync: dependencies.readFileSync || fs.readFileSync,
172
+ X509Certificate: dependencies.X509Certificate || crypto.X509Certificate,
173
+ createPrivateKey: dependencies.createPrivateKey || crypto.createPrivateKey,
174
+ now: dependencies.now == null ? Date.now() : Number(dependencies.now),
175
+ };
176
+ if (normalized.securityMode === 'None') {
177
+ return {
178
+ ...normalized,
179
+ securityPreflight: { enabled: false, policy: normalized.securityPolicy },
180
+ };
181
+ }
182
+
183
+ const certificate = parseCertificate(
184
+ readSecurityFile(normalized.certificate, 'client certificate', runtime.readFileSync),
185
+ 'client certificate',
186
+ runtime.X509Certificate
187
+ );
188
+ validateCertificateTime(certificate, 'client certificate', runtime.now);
189
+
190
+ const uris = applicationUris(certificate);
191
+ const applicationUri = normalized.applicationUri || (uris.length === 1 ? uris[0] : '');
192
+ if (!applicationUri) {
193
+ throw configurationError(
194
+ 'The OPC UA client certificate must contain one Application URI or Application URI must be configured',
195
+ 'OPCUA_CERTIFICATE_URI_MISSING'
196
+ );
197
+ }
198
+ if (!uris.includes(applicationUri)) {
199
+ throw configurationError(
200
+ `Configured Application URI does not match the client certificate: ${applicationUri}`,
201
+ 'OPCUA_CERTIFICATE_URI_MISMATCH'
202
+ );
203
+ }
204
+
205
+ const privateKey = loadPrivateKey(
206
+ readSecurityFile(normalized.privateKey, 'client private key', runtime.readFileSync),
207
+ normalized.privateKeyPassword,
208
+ runtime.createPrivateKey
209
+ );
210
+ if (!certificate.checkPrivateKey(privateKey)) {
211
+ throw configurationError(
212
+ 'The OPC UA client private key does not match the client certificate',
213
+ 'OPCUA_PRIVATE_KEY_MISMATCH'
214
+ );
215
+ }
216
+
217
+ const keyType = certificate.publicKey?.asymmetricKeyType || '';
218
+ const modulusLength = Number(certificate.publicKey?.asymmetricKeyDetails?.modulusLength) || 0;
219
+ if (!keyType.startsWith('rsa')) {
220
+ throw configurationError(
221
+ `OPC UA ${normalized.securityPolicy} requires an RSA client certificate`,
222
+ 'OPCUA_CERTIFICATE_KEY_TYPE_UNSUPPORTED'
223
+ );
224
+ }
225
+ if (modulusLength > 0 && modulusLength < 2048) {
226
+ throw configurationError(
227
+ `OPC UA client certificate RSA key is too short: ${modulusLength} bits; at least 2048 bits required`,
228
+ 'OPCUA_CERTIFICATE_KEY_TOO_SHORT'
229
+ );
230
+ }
231
+
232
+ const trust = normalized.trustedCertificates.map((filePath) => {
233
+ const trusted = parseCertificate(
234
+ readSecurityFile(filePath, 'trusted certificate', runtime.readFileSync),
235
+ `trusted certificate: ${filePath}`,
236
+ runtime.X509Certificate
237
+ );
238
+ validateCertificateTime(trusted, `trusted certificate: ${filePath}`, runtime.now);
239
+ return certificateSummary(trusted);
240
+ });
241
+
242
+ return {
243
+ ...normalized,
244
+ applicationUri,
245
+ securityPreflight: {
246
+ enabled: true,
247
+ policy: normalized.securityPolicy,
248
+ clientCertificate: certificateSummary(certificate, applicationUri),
249
+ trustedCertificates: trust,
250
+ },
251
+ };
252
+ }
253
+
75
254
  function endpointIdentityTypes(endpoint) {
76
255
  return [...new Set((endpoint?.userIdentityTokens || []).map((token) => token.tokenType))];
77
256
  }
@@ -92,5 +271,6 @@ module.exports = {
92
271
  matchingEndpoints,
93
272
  normalizeConnectionOptions,
94
273
  normalizePathList,
274
+ preflightConnectionSecurity,
95
275
  securityPolicyName,
96
276
  };
@@ -15,11 +15,13 @@
15
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
16
  <div class="opcua-discovery-result" id="node-config-input-discoveryResult" style="display:none"><select id="node-config-input-discoveredEndpoint"></select></div>
17
17
  <div class="opcua-standard-note" id="node-config-input-testResult"></div>
18
+ <div class="form-row"><label for="node-config-input-compatibilityProfile">Server profile</label><select id="node-config-input-compatibilityProfile"><option value="standard">Standard OPC UA</option><option value="siemens-wincc">Siemens WinCC</option></select></div>
19
+ <div class="opcua-standard-note">Siemens WinCC uses conservative 200-item service batches and disables unsupported Subscription Deadband filters when the Server does not advertise usable limits.</div>
18
20
  <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>
19
21
  <div class="opcua-standard-note">Standard defaults include automatic reconnect, connectivity checks, and bounded command/NodeId queues.</div>
20
22
  <details class="opcua-advanced">
21
23
  <summary>Security and advanced connection settings</summary>
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>
24
+ <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="Aes128_Sha256_RsaOaep">Aes128_Sha256_RsaOaep</option><option value="Aes256_Sha256_RsaPss">Aes256_Sha256_RsaPss</option></select></div>
23
25
  <div class="form-row"><label for="node-config-input-user">User</label><input type="text" id="node-config-input-user"></div>
24
26
  <div class="form-row"><label for="node-config-input-password">Password</label><input type="password" id="node-config-input-password"></div>
25
27
  <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>
@@ -50,8 +52,9 @@
50
52
  <li><b>Endpoint</b>:服务器地址,例如 <code>opc.tcp://192.168.1.10:4840</code>。</li>
51
53
  <li><b>Discover endpoints</b>:读取服务器公开的安全模式、安全策略和用户认证方式。</li>
52
54
  <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
+ <li><b>Server profile</b>:默认使用标准 OPC UA 行为;Siemens WinCC 模式在服务器未公布有效 OperationLimits 时按每批 200 点请求,并在订阅中禁用 WinCC 不支持的 Deadband。</li>
56
+ <li><b>Security</b>:支持 None、Sign、SignAndEncrypt,以及 Basic256Sha256、Aes128_Sha256_RsaOaep、Aes256_Sha256_RsaPss;安全模式、策略和认证方式必须与服务器 Endpoint 匹配。</li>
57
+ <li><b>证书</b>:安全连接可上传或指定客户端证书、私钥、信任证书和 CRL。连接前会检查有效期、证书和私钥是否匹配、RSA 密钥强度及 Application URI。若证书只包含一个 URI,可留空并自动采用。</li>
55
58
  </ul>
56
59
  <h4>建议</h4>
57
60
  <p>一个 Connection 配置对应一条客户端 Session。多个业务节点应复用同一个 Connection,避免为相同 Endpoint 重复创建 Session。节点内置自动重连、连通性检查以及有界命令队列。</p>
@@ -65,8 +68,9 @@
65
68
  <li><b>Endpoint</b>: Server URL, for example <code>opc.tcp://192.168.1.10:4840</code>.</li>
66
69
  <li><b>Discover endpoints</b>: Reads advertised security modes, policies, and user token types.</li>
67
70
  <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>
71
+ <li><b>Server profile</b>: Standard uses normal OPC UA behavior. Siemens WinCC falls back to 200-item service batches when usable OperationLimits are not advertised and removes unsupported Subscription Deadband filters.</li>
72
+ <li><b>Security</b>: Supports None, Sign, and SignAndEncrypt with Basic256Sha256, Aes128_Sha256_RsaOaep, or Aes256_Sha256_RsaPss. Mode, policy, and identity must match an advertised endpoint.</li>
73
+ <li><b>Certificates</b>: Secure connections can upload or reference a client certificate, private key, trust list, and CRLs. Validity, key matching, RSA strength, and Application URI are checked before connecting. An empty URI is filled automatically when the certificate contains exactly one URI.</li>
70
74
  </ul>
71
75
  <h4>Recommendation</h4>
72
76
  <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>
@@ -78,6 +82,7 @@
78
82
  defaults: {
79
83
  endpoint: { value: '', required: true },
80
84
  securityMode: { value: 'None' }, securityPolicy: { value: 'None' },
85
+ compatibilityProfile: { value: 'standard' },
81
86
  user: { value: '' }, certificate: { value: '' }, privateKey: { value: '' },
82
87
  applicationUri: { value: '' }, trustedCertificates: { value: '' }, revocationList: { value: '' },
83
88
  requestedSessionTimeout: { value: 60000, validate: RED.validators.number() },
@@ -207,6 +212,7 @@
207
212
  endpoint: String($('#node-config-input-endpoint').val() || '').trim(),
208
213
  securityMode: mode.val(),
209
214
  securityPolicy: policy.val(),
215
+ compatibilityProfile: $('#node-config-input-compatibilityProfile').val(),
210
216
  user: $('#node-config-input-user').val(),
211
217
  password: $('#node-config-input-password').val(),
212
218
  certificate: $('#node-config-input-certificate').val(),
@@ -245,9 +251,12 @@
245
251
  const allEndpoints = Array.isArray(response.endpoints) ? response.endpoints : [];
246
252
  const identityType = String($('#node-config-input-user').val() || '').trim()
247
253
  ? 'Username' : 'Anonymous';
254
+ const supportedPolicies = new Set([
255
+ 'None', 'Basic256Sha256',
256
+ 'Aes128_Sha256_RsaOaep', 'Aes256_Sha256_RsaPss',
257
+ ]);
248
258
  const supported = allEndpoints.filter(function (item) {
249
- const itemPolicy = policyName(item.securityPolicyUri);
250
- return itemPolicy === 'None' || itemPolicy === 'Basic256Sha256';
259
+ return supportedPolicies.has(policyName(item.securityPolicyUri));
251
260
  });
252
261
  const matchingIdentity = supported.filter(function (item) {
253
262
  return (item.userIdentityTokens || []).some(function (token) {
@@ -271,7 +280,7 @@
271
280
  .appendTo(discovered);
272
281
  });
273
282
  if (!endpoints.length) {
274
- RED.notify('The server returned no supported None or Basic256Sha256 endpoint', 'warning');
283
+ RED.notify('The server returned no endpoint supported by this client', 'warning');
275
284
  result.hide();
276
285
  return;
277
286
  }
@@ -293,7 +302,8 @@
293
302
  if (!selected) return;
294
303
  const selectedPolicy = policyName(selected.securityPolicyUri);
295
304
  mode.val(selected.securityMode);
296
- if (selectedPolicy === 'None' || selectedPolicy === 'Basic256Sha256') {
305
+ if (['None', 'Basic256Sha256', 'Aes128_Sha256_RsaOaep',
306
+ 'Aes256_Sha256_RsaPss'].includes(selectedPolicy)) {
297
307
  policy.val(selectedPolicy);
298
308
  }
299
309
  updateSecurityFields();
@@ -3,6 +3,7 @@
3
3
  const { OpcUaClient, discoverEndpoints } = require('../lib/client');
4
4
  const { ConnectionRegistry } = require('../lib/connection-registry');
5
5
  const { SecurityFileStore } = require('../lib/security-file-store');
6
+ const { normalizeCompatibilityProfile } = require('../lib/compatibility');
6
7
 
7
8
  function pathList(value) {
8
9
  const source = Array.isArray(value) ? value : String(value || '').split(/\r?\n|;/);
@@ -14,6 +15,7 @@ function clientOptions(config, credentials = {}) {
14
15
  endpoint: config.endpoint,
15
16
  securityMode: config.securityMode || 'None',
16
17
  securityPolicy: config.securityPolicy || 'None',
18
+ compatibilityProfile: normalizeCompatibilityProfile(config.compatibilityProfile),
17
19
  user: config.user || '',
18
20
  password: credentials.password || '',
19
21
  certificate: config.certificate || '',
@@ -71,6 +71,7 @@
71
71
  <li>服务器可以把请求间隔修订为它支持的值。</li>
72
72
  </ul>
73
73
  <p>新节点默认 Server queue size=1、Discard oldest=true、Queue mode=latest,适合实时状态采集。脉冲、报警转换或完整历史不能丢失时,应改为 all 并增大服务器队列。</p>
74
+ <p>Connection 选择 Siemens WinCC 后,如果服务器没有公布有效限制,每个 Subscription 最多建立 1000 个 MonitoredItem,并自动移除 WinCC 不支持的 Deadband 配置;节点状态消息会报告实际兼容策略。</p>
74
75
  <h4>输出</h4>
75
76
  <ul>
76
77
  <li><b>Compact</b>:NodeId/alias、值、状态和源时间戳。</li>
@@ -93,6 +94,7 @@
93
94
  <li>The server may revise requested intervals to supported values.</li>
94
95
  </ul>
95
96
  <p>New nodes default to server queue size 1, discard-oldest, and local latest coalescing for real-time state. Select all and increase the server queue for pulses, alarm transitions, or history where every change must be retained.</p>
97
+ <p>With the Siemens WinCC Connection profile, each Subscription is conservatively capped at 1000 MonitoredItems when the server advertises no usable limit, and unsupported Deadband filters are removed automatically. Status output reports the active compatibility behavior.</p>
96
98
  <h4>Output</h4>
97
99
  <ul>
98
100
  <li><b>Compact</b>: NodeId/alias, value, status, and source timestamp.</li>
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { looksLikePointList, messagePoints } = require('../lib/points');
4
+ const { SIEMENS_WINCC_DEFAULTS, isSiemensWinCC } = require('../lib/compatibility');
4
5
 
5
6
  const NOTIFICATION_CONFIG_FIELDS = new Set([
6
7
  'nodeId',
@@ -55,19 +56,19 @@ function positiveInteger(value, fallback) {
55
56
  return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
56
57
  }
57
58
 
58
- function monitoringSettings(point, config) {
59
+ function monitoringSettings(point, config, siemensWincc = false) {
59
60
  return {
60
61
  samplingInterval: Number(point.samplingInterval) || Number(config.samplingInterval) || 1000,
61
62
  queueSize: positiveInteger(point.queueSize, positiveInteger(config.queueSize, 1)),
62
63
  discardOldest: point.discardOldest !== false,
63
- deadbandType: point.deadbandType || config.deadbandType || 'none',
64
- deadbandValue: Number(point.deadbandValue ?? config.deadbandValue) || 0,
64
+ deadbandType: siemensWincc ? 'none' : point.deadbandType || config.deadbandType || 'none',
65
+ deadbandValue: siemensWincc ? 0 : Number(point.deadbandValue ?? config.deadbandValue) || 0,
65
66
  };
66
67
  }
67
68
 
68
- function sameMonitoringSettings(left, right, config) {
69
- const leftSettings = monitoringSettings(left, config);
70
- const rightSettings = monitoringSettings(right, config);
69
+ function sameMonitoringSettings(left, right, config, siemensWincc = false) {
70
+ const leftSettings = monitoringSettings(left, config, siemensWincc);
71
+ const rightSettings = monitoringSettings(right, config, siemensWincc);
71
72
  return Object.keys(leftSettings).every(
72
73
  (key) => leftSettings[key] === rightSettings[key]
73
74
  );
@@ -111,6 +112,7 @@ module.exports = function registerSubscribeNode(RED) {
111
112
  node.closing = false;
112
113
  node.lastDropped = 0;
113
114
  node.lastStatusAt = 0;
115
+ node.compatibilityWarningIssued = false;
114
116
  const consumer = node.connection?.registerConsumer?.('subscribe', {
115
117
  nodeId: config.id, name: config.name,
116
118
  });
@@ -118,7 +120,16 @@ module.exports = function registerSubscribeNode(RED) {
118
120
  const batchSize = positiveInteger(config.batchSize, 500);
119
121
  const callbackBatchSize = positiveInteger(config.callbackBatchSize, 500);
120
122
  const callbackFlushIntervalMs = positiveInteger(config.callbackFlushIntervalMs, 50);
121
- const configuredSubscriptionCapacity = positiveInteger(config.maxItemsPerSubscription, 5000);
123
+ const siemensWincc = isSiemensWinCC(
124
+ node.connection?.client?.options?.compatibilityProfile
125
+ );
126
+ const requestedSubscriptionCapacity = positiveInteger(config.maxItemsPerSubscription, 5000);
127
+ const configuredSubscriptionCapacity = siemensWincc
128
+ ? Math.min(
129
+ requestedSubscriptionCapacity,
130
+ SIEMENS_WINCC_DEFAULTS.monitoredItemsPerSubscription
131
+ )
132
+ : requestedSubscriptionCapacity;
122
133
  // Undefined preserves the descriptor-rich output used by Flow JSON created
123
134
  // before resultDetail existed. New editor nodes explicitly save compact.
124
135
  const resultDetail = ['compact', 'value', 'full'].includes(config.resultDetail)
@@ -129,6 +140,24 @@ module.exports = function registerSubscribeNode(RED) {
129
140
  return [...node.subscriptions.keys()];
130
141
  }
131
142
 
143
+ function applyCompatibility(points) {
144
+ if (!siemensWincc) return points;
145
+ let removedDeadband = false;
146
+ const compatible = points.map((point) => {
147
+ const configuredType = point.deadbandType || config.deadbandType || 'none';
148
+ if (configuredType === 'none') return point;
149
+ removedDeadband = true;
150
+ return { ...point, deadbandType: 'none', deadbandValue: 0 };
151
+ });
152
+ if (removedDeadband && !node.compatibilityWarningIssued) {
153
+ node.compatibilityWarningIssued = true;
154
+ node.warn(
155
+ 'Siemens WinCC does not support OPC UA DataChange Deadband filters; Deadband was disabled.'
156
+ );
157
+ }
158
+ return compatible;
159
+ }
160
+
132
161
  function primarySubscriptionId() {
133
162
  return subscriptionIds()[0] ?? null;
134
163
  }
@@ -325,8 +354,8 @@ module.exports = function registerSubscribeNode(RED) {
325
354
  samplingInterval: Number(config.samplingInterval) || 1000,
326
355
  queueSize: Number(config.queueSize) || 10,
327
356
  discardOldest: config.discardOldest !== false,
328
- deadbandType: config.deadbandType || 'none',
329
- deadbandValue: Number(config.deadbandValue) || 0,
357
+ deadbandType: siemensWincc ? 'none' : config.deadbandType || 'none',
358
+ deadbandValue: siemensWincc ? 0 : Number(config.deadbandValue) || 0,
330
359
  timestampMode: config.timestampMode || 'source',
331
360
  },
332
361
  batchSize
@@ -497,6 +526,11 @@ module.exports = function registerSubscribeNode(RED) {
497
526
  queueSizeMin: revisedQueues.minimum,
498
527
  queueSizeMax: revisedQueues.maximum,
499
528
  },
529
+ compatibility: {
530
+ profile: siemensWincc ? 'siemens-wincc' : 'standard',
531
+ deadbandSupported: !siemensWincc,
532
+ monitoredItemsPerSubscription: configuredSubscriptionCapacity,
533
+ },
500
534
  connectionGovernance: node.connection?.getConnectionGovernance?.(),
501
535
  connectionUsage: node.connection?.getConnectionUsage?.(),
502
536
  };
@@ -563,7 +597,7 @@ module.exports = function registerSubscribeNode(RED) {
563
597
  for (const [nodeId, monitored] of node.monitored) {
564
598
  const previous = node.desired.get(nodeId);
565
599
  const next = nextDesired.get(nodeId);
566
- if (!next || !previous || !sameMonitoringSettings(previous, next, config)) {
600
+ if (!next || !previous || !sameMonitoringSettings(previous, next, config, siemensWincc)) {
567
601
  changed.add(nodeId);
568
602
  }
569
603
  }
@@ -626,14 +660,14 @@ module.exports = function registerSubscribeNode(RED) {
626
660
  const configuredPayload = !looksLikePointList(msg.payload) && !Array.isArray(msg.items) &&
627
661
  !dynamicNodeId ? config.pointList : '';
628
662
  const staticNodeId = dynamicNodeId ? '' : config.nodeId;
629
- const points = command === 'clear' ? [] : messagePoints(msg, staticNodeId, {
663
+ const points = command === 'clear' ? [] : applyCompatibility(messagePoints(msg, staticNodeId, {
630
664
  samplingInterval: Number(config.samplingInterval) || 1000,
631
665
  queueSize: Number(config.queueSize) || 10,
632
666
  discardOldest: config.discardOldest !== false,
633
667
  }, {
634
668
  configuredPayload,
635
669
  format: 'auto',
636
- });
670
+ }));
637
671
 
638
672
  node.status({ fill: 'blue', shape: 'dot', text: `${command} ${points.length}` });
639
673
  await runSerialized(async () => {
@@ -642,7 +676,7 @@ module.exports = function registerSubscribeNode(RED) {
642
676
  const changed = new Set();
643
677
  points.forEach((point) => {
644
678
  const previous = node.desired.get(point.nodeId);
645
- if (previous && !sameMonitoringSettings(previous, point, config)) {
679
+ if (previous && !sameMonitoringSettings(previous, point, config, siemensWincc)) {
646
680
  changed.add(point.nodeId);
647
681
  }
648
682
  node.desired.set(point.nodeId, point);
@@ -710,14 +744,14 @@ module.exports = function registerSubscribeNode(RED) {
710
744
  if (node.closing) return;
711
745
  let points;
712
746
  try {
713
- points = messagePoints({}, config.nodeId, {
747
+ points = applyCompatibility(messagePoints({}, config.nodeId, {
714
748
  samplingInterval: Number(config.samplingInterval) || 1000,
715
749
  queueSize: Number(config.queueSize) || 10,
716
750
  discardOldest: config.discardOldest !== false,
717
751
  }, {
718
752
  configuredPayload: config.pointList,
719
753
  format: 'auto',
720
- });
754
+ }));
721
755
  } catch (error) {
722
756
  node.status({ fill: 'red', shape: 'ring', text: error.message });
723
757
  node.warn(`OPC UA saved subscription point list is invalid: ${error.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tier0/opcua-client",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "High-scale OPC UA client nodes for Node-RED powered by open62541",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/addon.cc CHANGED
@@ -49,6 +49,14 @@ std::string SecurityPolicyUri(const std::string& policy) {
49
49
  policy == "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256") {
50
50
  return "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256";
51
51
  }
52
+ if (policy == "Aes128_Sha256_RsaOaep" ||
53
+ policy == "http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep") {
54
+ return "http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep";
55
+ }
56
+ if (policy == "Aes256_Sha256_RsaPss" ||
57
+ policy == "http://opcfoundation.org/UA/SecurityPolicy#Aes256_Sha256_RsaPss") {
58
+ return "http://opcfoundation.org/UA/SecurityPolicy#Aes256_Sha256_RsaPss";
59
+ }
52
60
  return "";
53
61
  }
54
62