@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 +27 -0
- package/README.md +28 -1
- package/lib/client.js +23 -1
- package/lib/points.js +40 -5
- package/nodes/browse.html +13 -2
- package/nodes/browse.js +29 -2
- package/nodes/connection.js +95 -0
- package/nodes/read.html +2 -0
- package/nodes/read.js +10 -0
- package/nodes/subscribe.html +163 -5
- package/nodes/subscribe.js +190 -21
- package/nodes/write.html +2 -0
- package/nodes/write.js +10 -0
- package/package.json +3 -2
- package/prebuilds/linux-x64-musl/opcua_open62541.node +0 -0
- package/scripts/verify-offline-package.js +21 -1
- package/src/addon.cc +335 -49
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.
|
|
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
|
-
|
|
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 (
|
|
252
|
-
if (/^(
|
|
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 (
|
|
256
|
-
|
|
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
|
|
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({
|
|
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
|
-
|
|
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);
|
package/nodes/connection.js
CHANGED
|
@@ -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);
|