@tier0/opcua-client 0.4.1 → 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/nodes/browse.js CHANGED
@@ -36,16 +36,22 @@ module.exports = function registerBrowseNode(RED) {
36
36
 
37
37
  activeEditorBrowses.add(connectionId);
38
38
  try {
39
+ connection.markUsed?.('editor-browse');
39
40
  const items = await connection.client.browse({
40
41
  rootNodeId,
41
42
  recursive: true,
42
43
  variablesOnly: false,
43
44
  maxDepth: positiveInteger(body.maxDepth, 10, 50),
44
45
  maxNodes: positiveInteger(body.maxNodes, 10000, 100000),
46
+ browseBatchSize: positiveInteger(body.browseBatchSize, 100, 5000),
47
+ referencesPerNode: positiveInteger(body.referencesPerNode, 1000, 10000),
45
48
  attributeBatchSize: positiveInteger(body.attributeBatchSize, 200, 5000),
46
49
  });
47
50
  const tree = buildBrowseTree(items, rootNodeId);
48
- res.send({ tree, total: items.length, rootNodeId });
51
+ res.send({
52
+ tree, total: items.length, rootNodeId,
53
+ ...(items.diagnostics || {}),
54
+ });
49
55
  } catch (error) {
50
56
  const status = error?.code === 'OPCUA_RECONNECTING' ? 503 : 500;
51
57
  res.status(status).send({ error: error.message || String(error) });
@@ -59,11 +65,15 @@ module.exports = function registerBrowseNode(RED) {
59
65
  RED.nodes.createNode(this, config);
60
66
  const node = this;
61
67
  node.connection = RED.nodes.getNode(config.connection);
68
+ const consumer = node.connection?.registerConsumer?.('browse', {
69
+ nodeId: config.id, name: config.name,
70
+ });
62
71
 
63
72
  node.on('input', async (msg, send, done) => {
64
73
  if (!node.connection) return done(new Error('OPC UA connection not configured'));
65
74
 
66
75
  try {
76
+ consumer?.markUsed('browse');
67
77
  const rootNodeId = msg.nodeId || msg.topic || config.rootNodeId || 'ns=0;i=85';
68
78
  node.status({ fill: 'blue', shape: 'dot', text: 'browsing' });
69
79
  const configuredSelection = selectedVariables(config.selectedNodes);
@@ -75,8 +85,11 @@ module.exports = function registerBrowseNode(RED) {
75
85
  maxNodes: Number(config.maxNodes) || 10000,
76
86
  variablesOnly: config.variablesOnly !== false,
77
87
  namespaceIndexes: msg.opcua?.namespaceIndexes || [],
88
+ browseBatchSize: Number(config.browseBatchSize) || 100,
89
+ referencesPerNode: Number(config.referencesPerNode) || 1000,
78
90
  attributeBatchSize: Number(config.attributeBatchSize) || 200,
79
91
  });
92
+ const browseDiagnostics = useSelection ? null : points.diagnostics || null;
80
93
 
81
94
  const outputMode = config.outputMode || 'all';
82
95
  if (outputMode === 'paged') {
@@ -93,6 +106,7 @@ module.exports = function registerBrowseNode(RED) {
93
106
  pages: pages.length,
94
107
  done: index === pages.length - 1,
95
108
  total: points.length,
109
+ ...(browseDiagnostics ? { browse: browseDiagnostics } : {}),
96
110
  },
97
111
  }));
98
112
  } else {
@@ -104,17 +118,30 @@ module.exports = function registerBrowseNode(RED) {
104
118
  rootNodeId,
105
119
  done: true,
106
120
  total: points.length,
121
+ ...(browseDiagnostics ? { browse: browseDiagnostics } : {}),
107
122
  };
108
123
  send(msg);
109
124
  }
110
125
 
111
- node.status({ fill: 'green', shape: 'dot', text: `${points.length} points` });
126
+ const partial = browseDiagnostics?.truncated || browseDiagnostics?.failures?.length;
127
+ node.status({
128
+ fill: partial ? 'yellow' : 'green', shape: partial ? 'ring' : 'dot',
129
+ text: `${points.length} points` +
130
+ (browseDiagnostics?.truncated
131
+ ? ` / truncated: ${browseDiagnostics.truncationReason}` : ''),
132
+ });
112
133
  done();
113
134
  } catch (error) {
114
135
  node.status({ fill: 'red', shape: 'ring', text: error.message });
115
136
  done(error);
116
137
  }
117
138
  });
139
+
140
+ node.on('close', (removed, done) => {
141
+ if (typeof removed === 'function') done = removed;
142
+ consumer?.release();
143
+ done?.();
144
+ });
118
145
  }
119
146
 
120
147
  RED.nodes.registerType('tier0-opcua-browse', OpcUaBrowseNode);
@@ -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 || '',
@@ -127,6 +129,48 @@ module.exports = function registerConnectionNode(RED) {
127
129
  RED.nodes.createNode(this, config);
128
130
  const node = this;
129
131
  const dependentCleanups = new Set();
132
+ const consumers = new Map();
133
+ const operations = new Map();
134
+ let nextConsumerId = 1;
135
+ let lastUsedAt = null;
136
+ let totalOperations = 0;
137
+
138
+ function markUsed(operation = 'unknown', consumer) {
139
+ const now = Date.now();
140
+ const name = String(operation || 'unknown');
141
+ lastUsedAt = now;
142
+ totalOperations += 1;
143
+ operations.set(name, (operations.get(name) || 0) + 1);
144
+ if (consumer && consumers.has(consumer.id)) {
145
+ consumer.lastUsedAt = now;
146
+ consumer.operations += 1;
147
+ }
148
+ }
149
+
150
+ function connectionUsage() {
151
+ const consumerTypes = {};
152
+ for (const consumer of consumers.values()) {
153
+ consumerTypes[consumer.type] = (consumerTypes[consumer.type] || 0) + 1;
154
+ }
155
+ return {
156
+ consumerCount: consumers.size,
157
+ consumerTypes,
158
+ consumers: [...consumers.values()].map((consumer) => ({
159
+ id: consumer.nodeId || consumer.id,
160
+ type: consumer.type,
161
+ name: consumer.name || '',
162
+ registeredAt: new Date(consumer.registeredAt).toISOString(),
163
+ lastUsedAt: consumer.lastUsedAt == null
164
+ ? null : new Date(consumer.lastUsedAt).toISOString(),
165
+ operations: consumer.operations,
166
+ })),
167
+ lastUsedAt: lastUsedAt == null ? null : new Date(lastUsedAt).toISOString(),
168
+ idleMs: lastUsedAt == null ? null : Math.max(0, Date.now() - lastUsedAt),
169
+ totalOperations,
170
+ operations: Object.fromEntries(operations),
171
+ automaticIdleDisconnect: false,
172
+ };
173
+ }
130
174
 
131
175
  function runDependentCleanup(record) {
132
176
  if (!record.promise) {
@@ -147,7 +191,33 @@ module.exports = function registerConnectionNode(RED) {
147
191
  node.getDiagnostics = () => ({
148
192
  ...node.client.getDiagnostics(),
149
193
  connectionGovernance: node.getConnectionGovernance(),
194
+ connectionUsage: connectionUsage(),
150
195
  });
196
+ node.getConnectionUsage = connectionUsage;
197
+ node.markUsed = (operation) => markUsed(operation);
198
+ node.registerConsumer = (type, details = {}) => {
199
+ const consumer = {
200
+ id: nextConsumerId++,
201
+ nodeId: String(details.nodeId || ''),
202
+ name: String(details.name || ''),
203
+ type: String(type || 'unknown'),
204
+ registeredAt: Date.now(),
205
+ lastUsedAt: null,
206
+ operations: 0,
207
+ };
208
+ consumers.set(consumer.id, consumer);
209
+ let released = false;
210
+ return {
211
+ markUsed(operation) {
212
+ if (!released) markUsed(operation || consumer.type, consumer);
213
+ },
214
+ release() {
215
+ if (released) return;
216
+ released = true;
217
+ consumers.delete(consumer.id);
218
+ },
219
+ };
220
+ };
151
221
  node.registerDependentCleanup = (cleanup) => {
152
222
  if (typeof cleanup !== 'function') {
153
223
  throw new TypeError('OPC UA dependent cleanup must be a function');
@@ -187,6 +257,7 @@ module.exports = function registerConnectionNode(RED) {
187
257
  node.warn(`OPC UA dependent cleanup failed: ${result.reason?.message || result.reason}`);
188
258
  }
189
259
  }
260
+ consumers.clear();
190
261
  try {
191
262
  await node.client.disconnect();
192
263
  } catch (error) {
package/nodes/read.html CHANGED
@@ -60,6 +60,7 @@
60
60
  <li><b>Value only</b>:批量时按点表顺序输出值数组,单点时输出标量。</li>
61
61
  <li><b>Full metadata</b>:同时输出完整点表描述和客户端诊断,适合排障。</li>
62
62
  </ul>
63
+ <p>Int64/UInt64 超出 JavaScript 安全整数范围时以十进制字符串输出,避免累计量和计数器精度丢失。</p>
63
64
  <p>大点表会按照 Batch size 和服务器 OperationLimits 自动拆分为批量服务请求,不会逐点建立连接。</p>
64
65
  </section>
65
66
  <hr>
@@ -79,6 +80,7 @@
79
80
  <li><b>Value only</b>: A value array in point-list order for batch reads, or a scalar for one point.</li>
80
81
  <li><b>Full metadata</b>: Full point descriptors and client diagnostics for troubleshooting.</li>
81
82
  </ul>
83
+ <p>Int64/UInt64 values outside the JavaScript safe-integer range are emitted as decimal strings to preserve exact counters and totals.</p>
82
84
  <p>Large point lists are split according to Batch size and server OperationLimits. The node does not create one connection per point.</p>
83
85
  </section>
84
86
  </script>
package/nodes/read.js CHANGED
@@ -27,11 +27,15 @@ module.exports = function registerReadNode(RED) {
27
27
  RED.nodes.createNode(this, config);
28
28
  const node = this;
29
29
  node.connection = RED.nodes.getNode(config.connection);
30
+ const consumer = node.connection?.registerConsumer?.('read', {
31
+ nodeId: config.id, name: config.name,
32
+ });
30
33
 
31
34
  node.on('input', async (msg, send, done) => {
32
35
  if (!node.connection) return done(new Error('OPC UA connection not configured'));
33
36
 
34
37
  try {
38
+ consumer?.markUsed('read');
35
39
  const points = messagePoints(msg, config.nodeId, {
36
40
  indexRange: config.indexRange,
37
41
  }, {
@@ -99,6 +103,12 @@ module.exports = function registerReadNode(RED) {
99
103
  done(error);
100
104
  }
101
105
  });
106
+
107
+ node.on('close', (removed, done) => {
108
+ if (typeof removed === 'function') done = removed;
109
+ consumer?.release();
110
+ done?.();
111
+ });
102
112
  }
103
113
 
104
114
  RED.nodes.registerType('tier0-opcua-read', OpcUaReadNode);
@@ -2,12 +2,41 @@
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-point-editor { box-sizing: border-box; display: inline-block; vertical-align: top; width: calc(100% - 110px); }
6
+ .opcua-point-toolbar { display: flex; gap: 6px; margin-bottom: 8px; }
7
+ .opcua-point-add { display: flex; gap: 6px; margin-bottom: 8px; }
8
+ .opcua-point-add input { flex: 1; min-width: 0; }
9
+ .opcua-point-list { border: 1px solid var(--red-ui-form-input-border-color, #ccc); border-radius: 2px; box-sizing: border-box; height: 180px; list-style: none; margin: 0 !important; overflow: auto; padding: 0 !important; width: 100%; }
10
+ .opcua-point-list li { align-items: center; border-bottom: 1px solid var(--red-ui-secondary-border-color, #eee); display: flex; gap: 8px; min-height: 34px; padding: 4px 8px; }
11
+ .opcua-point-list li:last-child { border-bottom: 0; }
12
+ .opcua-point-list .opcua-point-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
13
+ .opcua-point-list .opcua-point-empty { color: var(--red-ui-form-placeholder-color, #888); justify-content: center; }
14
+ .opcua-point-list .opcua-point-remove { border: 0; color: var(--red-ui-text-color, #555); flex: 0 0 auto; }
15
+ .opcua-point-status { color: var(--red-ui-form-text-color, #666); font-size: 0.9em; margin-top: 5px; }
5
16
  </style>
6
17
  <script type="text/html" data-template-name="tier0-opcua-subscribe">
7
18
  <div class="form-row"><label for="node-input-connection">Connection</label><input type="text" id="node-input-connection"></div>
8
- <div class="form-row"><label for="node-input-nodeId">NodeId</label><input type="text" id="node-input-nodeId" placeholder="Optional when input supplies points"></div>
19
+ <input type="hidden" id="node-input-nodeId">
20
+ <input type="hidden" id="node-input-pointList">
21
+ <input type="hidden" id="node-input-pointListFileName">
22
+ <input type="file" id="node-input-pointFile" accept=".json,.csv,application/json,text/csv" style="display:none">
23
+ <div class="form-row">
24
+ <label>Points</label>
25
+ <div class="opcua-point-editor">
26
+ <div class="opcua-point-toolbar">
27
+ <button type="button" class="red-ui-button" id="node-input-importPointList"><i class="fa fa-upload"></i> Import</button>
28
+ <button type="button" class="red-ui-button" id="node-input-clearPointList"><i class="fa fa-trash"></i> Clear all</button>
29
+ </div>
30
+ <div class="opcua-point-add">
31
+ <input type="text" id="node-input-newPointNodeId" placeholder="NodeId, for example ns=2;s=Machine.Speed">
32
+ <button type="button" class="red-ui-button" id="node-input-addPoint"><i class="fa fa-plus"></i> Add</button>
33
+ </div>
34
+ <ol class="opcua-point-list" id="node-input-pointListItems"></ol>
35
+ <div class="opcua-point-status" id="node-input-pointListStatus">0 points</div>
36
+ </div>
37
+ </div>
9
38
  <div class="form-row"><label for="node-input-samplingInterval">Sampling (ms)</label><input type="number" id="node-input-samplingInterval"></div>
10
- <div class="opcua-standard-note">Standard mode keeps the latest pending value per point, shards large point sets, batches changes, and automatically restores them after reconnect.</div>
39
+ <div class="opcua-standard-note">Saved points start automatically after deploy. Repeated identical point lists are reconciled without rebuilding server subscriptions.</div>
11
40
  <details class="opcua-advanced">
12
41
  <summary>Advanced subscription and queue settings</summary>
13
42
  <div class="form-row"><label for="node-input-publishingInterval">Publishing interval</label><input type="number" id="node-input-publishingInterval"></div>
@@ -33,7 +62,7 @@
33
62
  <h3>订阅(Subscribe)</h3>
34
63
  <p>创建 OPC UA Subscription 和 MonitoredItems,由服务器在值变化时推送通知;它不是客户端定时 Read。大量点位会按照服务器限制自动拆分到多个 Subscription。</p>
35
64
  <h4>输入与命令</h4>
36
- <p>点表可来自数组、JSON、CSV 或 Browse 输出。通过 <code>msg.opcua.command</code> 使用 <code>replace</code>、<code>add</code>、<code>remove</code>、<code>clear</code> 动态管理点位。</p>
65
+ <p>可直接保存或导入 JSON/CSV 点表;部署后自动建立订阅。点表也可来自消息或 Browse 输出,通过 <code>msg.opcua.command</code> 使用 <code>replace</code>、<code>add</code>、<code>remove</code>、<code>clear</code> 动态管理点位。重复的相同点表不会重建 Subscription。</p>
37
66
  <h4>时间与队列</h4>
38
67
  <ul>
39
68
  <li><b>Sampling</b>:服务器检查 MonitoredItem 的请求间隔。</li>
@@ -42,6 +71,7 @@
42
71
  <li>服务器可以把请求间隔修订为它支持的值。</li>
43
72
  </ul>
44
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>
45
75
  <h4>输出</h4>
46
76
  <ul>
47
77
  <li><b>Compact</b>:NodeId/alias、值、状态和源时间戳。</li>
@@ -55,7 +85,7 @@
55
85
  <h3>Subscribe</h3>
56
86
  <p>Creates one or more OPC UA subscriptions and MonitoredItems so the server sends data-change notifications. It is not a periodic client Read. Large point sets are automatically sharded across Subscriptions according to server limits.</p>
57
87
  <h4>Input and commands</h4>
58
- <p>Point lists may come from arrays, JSON, CSV, or Browse output. Use <code>replace</code>, <code>add</code>, <code>remove</code>, or <code>clear</code> in <code>msg.opcua.command</code> to manage points dynamically.</p>
88
+ <p>Save or import a JSON/CSV point list to start automatically after deploy. Point lists may also come from messages or Browse output. Use <code>replace</code>, <code>add</code>, <code>remove</code>, or <code>clear</code> in <code>msg.opcua.command</code> to manage points dynamically. Repeating an unchanged list does not rebuild the server Subscription.</p>
59
89
  <h4>Timing and queues</h4>
60
90
  <ul>
61
91
  <li><b>Sampling</b>: Requested interval at which the server checks a MonitoredItem.</li>
@@ -64,6 +94,7 @@
64
94
  <li>The server may revise requested intervals to supported values.</li>
65
95
  </ul>
66
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>
67
98
  <h4>Output</h4>
68
99
  <ul>
69
100
  <li><b>Compact</b>: NodeId/alias, value, status, and source timestamp.</li>
@@ -77,7 +108,8 @@
77
108
  RED.nodes.registerType('tier0-opcua-subscribe', {
78
109
  category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 1, icon: 'bridge.png',
79
110
  defaults: {
80
- connection: { value: '', type: 'tier0-opcua-connection', required: true }, nodeId: { value: '' },
111
+ connection: { value: '', type: 'tier0-opcua-connection', required: true },
112
+ nodeId: { value: '' }, pointList: { value: '' }, pointListFileName: { value: '' },
81
113
  publishingInterval: { value: 1000, validate: RED.validators.number() }, samplingInterval: { value: 1000, validate: RED.validators.number() },
82
114
  queueSize: { value: 1, validate: RED.validators.number() }, discardOldest: { value: true },
83
115
  deadbandType: { value: 'none' }, deadbandValue: { value: 0, validate: RED.validators.number() },
@@ -91,6 +123,134 @@
91
123
  lifetimeCount: { value: 120 }, maxKeepAliveCount: { value: 10 }, maxNotificationsPerPublish: { value: 0 },
92
124
  reconnectInterval: { value: 5000 }, name: { value: '' }
93
125
  },
126
+ oneditprepare: function () {
127
+ const points = new Map();
128
+
129
+ const csvLine = (line) => {
130
+ const fields = [];
131
+ let value = '';
132
+ let quoted = false;
133
+ for (let index = 0; index < line.length; index += 1) {
134
+ const character = line[index];
135
+ if (character === '"') {
136
+ if (quoted && line[index + 1] === '"') { value += '"'; index += 1; }
137
+ else quoted = !quoted;
138
+ } else if (character === ',' && !quoted) {
139
+ fields.push(value.trim()); value = '';
140
+ } else value += character;
141
+ }
142
+ fields.push(value.trim());
143
+ return fields;
144
+ };
145
+
146
+ const parse = (content) => {
147
+ const text = String(content || '').replace(/^\uFEFF/, '').trim();
148
+ if (!text) return [];
149
+ if (text.startsWith('[') || text.startsWith('{')) {
150
+ const parsed = JSON.parse(text);
151
+ return Array.isArray(parsed) ? parsed : (Array.isArray(parsed.items) ? parsed.items : [parsed]);
152
+ }
153
+ const lines = text.split(/\r?\n/).filter((line) => line.trim() && !line.trimStart().startsWith('#'));
154
+ if (!lines.length) return [];
155
+ const headers = csvLine(lines.shift());
156
+ if (!headers.includes('nodeId')) throw new Error('CSV header must contain nodeId');
157
+ return lines.map((line) => {
158
+ const values = csvLine(line);
159
+ return Object.fromEntries(headers.map((header, index) => [header, values[index] || '']));
160
+ });
161
+ };
162
+
163
+ const normalize = (item) => {
164
+ const source = typeof item === 'string' ? { nodeId: item } : { ...(item || {}) };
165
+ source.nodeId = String(source.nodeId || source.topic || '').trim();
166
+ if (!source.nodeId) throw new Error('Point is missing nodeId');
167
+ return source;
168
+ };
169
+
170
+ const persist = () => {
171
+ const items = [...points.values()];
172
+ $('#node-input-pointList').val(items.length ? JSON.stringify(items) : '');
173
+ if (items.length) $('#node-input-nodeId').val('');
174
+ $('#node-input-pointListStatus').text(`${items.length} point${items.length === 1 ? '' : 's'}`);
175
+ };
176
+
177
+ const render = () => {
178
+ const list = document.getElementById('node-input-pointListItems');
179
+ list.textContent = '';
180
+ if (!points.size) {
181
+ const empty = document.createElement('li');
182
+ empty.className = 'opcua-point-empty';
183
+ empty.textContent = 'No saved points. Add a NodeId, import a file, or supply points by message.';
184
+ list.appendChild(empty);
185
+ } else {
186
+ const fragment = document.createDocumentFragment();
187
+ points.forEach((point, nodeId) => {
188
+ const row = document.createElement('li');
189
+ const text = document.createElement('span');
190
+ text.className = 'opcua-point-text';
191
+ text.textContent = point.alias ? `${point.alias} — ${nodeId}` : nodeId;
192
+ text.title = text.textContent;
193
+ const remove = document.createElement('button');
194
+ remove.type = 'button';
195
+ remove.className = 'red-ui-button opcua-point-remove';
196
+ remove.title = `Remove ${nodeId}`;
197
+ remove.innerHTML = '<i class="fa fa-times"></i>';
198
+ remove.addEventListener('click', () => { points.delete(nodeId); render(); });
199
+ row.append(text, remove);
200
+ fragment.appendChild(row);
201
+ });
202
+ list.appendChild(fragment);
203
+ }
204
+ persist();
205
+ };
206
+
207
+ const merge = (items) => {
208
+ items.forEach((item) => { const point = normalize(item); points.set(point.nodeId, point); });
209
+ render();
210
+ };
211
+
212
+ try {
213
+ const stored = $('#node-input-pointList').val();
214
+ if (stored) merge(parse(stored));
215
+ else if ($('#node-input-nodeId').val()) merge([{ nodeId: $('#node-input-nodeId').val() }]);
216
+ else render();
217
+ } catch (error) {
218
+ RED.notify(`Cannot load point list: ${error.message}`, 'error');
219
+ render();
220
+ }
221
+
222
+ $('#node-input-addPoint').on('click', () => {
223
+ try {
224
+ merge([{ nodeId: $('#node-input-newPointNodeId').val() }]);
225
+ $('#node-input-newPointNodeId').val('').focus();
226
+ } catch (error) { RED.notify(error.message, 'error'); }
227
+ });
228
+ $('#node-input-newPointNodeId').on('keydown', function (event) {
229
+ if (event.key === 'Enter') { event.preventDefault(); $('#node-input-addPoint').click(); }
230
+ });
231
+ $('#node-input-importPointList').on('click', () => $('#node-input-pointFile').click());
232
+ $('#node-input-pointFile').on('change', function () {
233
+ const file = this.files && this.files[0];
234
+ if (!file) return;
235
+ const reader = new FileReader();
236
+ reader.onload = () => {
237
+ try {
238
+ const imported = parse(reader.result);
239
+ merge(imported);
240
+ $('#node-input-pointListFileName').val(file.name);
241
+ RED.notify(`${imported.length} point(s) imported`, 'success');
242
+ } catch (error) { RED.notify(`Import failed: ${error.message}`, 'error'); }
243
+ $('#node-input-pointFile').val('');
244
+ };
245
+ reader.readAsText(file);
246
+ });
247
+ $('#node-input-clearPointList').on('click', () => {
248
+ points.clear();
249
+ $('#node-input-nodeId').val('');
250
+ $('#node-input-pointListFileName').val('');
251
+ render();
252
+ });
253
+ },
94
254
  label: function () { return this.name || 'tier0-opcua-subscribe'; }
95
255
  });
96
256
  </script>