@tier0/opcua-client 0.4.0 → 0.5.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/BENCHMARK.md CHANGED
@@ -132,3 +132,30 @@ The same deployed Flow was tested with a separate native client generating the f
132
132
  Write CSV parsing was then changed from multiple whole-payload arrays and object copies to a single line scan that constructs compact final write descriptors. Two mixed Node-RED runs, each producing CSV and executing all 100,000 writes inside the Flow, recorded stable peaks of 226.73 MB and 226.06 MB. The immediately preceding fresh-process repeat peaked at 362.29 MB, so the repeatable peak reduction was approximately 37.6%. The optimized repeat recorded Write P50/P95 of 1,410/1,769 ms, delivered 120,000/120,000 native notifications with zero drops, and converged 20,000/20,000 final values. These are process-RSS engineering measurements; allocator and garbage-collector behavior still require production baselines on the target Node-RED image.
133
133
 
134
134
  The 20,000-point outage acceptance stopped and restarted the real OPC UA fixture after the initial four Subscriptions were created. The Subscribe node rebuilt exactly four Subscriptions with 20,000/20,000 MonitoredItems and zero failures. Two subsequent complete write rounds plus the rebuilt initial values delivered 60,000/60,000 native notifications with zero drops and final convergence for all 20,000 points. Recovery-phase RSS peaked at 175.04 MB and the complete run at 184.5 MB. The recovery status explicitly reported `rebuilt=true` and `dataGapPossible=true` because a server-side subscription rebuild cannot guarantee changes that occurred during the outage.
135
+
136
+ ## 0.5.0 knee-point benchmark
137
+
138
+ Recorded on 2026-08-24 with Node-RED 4.1.10, Node.js 22.22.2, open62541 1.4.16, and package 0.5.0. Every tier used a fresh Node-RED process constrained to 2 vCPU and 2 GB. The server and clients used security mode `None` over container loopback. Service batches contained 500 nodes, each server Subscription contained at most 5,000 MonitoredItems, publishing and sampling intervals were 100 ms, and an independent native client changed every monitored value so the Node-RED Subscribe path could be measured without CSV construction in the Write node.
139
+
140
+ | Points | Read P50 / P95 | External write P50 / P95 | Subscribe setup | Native notifications | Max callback lag | Node-RED peak RSS |
141
+ | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
142
+ | 5,000 | 195 / 206 ms | 195 / 196 ms | 232 ms | 30,000, 0 dropped | 0.21 ms | 132.4 MB |
143
+ | 10,000 | 411 / 467 ms | 392 / 396 ms | 475 ms | 60,000, 0 dropped | 1.00 ms | 153.4 MB |
144
+ | 20,000 | 817 / 852 ms | 788 / 792 ms | 915 ms | 120,000, 0 dropped | 2.42 ms | 182.1 MB |
145
+ | 30,000 | 1,263 / 1,274 ms | 1,208 / 1,219 ms | 1,523 ms | 180,000, 0 dropped | 2.18 ms | 194.4 MB |
146
+ | 40,000 | 1,673 / 1,701 ms | 1,753 / 1,795 ms | 1,889 ms | 240,000, 0 dropped | 2.27 ms | 218.8 MB |
147
+ | 60,000 | 2,521 / 2,556 ms | 2,590 / 2,641 ms | 2,860 ms | 360,000, 0 dropped | 5.07 ms | 261.3 MB |
148
+ | 100,000 | 4,215 / 4,263 ms | 4,758 / 4,884 ms | 5,174 ms | 600,000, 0 dropped | 1.64 ms | 391.4 MB |
149
+ | 150,000 | 6,353 / 6,470 ms | 8,045 / 8,137 ms | 7,582 ms | 600,000, 0 dropped | 27.53 ms | 463.2 MB |
150
+ | 175,000 | 7,488 ms | 11,864 ms | 9,290 ms | 525,000, 0 dropped | 1.91 ms | 526.7 MB |
151
+ | 200,000 | 8,485 / 8,663 ms | 42,410 / 43,425 ms | 10,968 ms | 800,000, 0 dropped | 1.73 ms | 564.0 MB |
152
+
153
+ The 5,000 through 100,000 Read results remain close to 23,000-25,000 values per second. A one-second full-poll target therefore crosses its practical boundary between 20,000 and 30,000 points even though larger batches remain correct. Subscription creation stays near 18,000-22,000 MonitoredItems per second through 200,000 points.
154
+
155
+ The coupled server/client system begins a soft knee around 150,000 monitored points. At that tier callback lag repeatedly reached about 27 ms and the default 100,000-entry NodeId cache started heavy eviction. Increasing the cache to 200,000 removed all evictions and improved Read P50 from 6,524 ms to 6,353 ms, but raised peak RSS from 425 MB to 463 MB and did not remove the callback-lag spike. Cache size is therefore a secondary tuning control, not the main scale limit.
156
+
157
+ The sharp knee occurred between 175,000 and 200,000 monitored points. Increasing the point set by 14% increased one full write from about 11.9 seconds to 42.4 seconds. Node-RED still delivered all 800,000 native notifications with zero drops at 200,000 points, so the collapse occurred first in the open62541 server's processing of value changes under a very large monitored set, rather than in the Node-RED callback queue. A Siemens WinCC server can reach its own knee much earlier or later depending on licensed limits, Session and Subscription quotas, security, hardware, and project load.
158
+
159
+ The editor Browse API intentionally caps one tree request at 100,000 total nodes. A namespace containing approximately 100,000 variables plus their Area objects therefore cannot be rendered as one complete selectable tree. Browse a narrower Area/root or import the point list instead of raising this UI protection.
160
+
161
+ For production, keep a one-second full Read at or below roughly 20,000 points per Connection on equivalent hardware. For Subscribe, 50,000-60,000 monitored points per Connection is a conservative local-loopback engineering envelope with substantial margin below the observed system knee. Do not automatically add Sessions to exceed it: first verify the target server's Session, Subscription, MonitoredItem, publish, and sampling limits. Above 100,000 points, raise `nodeIdCacheSize` only with a corresponding memory budget and test the real server under the expected change rate and security policy.
package/README.md CHANGED
@@ -39,6 +39,8 @@ Browse output can be wired directly into Read or Subscribe. In the Read and Writ
39
39
 
40
40
  Subscription commands are `replace`, `add`, `remove`, and `clear`. Read and Write split large arrays into configurable service batches; they do not loop over scalar OPC UA requests.
41
41
 
42
+ Subscribe nodes can save or import their own point list and establish it automatically after deploy. Repeating an unchanged `replace` command preserves existing server Subscriptions and MonitoredItems. Only added, removed, or monitoring-parameter-changed points are reconciled. A rejected point is reported in `failures` without stopping valid points, and requested versus server-revised publishing, sampling, lifetime, keepalive, and queue settings are included in subscription status output.
43
+
42
44
  New Read nodes default to compact results: `nodeId`, optional `alias`, `value`,
43
45
  `statusCode`, and `sourceTimestamp`. Browse metadata and subscription-only point
44
46
  settings are not repeated on every Read. **Value only** emits an array in point-
@@ -107,6 +109,13 @@ Session is intentional. Read/Write diagnostics and subscription status outputs
107
109
  include `connectionGovernance`, with the equivalent and active configuration
108
110
  counts.
109
111
 
112
+ `connectionUsage` reports the current Read/Write/Browse/Subscribe consumer count
113
+ and types, each consumer's last use, aggregate operation counts, and connection
114
+ idle time. It is diagnostic only: `automaticIdleDisconnect` is false. A deployed
115
+ Connection therefore remains stable for subscriptions until its configuration
116
+ node is closed; deleting the last referencing flow is visible as a zero consumer
117
+ count but does not race an idle timer.
118
+
110
119
  ## Connection and subscription recovery
111
120
 
112
121
  The native client reports secure-channel, session, connectivity-check, and subscription-inactivity transitions to a JavaScript connection supervisor. Recoverable channel interruptions keep the native client alive. Terminal failures replace the native client and retry with exponential backoff, 20% jitter, and the configured maximum interval. Operations fail fast with `OPCUA_RECONNECTING` while the connection is unavailable instead of building an unbounded request queue.
@@ -137,6 +146,12 @@ message or Write node identifies one target NodeId. A configured single
137
146
  `useInput` point behaves the same way. Set `msg.opcua.pointList = true` only when
138
147
  an array payload must be forced to mean a dynamic list of point descriptors.
139
148
 
149
+ OPC UA Int64 and UInt64 values inside the JavaScript safe-integer range are
150
+ returned as Numbers. Larger values are returned as exact decimal strings so
151
+ totals and counters never silently lose precision. Use decimal strings for
152
+ 64-bit writes; unsafe Numbers, fractional integers, and values outside the OPC UA
153
+ type range are rejected before a service request.
154
+
140
155
  ```json
141
156
  {
142
157
  "topic": "ns=2;s=Recipe.Steps",
@@ -154,6 +169,13 @@ The Browse editor loads the address space through the deployed Connection node.
154
169
 
155
170
  The selected Variable descriptors are saved with the Browse node. Each input message outputs the full saved selection so it can be wired directly into Read or Subscribe. Send `msg.opcua.liveBrowse = true` when a flow needs to ignore the saved selection and perform a new live Browse.
156
171
 
172
+ Recursive Browse requests batch multiple parent nodes and honor the server's
173
+ `MaxNodesPerBrowse` OperationLimit. `Browse batch` and `References / page` bound
174
+ each request. If `Maximum depth` or `Maximum nodes` truncates a live Browse,
175
+ `msg.opcua.browse.truncated` and `truncationReason` make the partial result
176
+ explicit. Unused server ContinuationPoints are released before the operation
177
+ finishes.
178
+
157
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.
158
180
 
159
181
  ## Secure connections
@@ -190,7 +212,7 @@ Node-RED Alpine image, the pinned open62541 v1.4.16 source archive, and its
190
212
  not contact npm or GitHub and does not invoke a compiler:
191
213
 
192
214
  ```bash
193
- npm install ./tier0-opcua-client-0.4.0.tgz --offline
215
+ npm install ./tier0-opcua-client-0.5.0.tgz --offline
194
216
  ```
195
217
 
196
218
  The loader uses the bundled native addon directly. Set
@@ -207,6 +229,11 @@ npm run build:offline-prebuild -- --nodedir=/usr/local
207
229
  npm pack
208
230
  ```
209
231
 
232
+ Before publishing, `npm run test:release` runs the JavaScript, native, offline
233
+ runtime, and deployed Node-RED suites when their test Server and Node-RED runtime
234
+ are available. `npm pack` also reruns the JavaScript suite and rejects an
235
+ incomplete offline package.
236
+
210
237
  See `THIRD_PARTY_NOTICES.md` for the bundled open62541 and node-addon-api
211
238
  licenses.
212
239
 
package/lib/client.js CHANGED
@@ -164,7 +164,28 @@ class OpcUaClient extends EventEmitter {
164
164
  }
165
165
 
166
166
  async browse(options) {
167
- return this.#invoke('browseAsync', options);
167
+ const items = await this.#invoke('browseAsync', options);
168
+ const browse = {
169
+ truncated: Boolean(items?.truncated),
170
+ truncationReason: items?.truncationReason || null,
171
+ serviceCalls: Number(items?.serviceCalls) || 0,
172
+ browseBatchSize: Number(items?.browseBatchSize) || 1,
173
+ releasedContinuationPoints: Number(items?.releasedContinuationPoints) || 0,
174
+ failures: Array.isArray(items?.browseFailures) ? items.browseFailures : [],
175
+ };
176
+ this.diagnostics.browse = browse;
177
+ if (Array.isArray(items)) {
178
+ for (const field of [
179
+ 'truncated', 'truncationReason', 'serviceCalls', 'browseBatchSize',
180
+ 'releasedContinuationPoints', 'browseFailures',
181
+ ]) delete items[field];
182
+ Object.defineProperty(items, 'diagnostics', {
183
+ configurable: true,
184
+ enumerable: false,
185
+ value: browse,
186
+ });
187
+ }
188
+ return items;
168
189
  }
169
190
 
170
191
  async readMany(points, batchSize = 500) {
@@ -248,6 +269,7 @@ class OpcUaClient extends EventEmitter {
248
269
  const nativeDiagnostics = addon.getClientDiagnostics(this.handle) || {};
249
270
  this.diagnostics = {
250
271
  ...nativeDiagnostics,
272
+ browse: this.diagnostics.browse,
251
273
  connection: {
252
274
  ...(nativeDiagnostics.connection || {}),
253
275
  supervisor: { ...this.supervisorDiagnostics },
package/lib/points.js CHANGED
@@ -6,6 +6,17 @@ const DEFAULTS = Object.freeze({
6
6
  discardOldest: true,
7
7
  });
8
8
 
9
+ const INTEGER_RANGES = Object.freeze({
10
+ SByte: [-128n, 127n],
11
+ Byte: [0n, 255n],
12
+ Int16: [-32768n, 32767n],
13
+ UInt16: [0n, 65535n],
14
+ Int32: [-2147483648n, 2147483647n],
15
+ UInt32: [0n, 4294967295n],
16
+ Int64: [-9223372036854775808n, 9223372036854775807n],
17
+ UInt64: [0n, 18446744073709551615n],
18
+ });
19
+
9
20
  function positiveNumber(value, fallback) {
10
21
  const number = Number(value);
11
22
  return Number.isFinite(number) && number > 0 ? number : fallback;
@@ -245,15 +256,39 @@ function messagePoints(msg, staticNodeId, defaults = {}, options = {}) {
245
256
  }
246
257
 
247
258
  function coerceWriteValue(value, dataType) {
248
- if (value == null || typeof value !== 'string') return value;
249
259
  const type = String(dataType || 'String');
260
+ if (Array.isArray(value)) {
261
+ return value.map((item) => coerceWriteValue(item, type));
262
+ }
263
+ if (value == null) return value;
250
264
  if (type === 'Boolean') {
251
- if (/^(true|1)$/i.test(value)) return true;
252
- if (/^(false|0)$/i.test(value)) return false;
265
+ if (typeof value === 'boolean') return value;
266
+ if (/^(true|1)$/i.test(String(value))) return true;
267
+ if (/^(false|0)$/i.test(String(value))) return false;
253
268
  throw new Error(`Invalid Boolean value: ${value}`);
254
269
  }
255
- if (/^(S?Byte|U?Int(16|32|64)|Float|Double)$/.test(type)) {
256
- const number = Number(value);
270
+ if (Object.prototype.hasOwnProperty.call(INTEGER_RANGES, type)) {
271
+ let integer;
272
+ if (typeof value === 'bigint') integer = value;
273
+ else if (typeof value === 'number') {
274
+ if (!Number.isSafeInteger(value)) {
275
+ throw new Error(`Invalid ${type} value: ${value}; use a decimal string for 64-bit integers`);
276
+ }
277
+ integer = BigInt(value);
278
+ } else if (typeof value === 'string' && /^-?\d+$/.test(value.trim())) {
279
+ integer = BigInt(value.trim());
280
+ } else throw new Error(`Invalid ${type} value: ${value}`);
281
+ const [minimum, maximum] = INTEGER_RANGES[type];
282
+ if (integer < minimum || integer > maximum) {
283
+ throw new Error(`Invalid ${type} value: ${value}; outside OPC UA range`);
284
+ }
285
+ if (type === 'Int64' || type === 'UInt64') {
286
+ return typeof value === 'number' ? value : integer.toString();
287
+ }
288
+ return Number(integer);
289
+ }
290
+ if (type === 'Float' || type === 'Double') {
291
+ const number = typeof value === 'number' ? value : Number(value);
257
292
  if (!Number.isFinite(number)) throw new Error(`Invalid ${type} value: ${value}`);
258
293
  return number;
259
294
  }
package/nodes/browse.html CHANGED
@@ -36,6 +36,8 @@
36
36
  <div class="form-row"><label for="node-input-variablesOnly">Variables only</label><input type="checkbox" id="node-input-variablesOnly" style="width:auto"></div>
37
37
  <div class="form-row"><label for="node-input-maxDepth">Maximum depth</label><input type="number" id="node-input-maxDepth"></div>
38
38
  <div class="form-row"><label for="node-input-maxNodes">Maximum nodes</label><input type="number" id="node-input-maxNodes"></div>
39
+ <div class="form-row"><label for="node-input-browseBatchSize">Browse batch</label><input type="number" id="node-input-browseBatchSize"></div>
40
+ <div class="form-row"><label for="node-input-referencesPerNode">References / page</label><input type="number" id="node-input-referencesPerNode"></div>
39
41
  <div class="form-row"><label for="node-input-attributeBatchSize">Attribute batch</label><input type="number" id="node-input-attributeBatchSize"></div>
40
42
  <div class="form-row"><label for="node-input-outputMode">Output</label><select id="node-input-outputMode"><option value="all">All selected points</option><option value="paged">Paged selected points</option></select></div>
41
43
  <div class="form-row"><label for="node-input-pageSize">Page size</label><input type="number" id="node-input-pageSize"></div>
@@ -54,7 +56,7 @@
54
56
  <li><code>msg.nodeId</code> 或 <code>msg.topic</code> 可覆盖根 NodeId;默认根节点为 ObjectsFolder。</li>
55
57
  </ul>
56
58
  <h4>输出</h4>
57
- <p><code>msg.payload</code> 是点位描述数组,包含 NodeId、名称、Browse 路径、数据类型和访问属性,可直接连接 Read 或 Subscribe。Paged 模式会分批输出,并通过 <code>msg.opcua.command</code> 标记 replace/add。</p>
59
+ <p><code>msg.payload</code> 是点位描述数组,包含 NodeId、名称、Browse 路径、数据类型和访问属性,可直接连接 Read 或 Subscribe。Paged 模式会分批输出,并通过 <code>msg.opcua.command</code> 标记 replace/add。浏览被最大深度或节点数截断时,<code>msg.opcua.browse.truncated</code> 为 true;节点会释放未使用的 ContinuationPoint。</p>
58
60
  </section>
59
61
  <hr>
60
62
  <section lang="en">
@@ -68,7 +70,7 @@
68
70
  <li><code>msg.nodeId</code> or <code>msg.topic</code> can override the root NodeId; the default root is ObjectsFolder.</li>
69
71
  </ul>
70
72
  <h4>Output</h4>
71
- <p><code>msg.payload</code> is an array of point descriptors containing NodeId, names, Browse path, data type, and access attributes. It can feed Read or Subscribe directly. Paged mode emits multiple messages with replace/add in <code>msg.opcua.command</code>.</p>
73
+ <p><code>msg.payload</code> is an array of point descriptors containing NodeId, names, Browse path, data type, and access attributes. It can feed Read or Subscribe directly. Paged mode emits multiple messages with replace/add in <code>msg.opcua.command</code>. When maximum depth or node count truncates a Browse, <code>msg.opcua.browse.truncated</code> is true; unused ContinuationPoints are released.</p>
72
74
  </section>
73
75
  </script>
74
76
  <script type="text/javascript">
@@ -78,6 +80,8 @@
78
80
  connection: { value: '', type: 'tier0-opcua-connection', required: true }, rootNodeId: { value: 'ns=0;i=85' },
79
81
  selectedNodes: { value: [] }, recursive: { value: true }, variablesOnly: { value: true },
80
82
  maxDepth: { value: 10, validate: RED.validators.number() }, maxNodes: { value: 10000, validate: RED.validators.number() },
83
+ browseBatchSize: { value: 100, validate: RED.validators.number() },
84
+ referencesPerNode: { value: 1000, validate: RED.validators.number() },
81
85
  attributeBatchSize: { value: 200, validate: RED.validators.number() }, outputMode: { value: 'all' },
82
86
  pageSize: { value: 500, validate: RED.validators.number() }, name: { value: '' }
83
87
  },
@@ -240,6 +244,8 @@
240
244
  rootNodeId: $('#node-input-rootNodeId').val() || 'ns=0;i=85',
241
245
  maxDepth: $('#node-input-maxDepth').val(),
242
246
  maxNodes: $('#node-input-maxNodes').val(),
247
+ browseBatchSize: $('#node-input-browseBatchSize').val(),
248
+ referencesPerNode: $('#node-input-referencesPerNode').val(),
243
249
  attributeBatchSize: $('#node-input-attributeBatchSize').val()
244
250
  })
245
251
  }).done((data) => {
@@ -248,6 +254,11 @@
248
254
  return;
249
255
  }
250
256
  renderTree(data.tree);
257
+ if (data.truncated) {
258
+ statusArea.text(`${data.total} node(s) loaded; truncated by ${data.truncationReason || 'server limit'}.`);
259
+ } else if (Array.isArray(data.failures) && data.failures.length) {
260
+ statusArea.text(`${data.total} node(s) loaded; ${data.failures.length} branch(es) failed.`);
261
+ }
251
262
  }).fail((request) => {
252
263
  const message = request.responseJSON && request.responseJSON.error || request.responseText || 'Browse failed';
253
264
  statusArea.text(message);
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);
@@ -126,6 +126,58 @@ module.exports = function registerConnectionNode(RED) {
126
126
  function OpcUaConnectionNode(config) {
127
127
  RED.nodes.createNode(this, config);
128
128
  const node = this;
129
+ const dependentCleanups = new Set();
130
+ const consumers = new Map();
131
+ const operations = new Map();
132
+ let nextConsumerId = 1;
133
+ let lastUsedAt = null;
134
+ let totalOperations = 0;
135
+
136
+ function markUsed(operation = 'unknown', consumer) {
137
+ const now = Date.now();
138
+ const name = String(operation || 'unknown');
139
+ lastUsedAt = now;
140
+ totalOperations += 1;
141
+ operations.set(name, (operations.get(name) || 0) + 1);
142
+ if (consumer && consumers.has(consumer.id)) {
143
+ consumer.lastUsedAt = now;
144
+ consumer.operations += 1;
145
+ }
146
+ }
147
+
148
+ function connectionUsage() {
149
+ const consumerTypes = {};
150
+ for (const consumer of consumers.values()) {
151
+ consumerTypes[consumer.type] = (consumerTypes[consumer.type] || 0) + 1;
152
+ }
153
+ return {
154
+ consumerCount: consumers.size,
155
+ consumerTypes,
156
+ consumers: [...consumers.values()].map((consumer) => ({
157
+ id: consumer.nodeId || consumer.id,
158
+ type: consumer.type,
159
+ name: consumer.name || '',
160
+ registeredAt: new Date(consumer.registeredAt).toISOString(),
161
+ lastUsedAt: consumer.lastUsedAt == null
162
+ ? null : new Date(consumer.lastUsedAt).toISOString(),
163
+ operations: consumer.operations,
164
+ })),
165
+ lastUsedAt: lastUsedAt == null ? null : new Date(lastUsedAt).toISOString(),
166
+ idleMs: lastUsedAt == null ? null : Math.max(0, Date.now() - lastUsedAt),
167
+ totalOperations,
168
+ operations: Object.fromEntries(operations),
169
+ automaticIdleDisconnect: false,
170
+ };
171
+ }
172
+
173
+ function runDependentCleanup(record) {
174
+ if (!record.promise) {
175
+ record.promise = Promise.resolve()
176
+ .then(record.cleanup)
177
+ .finally(() => dependentCleanups.delete(record));
178
+ }
179
+ return record.promise;
180
+ }
129
181
 
130
182
  const options = clientOptions(config, node.credentials);
131
183
  const registration = connectionRegistry.register(options);
@@ -137,7 +189,41 @@ module.exports = function registerConnectionNode(RED) {
137
189
  node.getDiagnostics = () => ({
138
190
  ...node.client.getDiagnostics(),
139
191
  connectionGovernance: node.getConnectionGovernance(),
192
+ connectionUsage: connectionUsage(),
140
193
  });
194
+ node.getConnectionUsage = connectionUsage;
195
+ node.markUsed = (operation) => markUsed(operation);
196
+ node.registerConsumer = (type, details = {}) => {
197
+ const consumer = {
198
+ id: nextConsumerId++,
199
+ nodeId: String(details.nodeId || ''),
200
+ name: String(details.name || ''),
201
+ type: String(type || 'unknown'),
202
+ registeredAt: Date.now(),
203
+ lastUsedAt: null,
204
+ operations: 0,
205
+ };
206
+ consumers.set(consumer.id, consumer);
207
+ let released = false;
208
+ return {
209
+ markUsed(operation) {
210
+ if (!released) markUsed(operation || consumer.type, consumer);
211
+ },
212
+ release() {
213
+ if (released) return;
214
+ released = true;
215
+ consumers.delete(consumer.id);
216
+ },
217
+ };
218
+ };
219
+ node.registerDependentCleanup = (cleanup) => {
220
+ if (typeof cleanup !== 'function') {
221
+ throw new TypeError('OPC UA dependent cleanup must be a function');
222
+ }
223
+ const record = { cleanup, promise: null };
224
+ dependentCleanups.add(record);
225
+ return () => runDependentCleanup(record);
226
+ };
141
227
 
142
228
  if (registration.duplicate && config.allowParallelSession !== true) {
143
229
  node.warn(
@@ -161,6 +247,15 @@ module.exports = function registerConnectionNode(RED) {
161
247
  node.client.off('fault', onFault);
162
248
  node.client.off('state', onState);
163
249
  registration.unregister();
250
+ const cleanupResults = await Promise.allSettled(
251
+ [...dependentCleanups].map(runDependentCleanup)
252
+ );
253
+ for (const result of cleanupResults) {
254
+ if (result.status === 'rejected') {
255
+ node.warn(`OPC UA dependent cleanup failed: ${result.reason?.message || result.reason}`);
256
+ }
257
+ }
258
+ consumers.clear();
164
259
  try {
165
260
  await node.client.disconnect();
166
261
  } 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);