@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/BENCHMARK.md +27 -0
- package/README.md +48 -6
- package/lib/client.js +61 -10
- package/lib/compatibility.js +28 -0
- package/lib/points.js +40 -5
- package/lib/security.js +182 -2
- package/nodes/browse.html +13 -2
- package/nodes/browse.js +29 -2
- package/nodes/connection.html +19 -9
- package/nodes/connection.js +71 -0
- package/nodes/read.html +2 -0
- package/nodes/read.js +10 -0
- package/nodes/subscribe.html +165 -5
- package/nodes/subscribe.js +218 -23
- 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 +343 -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,21 +169,43 @@ 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
|
|
|
181
|
+
## Server compatibility profiles
|
|
182
|
+
|
|
183
|
+
Connection nodes default to the standard OPC UA profile. Select **Siemens
|
|
184
|
+
WinCC** for classic WinCC or Unified servers that do not advertise usable
|
|
185
|
+
OperationLimits: Read, Write, and MonitoredItem service calls then use a
|
|
186
|
+
conservative fallback of 200 items per request, while advertised non-zero
|
|
187
|
+
server limits still take precedence. Subscribe nodes use at most 1000
|
|
188
|
+
MonitoredItems per Subscription and remove Deadband filters because WinCC does
|
|
189
|
+
not support `ModifyMonitoredItems`/Deadband behavior consistently. The selected
|
|
190
|
+
profile and effective subscription behavior are included in status diagnostics.
|
|
191
|
+
|
|
159
192
|
## Secure connections
|
|
160
193
|
|
|
161
194
|
Connection nodes support `None/None` and `Sign` or `SignAndEncrypt` with
|
|
162
|
-
`Basic256Sha256
|
|
163
|
-
|
|
195
|
+
`Basic256Sha256`, `Aes128_Sha256_RsaOaep`, or `Aes256_Sha256_RsaPss`. Secure
|
|
196
|
+
connections require a client certificate, its private key, and at least one
|
|
197
|
+
trusted Server or CA certificate. Trust is strict: an
|
|
164
198
|
empty trust list is rejected instead of silently accepting every Server
|
|
165
199
|
certificate. Optional CRL files can be supplied as a newline-separated list.
|
|
166
200
|
Password-protected PEM private keys use the separate Node-RED credential field,
|
|
167
201
|
so the password is not stored in the normal Flow configuration.
|
|
168
202
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
203
|
+
Before opening the native client, the package checks certificate validity,
|
|
204
|
+
certificate/private-key matching, RSA key type and minimum 2048-bit strength,
|
|
205
|
+
trusted-certificate validity, and Application URI consistency. When the client
|
|
206
|
+
certificate contains exactly one URI, leaving Application URI empty selects it
|
|
207
|
+
automatically. Username authentication uses the selected secure endpoint and
|
|
208
|
+
its advertised UserTokenPolicy.
|
|
172
209
|
|
|
173
210
|
The Connection editor can call `GetEndpoints`, filters the response to the
|
|
174
211
|
implemented policies, and selects an endpoint compatible with Anonymous or
|
|
@@ -190,7 +227,7 @@ Node-RED Alpine image, the pinned open62541 v1.4.16 source archive, and its
|
|
|
190
227
|
not contact npm or GitHub and does not invoke a compiler:
|
|
191
228
|
|
|
192
229
|
```bash
|
|
193
|
-
npm install ./tier0-opcua-client-0.
|
|
230
|
+
npm install ./tier0-opcua-client-0.6.0.tgz --offline
|
|
194
231
|
```
|
|
195
232
|
|
|
196
233
|
The loader uses the bundled native addon directly. Set
|
|
@@ -207,6 +244,11 @@ npm run build:offline-prebuild -- --nodedir=/usr/local
|
|
|
207
244
|
npm pack
|
|
208
245
|
```
|
|
209
246
|
|
|
247
|
+
Before publishing, `npm run test:release` runs the JavaScript, native, offline
|
|
248
|
+
runtime, and deployed Node-RED suites when their test Server and Node-RED runtime
|
|
249
|
+
are available. `npm pack` also reruns the JavaScript suite and rejects an
|
|
250
|
+
incomplete offline package.
|
|
251
|
+
|
|
210
252
|
See `THIRD_PARTY_NOTICES.md` for the bundled open62541 and node-addon-api
|
|
211
253
|
licenses.
|
|
212
254
|
|
package/lib/client.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
const { EventEmitter } = require('events');
|
|
4
4
|
const { chunks } = require('./points');
|
|
5
5
|
const { loadAddon } = require('./native');
|
|
6
|
-
const {
|
|
6
|
+
const { preflightConnectionSecurity } = require('./security');
|
|
7
|
+
const { SIEMENS_WINCC_DEFAULTS, isSiemensWinCC } = require('./compatibility');
|
|
7
8
|
|
|
8
9
|
function normalizeNotificationBatch(notification) {
|
|
9
10
|
if (notification && Array.isArray(notification.changes)) {
|
|
@@ -16,13 +17,17 @@ function normalizeNotificationBatch(notification) {
|
|
|
16
17
|
};
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
function effectiveBatchSize(requested, serverLimit) {
|
|
20
|
+
function effectiveBatchSize(requested, serverLimit, fallbackLimit) {
|
|
20
21
|
const configured = Number.isFinite(Number(requested)) && Number(requested) > 0
|
|
21
22
|
? Math.floor(Number(requested))
|
|
22
23
|
: 500;
|
|
23
24
|
const limit = Number(serverLimit);
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
if (Number.isFinite(limit) && limit > 0) {
|
|
26
|
+
return Math.min(configured, Math.floor(limit));
|
|
27
|
+
}
|
|
28
|
+
const fallback = Number(fallbackLimit);
|
|
29
|
+
return Number.isFinite(fallback) && fallback > 0
|
|
30
|
+
? Math.min(configured, Math.floor(fallback))
|
|
26
31
|
: configured;
|
|
27
32
|
}
|
|
28
33
|
|
|
@@ -55,6 +60,7 @@ class OpcUaClient extends EventEmitter {
|
|
|
55
60
|
operationLimits: {},
|
|
56
61
|
nodeIdCache: {},
|
|
57
62
|
connection: {},
|
|
63
|
+
compatibility: this.#compatibilityDiagnostics(),
|
|
58
64
|
};
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -85,7 +91,8 @@ class OpcUaClient extends EventEmitter {
|
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
async #connect() {
|
|
88
|
-
const options =
|
|
94
|
+
const options = preflightConnectionSecurity(this.options);
|
|
95
|
+
this.diagnostics.securityPreflight = options.securityPreflight;
|
|
89
96
|
const addon = loadAddon();
|
|
90
97
|
const token = ++this.connectionToken;
|
|
91
98
|
this.connectionState = 'connecting';
|
|
@@ -164,14 +171,36 @@ class OpcUaClient extends EventEmitter {
|
|
|
164
171
|
}
|
|
165
172
|
|
|
166
173
|
async browse(options) {
|
|
167
|
-
|
|
174
|
+
const items = await this.#invoke('browseAsync', options);
|
|
175
|
+
const browse = {
|
|
176
|
+
truncated: Boolean(items?.truncated),
|
|
177
|
+
truncationReason: items?.truncationReason || null,
|
|
178
|
+
serviceCalls: Number(items?.serviceCalls) || 0,
|
|
179
|
+
browseBatchSize: Number(items?.browseBatchSize) || 1,
|
|
180
|
+
releasedContinuationPoints: Number(items?.releasedContinuationPoints) || 0,
|
|
181
|
+
failures: Array.isArray(items?.browseFailures) ? items.browseFailures : [],
|
|
182
|
+
};
|
|
183
|
+
this.diagnostics.browse = browse;
|
|
184
|
+
if (Array.isArray(items)) {
|
|
185
|
+
for (const field of [
|
|
186
|
+
'truncated', 'truncationReason', 'serviceCalls', 'browseBatchSize',
|
|
187
|
+
'releasedContinuationPoints', 'browseFailures',
|
|
188
|
+
]) delete items[field];
|
|
189
|
+
Object.defineProperty(items, 'diagnostics', {
|
|
190
|
+
configurable: true,
|
|
191
|
+
enumerable: false,
|
|
192
|
+
value: browse,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return items;
|
|
168
196
|
}
|
|
169
197
|
|
|
170
198
|
async readMany(points, batchSize = 500) {
|
|
171
199
|
const results = [];
|
|
172
200
|
await this.connect();
|
|
173
201
|
const effective = effectiveBatchSize(
|
|
174
|
-
batchSize, this.diagnostics.operationLimits?.maxNodesPerRead
|
|
202
|
+
batchSize, this.diagnostics.operationLimits?.maxNodesPerRead,
|
|
203
|
+
this.#compatibilityBatchFallback()
|
|
175
204
|
);
|
|
176
205
|
for (const batch of chunks(points, effective)) {
|
|
177
206
|
results.push(...await this.#invoke('readManyAsync', batch));
|
|
@@ -183,7 +212,8 @@ class OpcUaClient extends EventEmitter {
|
|
|
183
212
|
const results = [];
|
|
184
213
|
await this.connect();
|
|
185
214
|
const effective = effectiveBatchSize(
|
|
186
|
-
batchSize, this.diagnostics.operationLimits?.maxNodesPerWrite
|
|
215
|
+
batchSize, this.diagnostics.operationLimits?.maxNodesPerWrite,
|
|
216
|
+
this.#compatibilityBatchFallback()
|
|
187
217
|
);
|
|
188
218
|
for (const batch of chunks(points, effective)) {
|
|
189
219
|
results.push(...await this.#invoke('writeManyAsync', batch));
|
|
@@ -201,7 +231,8 @@ class OpcUaClient extends EventEmitter {
|
|
|
201
231
|
const results = [];
|
|
202
232
|
await this.connect();
|
|
203
233
|
const effective = effectiveBatchSize(
|
|
204
|
-
batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall
|
|
234
|
+
batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall,
|
|
235
|
+
this.#compatibilityBatchFallback()
|
|
205
236
|
);
|
|
206
237
|
for (const batch of chunks(points, effective)) {
|
|
207
238
|
results.push(...await this.#invoke(
|
|
@@ -215,7 +246,8 @@ class OpcUaClient extends EventEmitter {
|
|
|
215
246
|
const results = [];
|
|
216
247
|
await this.connect();
|
|
217
248
|
const effective = effectiveBatchSize(
|
|
218
|
-
batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall
|
|
249
|
+
batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall,
|
|
250
|
+
this.#compatibilityBatchFallback()
|
|
219
251
|
);
|
|
220
252
|
for (const batch of chunks(monitoredItemIds, effective)) {
|
|
221
253
|
results.push(...await this.#invoke('removeMonitoredItemsAsync', subscriptionId, batch));
|
|
@@ -240,6 +272,22 @@ class OpcUaClient extends EventEmitter {
|
|
|
240
272
|
return this.diagnostics;
|
|
241
273
|
}
|
|
242
274
|
|
|
275
|
+
#compatibilityBatchFallback() {
|
|
276
|
+
return isSiemensWinCC(this.options.compatibilityProfile)
|
|
277
|
+
? SIEMENS_WINCC_DEFAULTS.requestBatchSize
|
|
278
|
+
: 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#compatibilityDiagnostics() {
|
|
282
|
+
const siemensWincc = isSiemensWinCC(this.options.compatibilityProfile);
|
|
283
|
+
return {
|
|
284
|
+
profile: siemensWincc ? 'siemens-wincc' : 'standard',
|
|
285
|
+
requestBatchFallback: siemensWincc
|
|
286
|
+
? SIEMENS_WINCC_DEFAULTS.requestBatchSize
|
|
287
|
+
: null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
243
291
|
#refreshDiagnostics() {
|
|
244
292
|
if (this.handle == null) return;
|
|
245
293
|
const addon = loadAddon();
|
|
@@ -248,6 +296,9 @@ class OpcUaClient extends EventEmitter {
|
|
|
248
296
|
const nativeDiagnostics = addon.getClientDiagnostics(this.handle) || {};
|
|
249
297
|
this.diagnostics = {
|
|
250
298
|
...nativeDiagnostics,
|
|
299
|
+
browse: this.diagnostics.browse,
|
|
300
|
+
securityPreflight: this.diagnostics.securityPreflight,
|
|
301
|
+
compatibility: this.#compatibilityDiagnostics(),
|
|
251
302
|
connection: {
|
|
252
303
|
...(nativeDiagnostics.connection || {}),
|
|
253
304
|
supervisor: { ...this.supervisorDiagnostics },
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const COMPATIBILITY_PROFILES = Object.freeze({
|
|
4
|
+
Standard: 'standard',
|
|
5
|
+
SiemensWinCC: 'siemens-wincc',
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const SIEMENS_WINCC_DEFAULTS = Object.freeze({
|
|
9
|
+
requestBatchSize: 200,
|
|
10
|
+
monitoredItemsPerSubscription: 1000,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function normalizeCompatibilityProfile(value) {
|
|
14
|
+
return value === COMPATIBILITY_PROFILES.SiemensWinCC
|
|
15
|
+
? COMPATIBILITY_PROFILES.SiemensWinCC
|
|
16
|
+
: COMPATIBILITY_PROFILES.Standard;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isSiemensWinCC(value) {
|
|
20
|
+
return normalizeCompatibilityProfile(value) === COMPATIBILITY_PROFILES.SiemensWinCC;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = {
|
|
24
|
+
COMPATIBILITY_PROFILES,
|
|
25
|
+
SIEMENS_WINCC_DEFAULTS,
|
|
26
|
+
isSiemensWinCC,
|
|
27
|
+
normalizeCompatibilityProfile,
|
|
28
|
+
};
|
package/lib/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/lib/security.js
CHANGED
|
@@ -1,18 +1,111 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
|
|
3
6
|
const SECURITY_POLICY_URIS = Object.freeze({
|
|
4
7
|
None: 'http://opcfoundation.org/UA/SecurityPolicy#None',
|
|
5
8
|
Basic256Sha256: 'http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256',
|
|
9
|
+
Aes128_Sha256_RsaOaep: 'http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep',
|
|
10
|
+
Aes256_Sha256_RsaPss: 'http://opcfoundation.org/UA/SecurityPolicy#Aes256_Sha256_RsaPss',
|
|
6
11
|
});
|
|
7
12
|
|
|
8
13
|
const SECURITY_MODES = new Set(['None', 'Sign', 'SignAndEncrypt']);
|
|
9
14
|
|
|
10
|
-
function configurationError(message) {
|
|
15
|
+
function configurationError(message, code = 'OPCUA_INVALID_SECURITY_CONFIGURATION', cause) {
|
|
11
16
|
const error = new Error(message);
|
|
12
|
-
error.code =
|
|
17
|
+
error.code = code;
|
|
18
|
+
if (cause) error.cause = cause;
|
|
13
19
|
return error;
|
|
14
20
|
}
|
|
15
21
|
|
|
22
|
+
function readSecurityFile(filePath, label, readFileSync = fs.readFileSync) {
|
|
23
|
+
try {
|
|
24
|
+
return readFileSync(filePath);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw configurationError(
|
|
27
|
+
`Cannot read OPC UA ${label}: ${filePath}`,
|
|
28
|
+
'OPCUA_SECURITY_FILE_UNREADABLE',
|
|
29
|
+
error
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseCertificate(buffer, label, X509Certificate = crypto.X509Certificate) {
|
|
35
|
+
try {
|
|
36
|
+
return new X509Certificate(buffer);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
throw configurationError(
|
|
39
|
+
`Invalid OPC UA ${label}`,
|
|
40
|
+
'OPCUA_CERTIFICATE_INVALID',
|
|
41
|
+
error
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function applicationUris(certificate) {
|
|
47
|
+
const output = [];
|
|
48
|
+
const expression = /(?:^|,\s*)URI:((?:"[^"]+")|[^,]+)/g;
|
|
49
|
+
for (const match of String(certificate.subjectAltName || '').matchAll(expression)) {
|
|
50
|
+
const value = match[1].replace(/^"|"$/g, '').trim();
|
|
51
|
+
if (value && !output.includes(value)) output.push(value);
|
|
52
|
+
}
|
|
53
|
+
return output;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateCertificateTime(certificate, label, now = Date.now()) {
|
|
57
|
+
const validFrom = Date.parse(certificate.validFrom);
|
|
58
|
+
const validTo = Date.parse(certificate.validTo);
|
|
59
|
+
if (Number.isFinite(validFrom) && now < validFrom) {
|
|
60
|
+
throw configurationError(
|
|
61
|
+
`OPC UA ${label} is not valid before ${certificate.validFrom}`,
|
|
62
|
+
'OPCUA_CERTIFICATE_NOT_YET_VALID'
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (Number.isFinite(validTo) && now > validTo) {
|
|
66
|
+
throw configurationError(
|
|
67
|
+
`OPC UA ${label} expired at ${certificate.validTo}`,
|
|
68
|
+
'OPCUA_CERTIFICATE_EXPIRED'
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function loadPrivateKey(buffer, passphrase, createPrivateKey = crypto.createPrivateKey) {
|
|
74
|
+
const password = passphrase ? String(passphrase) : undefined;
|
|
75
|
+
const attempts = [
|
|
76
|
+
{ key: buffer, passphrase: password },
|
|
77
|
+
{ key: buffer, format: 'der', type: 'pkcs8', passphrase: password },
|
|
78
|
+
{ key: buffer, format: 'der', type: 'pkcs1', passphrase: password },
|
|
79
|
+
];
|
|
80
|
+
let lastError;
|
|
81
|
+
for (const options of attempts) {
|
|
82
|
+
try {
|
|
83
|
+
return createPrivateKey(options);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
lastError = error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw configurationError(
|
|
89
|
+
'Invalid OPC UA client private key or key password',
|
|
90
|
+
'OPCUA_PRIVATE_KEY_INVALID',
|
|
91
|
+
lastError
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function certificateSummary(certificate, uri) {
|
|
96
|
+
const publicKey = certificate.publicKey;
|
|
97
|
+
return {
|
|
98
|
+
subject: certificate.subject,
|
|
99
|
+
issuer: certificate.issuer,
|
|
100
|
+
validFrom: certificate.validFrom,
|
|
101
|
+
validTo: certificate.validTo,
|
|
102
|
+
fingerprint256: certificate.fingerprint256,
|
|
103
|
+
applicationUri: uri || null,
|
|
104
|
+
publicKeyType: publicKey?.asymmetricKeyType || null,
|
|
105
|
+
modulusLength: publicKey?.asymmetricKeyDetails?.modulusLength || null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
16
109
|
function normalizePathList(value) {
|
|
17
110
|
const source = Array.isArray(value)
|
|
18
111
|
? value
|
|
@@ -72,6 +165,92 @@ function normalizeConnectionOptions(options = {}) {
|
|
|
72
165
|
return normalized;
|
|
73
166
|
}
|
|
74
167
|
|
|
168
|
+
function preflightConnectionSecurity(options = {}, dependencies = {}) {
|
|
169
|
+
const normalized = normalizeConnectionOptions(options);
|
|
170
|
+
const runtime = {
|
|
171
|
+
readFileSync: dependencies.readFileSync || fs.readFileSync,
|
|
172
|
+
X509Certificate: dependencies.X509Certificate || crypto.X509Certificate,
|
|
173
|
+
createPrivateKey: dependencies.createPrivateKey || crypto.createPrivateKey,
|
|
174
|
+
now: dependencies.now == null ? Date.now() : Number(dependencies.now),
|
|
175
|
+
};
|
|
176
|
+
if (normalized.securityMode === 'None') {
|
|
177
|
+
return {
|
|
178
|
+
...normalized,
|
|
179
|
+
securityPreflight: { enabled: false, policy: normalized.securityPolicy },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const certificate = parseCertificate(
|
|
184
|
+
readSecurityFile(normalized.certificate, 'client certificate', runtime.readFileSync),
|
|
185
|
+
'client certificate',
|
|
186
|
+
runtime.X509Certificate
|
|
187
|
+
);
|
|
188
|
+
validateCertificateTime(certificate, 'client certificate', runtime.now);
|
|
189
|
+
|
|
190
|
+
const uris = applicationUris(certificate);
|
|
191
|
+
const applicationUri = normalized.applicationUri || (uris.length === 1 ? uris[0] : '');
|
|
192
|
+
if (!applicationUri) {
|
|
193
|
+
throw configurationError(
|
|
194
|
+
'The OPC UA client certificate must contain one Application URI or Application URI must be configured',
|
|
195
|
+
'OPCUA_CERTIFICATE_URI_MISSING'
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (!uris.includes(applicationUri)) {
|
|
199
|
+
throw configurationError(
|
|
200
|
+
`Configured Application URI does not match the client certificate: ${applicationUri}`,
|
|
201
|
+
'OPCUA_CERTIFICATE_URI_MISMATCH'
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const privateKey = loadPrivateKey(
|
|
206
|
+
readSecurityFile(normalized.privateKey, 'client private key', runtime.readFileSync),
|
|
207
|
+
normalized.privateKeyPassword,
|
|
208
|
+
runtime.createPrivateKey
|
|
209
|
+
);
|
|
210
|
+
if (!certificate.checkPrivateKey(privateKey)) {
|
|
211
|
+
throw configurationError(
|
|
212
|
+
'The OPC UA client private key does not match the client certificate',
|
|
213
|
+
'OPCUA_PRIVATE_KEY_MISMATCH'
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const keyType = certificate.publicKey?.asymmetricKeyType || '';
|
|
218
|
+
const modulusLength = Number(certificate.publicKey?.asymmetricKeyDetails?.modulusLength) || 0;
|
|
219
|
+
if (!keyType.startsWith('rsa')) {
|
|
220
|
+
throw configurationError(
|
|
221
|
+
`OPC UA ${normalized.securityPolicy} requires an RSA client certificate`,
|
|
222
|
+
'OPCUA_CERTIFICATE_KEY_TYPE_UNSUPPORTED'
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (modulusLength > 0 && modulusLength < 2048) {
|
|
226
|
+
throw configurationError(
|
|
227
|
+
`OPC UA client certificate RSA key is too short: ${modulusLength} bits; at least 2048 bits required`,
|
|
228
|
+
'OPCUA_CERTIFICATE_KEY_TOO_SHORT'
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const trust = normalized.trustedCertificates.map((filePath) => {
|
|
233
|
+
const trusted = parseCertificate(
|
|
234
|
+
readSecurityFile(filePath, 'trusted certificate', runtime.readFileSync),
|
|
235
|
+
`trusted certificate: ${filePath}`,
|
|
236
|
+
runtime.X509Certificate
|
|
237
|
+
);
|
|
238
|
+
validateCertificateTime(trusted, `trusted certificate: ${filePath}`, runtime.now);
|
|
239
|
+
return certificateSummary(trusted);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
...normalized,
|
|
244
|
+
applicationUri,
|
|
245
|
+
securityPreflight: {
|
|
246
|
+
enabled: true,
|
|
247
|
+
policy: normalized.securityPolicy,
|
|
248
|
+
clientCertificate: certificateSummary(certificate, applicationUri),
|
|
249
|
+
trustedCertificates: trust,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
75
254
|
function endpointIdentityTypes(endpoint) {
|
|
76
255
|
return [...new Set((endpoint?.userIdentityTokens || []).map((token) => token.tokenType))];
|
|
77
256
|
}
|
|
@@ -92,5 +271,6 @@ module.exports = {
|
|
|
92
271
|
matchingEndpoints,
|
|
93
272
|
normalizeConnectionOptions,
|
|
94
273
|
normalizePathList,
|
|
274
|
+
preflightConnectionSecurity,
|
|
95
275
|
securityPolicyName,
|
|
96
276
|
};
|
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);
|