@tier0/opcua-client 0.5.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -6
- package/lib/client.js +38 -9
- package/lib/compatibility.js +28 -0
- package/lib/diagnostics.js +123 -0
- package/lib/security.js +182 -2
- package/nodes/browse.html +3 -1
- package/nodes/browse.js +13 -1
- package/nodes/connection.html +19 -9
- package/nodes/connection.js +2 -0
- package/nodes/read.html +3 -1
- package/nodes/read.js +12 -2
- package/nodes/subscribe.html +7 -1
- package/nodes/subscribe.js +80 -21
- package/nodes/write.html +3 -1
- package/nodes/write.js +12 -2
- package/package.json +1 -1
- package/prebuilds/linux-x64-musl/opcua_open62541.node +0 -0
- package/src/addon.cc +8 -0
package/README.md
CHANGED
|
@@ -178,19 +178,34 @@ finishes.
|
|
|
178
178
|
|
|
179
179
|
Node editors show only the standard settings by default. Connection security/timeouts/cache, Browse limits/paging, Read/Write batching/output, and Subscribe queue/deadband tuning remain available under a closed **Advanced settings** section. Existing flows retain their stored advanced values.
|
|
180
180
|
|
|
181
|
+
## Server compatibility profiles
|
|
182
|
+
|
|
183
|
+
Connection nodes default to the standard OPC UA profile. Select **Siemens
|
|
184
|
+
WinCC** for classic WinCC or Unified servers that do not advertise usable
|
|
185
|
+
OperationLimits: Read, Write, and MonitoredItem service calls then use a
|
|
186
|
+
conservative fallback of 200 items per request, while advertised non-zero
|
|
187
|
+
server limits still take precedence. Subscribe nodes use at most 1000
|
|
188
|
+
MonitoredItems per Subscription and remove Deadband filters because WinCC does
|
|
189
|
+
not support `ModifyMonitoredItems`/Deadband behavior consistently. The selected
|
|
190
|
+
profile and effective subscription behavior are included in status diagnostics.
|
|
191
|
+
|
|
181
192
|
## Secure connections
|
|
182
193
|
|
|
183
194
|
Connection nodes support `None/None` and `Sign` or `SignAndEncrypt` with
|
|
184
|
-
`Basic256Sha256
|
|
185
|
-
|
|
195
|
+
`Basic256Sha256`, `Aes128_Sha256_RsaOaep`, or `Aes256_Sha256_RsaPss`. Secure
|
|
196
|
+
connections require a client certificate, its private key, and at least one
|
|
197
|
+
trusted Server or CA certificate. Trust is strict: an
|
|
186
198
|
empty trust list is rejected instead of silently accepting every Server
|
|
187
199
|
certificate. Optional CRL files can be supplied as a newline-separated list.
|
|
188
200
|
Password-protected PEM private keys use the separate Node-RED credential field,
|
|
189
201
|
so the password is not stored in the normal Flow configuration.
|
|
190
202
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
203
|
+
Before opening the native client, the package checks certificate validity,
|
|
204
|
+
certificate/private-key matching, RSA key type and minimum 2048-bit strength,
|
|
205
|
+
trusted-certificate validity, and Application URI consistency. When the client
|
|
206
|
+
certificate contains exactly one URI, leaving Application URI empty selects it
|
|
207
|
+
automatically. Username authentication uses the selected secure endpoint and
|
|
208
|
+
its advertised UserTokenPolicy.
|
|
194
209
|
|
|
195
210
|
The Connection editor can call `GetEndpoints`, filters the response to the
|
|
196
211
|
implemented policies, and selects an endpoint compatible with Anonymous or
|
|
@@ -212,7 +227,7 @@ Node-RED Alpine image, the pinned open62541 v1.4.16 source archive, and its
|
|
|
212
227
|
not contact npm or GitHub and does not invoke a compiler:
|
|
213
228
|
|
|
214
229
|
```bash
|
|
215
|
-
npm install ./tier0-opcua-client-0.
|
|
230
|
+
npm install ./tier0-opcua-client-0.6.0.tgz --offline
|
|
216
231
|
```
|
|
217
232
|
|
|
218
233
|
The loader uses the bundled native addon directly. Set
|
|
@@ -332,3 +347,56 @@ See [BENCHMARK.md](BENCHMARK.md) for the recorded 1,000, 5,000, 10,000, and sust
|
|
|
332
347
|
- Native command queue: 256 pending commands per connection.
|
|
333
348
|
|
|
334
349
|
Configured service batches are automatically clamped to the server's advertised OperationLimits.
|
|
350
|
+
|
|
351
|
+
## Status and error output (0.6.1)
|
|
352
|
+
|
|
353
|
+
Browse, Read, Write and Subscribe now have two outputs. Output 1 retains data;
|
|
354
|
+
output 2 emits structured diagnostic messages. Connect output 2 to Debug, a
|
|
355
|
+
JSON/file node or your logging service. The shared Connection configuration
|
|
356
|
+
has no output ports: its state is reported by each referencing operation node,
|
|
357
|
+
identified by nodeId and connectionId.
|
|
358
|
+
|
|
359
|
+
诊断输出可独立接 Debug、JSON + File 或日志服务。payload.level 为 info / warn / error;
|
|
360
|
+
连接断开、订阅无响应、恢复失败、点位 Bad 状态和队列丢数据都会输出对应事件。
|
|
361
|
+
恢复成功也会输出 info,但连接恢复或订阅重建不代表已经收到新数据。
|
|
362
|
+
|
|
363
|
+
Messages contain payload.level, event, message, time (ISO 8601), nodeId,
|
|
364
|
+
connectionId, operation, retrying and optional statusCode/counts. Subscription
|
|
365
|
+
recovery includes rebuilt and dataGapPossible. The envelope contains
|
|
366
|
+
opcua.operation = "diagnostic". No input values, endpoint URLs, credentials,
|
|
367
|
+
certificates or raw SDK error text are copied to this output. Existing runtime
|
|
368
|
+
logs and Catch handling remain unchanged; output 2 is additional reporting.
|
|
369
|
+
|
|
370
|
+
Identical event/status-code pairs are emitted at most once per 30 seconds per
|
|
371
|
+
node. The next emitted matching event includes suppressed repeats; there is
|
|
372
|
+
no delayed timer flush. Connection restoration resets this suppression window.
|
|
373
|
+
This limit applies only to diagnostic output, not the existing runtime logs.
|
|
374
|
+
|
|
375
|
+
For Subscribe, **Status messages → Both outputs (legacy compatible)** is the
|
|
376
|
+
default: existing subscription-status messages remain on output 1 and the new
|
|
377
|
+
structured events appear on output 2. Choose **Output 2 only** to keep output 1
|
|
378
|
+
strictly for data. Existing Flow JSON without statusOutput keeps its old behavior;
|
|
379
|
+
all existing wires on output 1 remain unchanged. New diagnostic events are not
|
|
380
|
+
sent to output 1. Structured events contain summaries; existing detailed
|
|
381
|
+
subscription-status payloads are preserved only in compatibility mode.
|
|
382
|
+
|
|
383
|
+
This change adds reporting; it does not add a data-silence watchdog or change
|
|
384
|
+
OPC UA reconnection/monitoring behavior.
|
|
385
|
+
|
|
386
|
+
## Point failure details (0.6.2)
|
|
387
|
+
|
|
388
|
+
Diagnostic output now includes payload.failures: an array of nodeId (OPC UA
|
|
389
|
+
point identifier), optional alias, statusCode and a short explanation. The
|
|
390
|
+
outer payload.nodeId still identifies the Node-RED flow node. Read/Write
|
|
391
|
+
partial failures display yellow status with success/total and failure counts.
|
|
392
|
+
The detail list is available even when data output is set to Value only.
|
|
393
|
+
|
|
394
|
+
Each event includes at most 100 failed points; failed reports the full failure
|
|
395
|
+
count, failuresTruncated indicates truncation, and omittedFailures reports the
|
|
396
|
+
remaining count. Use Full metadata on output 1 for complete Read/Write results.
|
|
397
|
+
Subscription compatibility output also retains its existing bounded failures.
|
|
398
|
+
Duplicate event suppression compares the failing point identities and status
|
|
399
|
+
codes so a different batch of failing points is not hidden by the first batch.
|
|
400
|
+
No point values or raw input messages are copied. A whole-service exception
|
|
401
|
+
without per-point results is still an operation failure: it cannot identify
|
|
402
|
+
which individual points failed.
|
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';
|
|
@@ -192,7 +199,8 @@ class OpcUaClient extends EventEmitter {
|
|
|
192
199
|
const results = [];
|
|
193
200
|
await this.connect();
|
|
194
201
|
const effective = effectiveBatchSize(
|
|
195
|
-
batchSize, this.diagnostics.operationLimits?.maxNodesPerRead
|
|
202
|
+
batchSize, this.diagnostics.operationLimits?.maxNodesPerRead,
|
|
203
|
+
this.#compatibilityBatchFallback()
|
|
196
204
|
);
|
|
197
205
|
for (const batch of chunks(points, effective)) {
|
|
198
206
|
results.push(...await this.#invoke('readManyAsync', batch));
|
|
@@ -204,7 +212,8 @@ class OpcUaClient extends EventEmitter {
|
|
|
204
212
|
const results = [];
|
|
205
213
|
await this.connect();
|
|
206
214
|
const effective = effectiveBatchSize(
|
|
207
|
-
batchSize, this.diagnostics.operationLimits?.maxNodesPerWrite
|
|
215
|
+
batchSize, this.diagnostics.operationLimits?.maxNodesPerWrite,
|
|
216
|
+
this.#compatibilityBatchFallback()
|
|
208
217
|
);
|
|
209
218
|
for (const batch of chunks(points, effective)) {
|
|
210
219
|
results.push(...await this.#invoke('writeManyAsync', batch));
|
|
@@ -222,7 +231,8 @@ class OpcUaClient extends EventEmitter {
|
|
|
222
231
|
const results = [];
|
|
223
232
|
await this.connect();
|
|
224
233
|
const effective = effectiveBatchSize(
|
|
225
|
-
batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall
|
|
234
|
+
batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall,
|
|
235
|
+
this.#compatibilityBatchFallback()
|
|
226
236
|
);
|
|
227
237
|
for (const batch of chunks(points, effective)) {
|
|
228
238
|
results.push(...await this.#invoke(
|
|
@@ -236,7 +246,8 @@ class OpcUaClient extends EventEmitter {
|
|
|
236
246
|
const results = [];
|
|
237
247
|
await this.connect();
|
|
238
248
|
const effective = effectiveBatchSize(
|
|
239
|
-
batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall
|
|
249
|
+
batchSize, this.diagnostics.operationLimits?.maxMonitoredItemsPerCall,
|
|
250
|
+
this.#compatibilityBatchFallback()
|
|
240
251
|
);
|
|
241
252
|
for (const batch of chunks(monitoredItemIds, effective)) {
|
|
242
253
|
results.push(...await this.#invoke('removeMonitoredItemsAsync', subscriptionId, batch));
|
|
@@ -261,6 +272,22 @@ class OpcUaClient extends EventEmitter {
|
|
|
261
272
|
return this.diagnostics;
|
|
262
273
|
}
|
|
263
274
|
|
|
275
|
+
#compatibilityBatchFallback() {
|
|
276
|
+
return isSiemensWinCC(this.options.compatibilityProfile)
|
|
277
|
+
? SIEMENS_WINCC_DEFAULTS.requestBatchSize
|
|
278
|
+
: 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#compatibilityDiagnostics() {
|
|
282
|
+
const siemensWincc = isSiemensWinCC(this.options.compatibilityProfile);
|
|
283
|
+
return {
|
|
284
|
+
profile: siemensWincc ? 'siemens-wincc' : 'standard',
|
|
285
|
+
requestBatchFallback: siemensWincc
|
|
286
|
+
? SIEMENS_WINCC_DEFAULTS.requestBatchSize
|
|
287
|
+
: null,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
264
291
|
#refreshDiagnostics() {
|
|
265
292
|
if (this.handle == null) return;
|
|
266
293
|
const addon = loadAddon();
|
|
@@ -270,6 +297,8 @@ class OpcUaClient extends EventEmitter {
|
|
|
270
297
|
this.diagnostics = {
|
|
271
298
|
...nativeDiagnostics,
|
|
272
299
|
browse: this.diagnostics.browse,
|
|
300
|
+
securityPreflight: this.diagnostics.securityPreflight,
|
|
301
|
+
compatibility: this.#compatibilityDiagnostics(),
|
|
273
302
|
connection: {
|
|
274
303
|
...(nativeDiagnostics.connection || {}),
|
|
275
304
|
supervisor: { ...this.supervisorDiagnostics },
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const COMPATIBILITY_PROFILES = Object.freeze({
|
|
4
|
+
Standard: 'standard',
|
|
5
|
+
SiemensWinCC: 'siemens-wincc',
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const SIEMENS_WINCC_DEFAULTS = Object.freeze({
|
|
9
|
+
requestBatchSize: 200,
|
|
10
|
+
monitoredItemsPerSubscription: 1000,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function normalizeCompatibilityProfile(value) {
|
|
14
|
+
return value === COMPATIBILITY_PROFILES.SiemensWinCC
|
|
15
|
+
? COMPATIBILITY_PROFILES.SiemensWinCC
|
|
16
|
+
: COMPATIBILITY_PROFILES.Standard;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isSiemensWinCC(value) {
|
|
20
|
+
return normalizeCompatibilityProfile(value) === COMPATIBILITY_PROFILES.SiemensWinCC;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = {
|
|
24
|
+
COMPATIBILITY_PROFILES,
|
|
25
|
+
SIEMENS_WINCC_DEFAULTS,
|
|
26
|
+
isSiemensWinCC,
|
|
27
|
+
normalizeCompatibilityProfile,
|
|
28
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createHash } = require('node:crypto');
|
|
4
|
+
const FAILURE_LIMIT = 100;
|
|
5
|
+
const POINT_MESSAGES = {
|
|
6
|
+
BadNotReadable: '访问级别不允许读取或订阅该点位',
|
|
7
|
+
BadUserAccessDenied: '当前用户无权执行该操作',
|
|
8
|
+
BadNodeIdUnknown: '服务端找不到该 NodeId',
|
|
9
|
+
BadNodeIdInvalid: 'NodeId 格式无效',
|
|
10
|
+
BadNoCommunication: '服务端无法与该点位的数据源通信',
|
|
11
|
+
BadWaitingForInitialData: '等待该点位的首次数据',
|
|
12
|
+
BadTimeout: '该点位操作超时',
|
|
13
|
+
BadNotWritable: '该点位不允许写入',
|
|
14
|
+
BadTypeMismatch: '写入值类型与点位类型不匹配',
|
|
15
|
+
BadTooManyMonitoredItems: '服务端监控点数量超限',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Output only allowlisted metadata: never forward SDK messages, credentials,
|
|
19
|
+
// endpoint URLs, input payloads or raw state/diagnostic objects.
|
|
20
|
+
function statusCode(error) {
|
|
21
|
+
const text = String(error?.code || error?.statusCode || error?.message || error || '');
|
|
22
|
+
return /\b(?:Bad[A-Za-z0-9]+|Uncertain[A-Za-z0-9]*|OPCUA_[A-Z_]+)\b/.exec(text)?.[0] || 'OperationFailed';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createDiagnostics(node, config, operation) {
|
|
26
|
+
const recent = new Map();
|
|
27
|
+
let closed = false;
|
|
28
|
+
let interrupted = false;
|
|
29
|
+
const client = node.connection?.client;
|
|
30
|
+
function emit(level, event, message, details = {}, send) {
|
|
31
|
+
if (closed) return;
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const failures = details.failures;
|
|
34
|
+
// Different failing points must not be hidden by another batch's throttle.
|
|
35
|
+
const fingerprint = createHash('sha256');
|
|
36
|
+
if (failures) for (const item of failures) {
|
|
37
|
+
fingerprint.update(JSON.stringify([item.nodeId, item.alias, item.statusCode]));
|
|
38
|
+
}
|
|
39
|
+
const key = `${event}:${details.statusCode || ''}:${failures ? fingerprint.digest('hex') : ''}`;
|
|
40
|
+
const previous = recent.get(key);
|
|
41
|
+
if (previous && now - previous.time < 30000) {
|
|
42
|
+
previous.suppressed += 1;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const payload = {
|
|
46
|
+
level, event, message, time: new Date(now).toISOString(),
|
|
47
|
+
nodeId: node.id || config.id, connectionId: config.connection, operation,
|
|
48
|
+
retrying: details.retrying === true,
|
|
49
|
+
};
|
|
50
|
+
if (details.statusCode) payload.statusCode = statusCode(details.statusCode);
|
|
51
|
+
if (failures) {
|
|
52
|
+
payload.failures = failures.slice(0, FAILURE_LIMIT).map((item) => {
|
|
53
|
+
const code = statusCode(item.statusCode);
|
|
54
|
+
return {
|
|
55
|
+
...(typeof item.nodeId === 'string' ? { nodeId: item.nodeId } : {}),
|
|
56
|
+
...(typeof item.alias === 'string' && item.alias ? { alias: item.alias } : {}),
|
|
57
|
+
statusCode: code,
|
|
58
|
+
message: POINT_MESSAGES[code] || '点位返回非 Good 状态,请根据 statusCode 排查',
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
payload.failuresTruncated = failures.length > FAILURE_LIMIT;
|
|
62
|
+
payload.omittedFailures = Math.max(0, failures.length - FAILURE_LIMIT);
|
|
63
|
+
}
|
|
64
|
+
for (const field of ['failed', 'total', 'monitored', 'desired', 'dropped', 'subscriptionId']) {
|
|
65
|
+
if (Number.isFinite(details[field])) payload[field] = details[field];
|
|
66
|
+
}
|
|
67
|
+
for (const field of ['rebuilt', 'dataGapPossible']) {
|
|
68
|
+
if (typeof details[field] === 'boolean') payload[field] = details[field];
|
|
69
|
+
}
|
|
70
|
+
if (previous?.suppressed) payload.suppressed = previous.suppressed;
|
|
71
|
+
if (recent.size >= 128) recent.delete(recent.keys().next().value);
|
|
72
|
+
recent.set(key, { time: now, suppressed: 0 });
|
|
73
|
+
const output = send || (node.send && node.send.bind(node));
|
|
74
|
+
output?.([null, { payload, opcua: { operation: 'diagnostic' } }]);
|
|
75
|
+
}
|
|
76
|
+
function failure(error, send, event = `${operation}_failed`, retrying = false) {
|
|
77
|
+
emit('error', event, `OPC UA ${operation} failed`, { statusCode: statusCode(error), retrying }, send);
|
|
78
|
+
}
|
|
79
|
+
function results(items, send, descriptors) {
|
|
80
|
+
const failed = items.filter((item) => item.statusCode && item.statusCode !== 'Good');
|
|
81
|
+
const failures = failed.map((item) => ({
|
|
82
|
+
nodeId: item.nodeId,
|
|
83
|
+
alias: item.alias ?? descriptors?.get(item.nodeId)?.alias,
|
|
84
|
+
statusCode: item.statusCode,
|
|
85
|
+
}));
|
|
86
|
+
if (failed.length) emit('warn', 'points_failed', 'Some OPC UA points returned a non-Good status', {
|
|
87
|
+
failed: failed.length, total: items.length, statusCode: statusCode(failed[0].statusCode),
|
|
88
|
+
failures,
|
|
89
|
+
}, send);
|
|
90
|
+
return failed.length;
|
|
91
|
+
}
|
|
92
|
+
const onFault = (error) => {
|
|
93
|
+
interrupted = true;
|
|
94
|
+
emit('warn', 'connection_fault', 'OPC UA connection fault', { statusCode: statusCode(error), retrying: true });
|
|
95
|
+
};
|
|
96
|
+
const onState = (state) => {
|
|
97
|
+
if (!state) return;
|
|
98
|
+
if (state.state === 'connected') {
|
|
99
|
+
if (interrupted) {
|
|
100
|
+
recent.clear();
|
|
101
|
+
emit('info', 'connection_restored', 'OPC UA connection restored; subscription recovery may still be pending');
|
|
102
|
+
}
|
|
103
|
+
interrupted = false;
|
|
104
|
+
} else if (['reconnecting', 'backoff', 'failed'].includes(state.state)) {
|
|
105
|
+
interrupted = true;
|
|
106
|
+
emit(state.state === 'failed' ? 'error' : 'warn', `connection_${state.state}`,
|
|
107
|
+
'OPC UA connection unavailable; recovery pending', {
|
|
108
|
+
retrying: true, ...(state.statusCode && state.statusCode !== 'Good' ? { statusCode: statusCode(state.statusCode) } : {}),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
client?.on?.('fault', onFault);
|
|
113
|
+
client?.on?.('state', onState);
|
|
114
|
+
node.on('close', () => {
|
|
115
|
+
closed = true;
|
|
116
|
+
recent.clear();
|
|
117
|
+
client?.removeListener?.('fault', onFault);
|
|
118
|
+
client?.removeListener?.('state', onState);
|
|
119
|
+
});
|
|
120
|
+
return { emit, failure, results };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { createDiagnostics, statusCode };
|
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
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
</script>
|
|
47
47
|
<script type="text/html" data-help-name="tier0-opcua-browse">
|
|
48
48
|
<section lang="zh-CN">
|
|
49
|
+
<p>输出 2 为独立状态/异常消息:payload.level 为 info、warn 或 error。可连接 Debug、文件或日志服务;原有 Catch 和日志仍然有效。同类事件每 30 秒最多输出一次,下一次包含 suppressed 计数。输出不含原始值、凭据或完整错误文本。</p>
|
|
49
50
|
<h3>浏览(Browse)</h3>
|
|
50
51
|
<p>浏览 OPC UA 地址空间并选择变量点位。Browse 返回节点描述信息,不读取实时值。</p>
|
|
51
52
|
<h4>配置与输入</h4>
|
|
@@ -60,6 +61,7 @@
|
|
|
60
61
|
</section>
|
|
61
62
|
<hr>
|
|
62
63
|
<section lang="en">
|
|
64
|
+
<p>Output 2 emits status/error events with payload.level (info, warn, error). Connect it to Debug, file or logging nodes. Catch and existing logs remain supported. Repeats are limited to one per 30 seconds; the next matching event includes suppressed counts. Raw values, credentials and SDK error text are omitted.</p>
|
|
63
65
|
<h3>Browse</h3>
|
|
64
66
|
<p>Browses the OPC UA address space and selects Variable nodes. Browse returns node descriptors; it does not read live values.</p>
|
|
65
67
|
<h4>Configuration and input</h4>
|
|
@@ -75,7 +77,7 @@
|
|
|
75
77
|
</script>
|
|
76
78
|
<script type="text/javascript">
|
|
77
79
|
RED.nodes.registerType('tier0-opcua-browse', {
|
|
78
|
-
category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs:
|
|
80
|
+
category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 2, outputLabels: ['Data', 'Status / errors'], icon: 'search.png',
|
|
79
81
|
defaults: {
|
|
80
82
|
connection: { value: '', type: 'tier0-opcua-connection', required: true }, rootNodeId: { value: 'ns=0;i=85' },
|
|
81
83
|
selectedNodes: { value: [] }, recursive: { value: true }, variablesOnly: { value: true },
|
package/nodes/browse.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { createDiagnostics } = require('../lib/diagnostics');
|
|
4
|
+
|
|
3
5
|
const { chunks } = require('../lib/points');
|
|
4
6
|
const { buildBrowseTree, selectedVariables } = require('../lib/browse-tree');
|
|
5
7
|
|
|
@@ -65,12 +67,17 @@ module.exports = function registerBrowseNode(RED) {
|
|
|
65
67
|
RED.nodes.createNode(this, config);
|
|
66
68
|
const node = this;
|
|
67
69
|
node.connection = RED.nodes.getNode(config.connection);
|
|
70
|
+
const diagnostics = createDiagnostics(node, config, 'browse');
|
|
68
71
|
const consumer = node.connection?.registerConsumer?.('browse', {
|
|
69
72
|
nodeId: config.id, name: config.name,
|
|
70
73
|
});
|
|
71
74
|
|
|
72
75
|
node.on('input', async (msg, send, done) => {
|
|
73
|
-
if (!node.connection)
|
|
76
|
+
if (!node.connection) {
|
|
77
|
+
const error = new Error('OPCUA_CONNECTION_NOT_CONFIGURED');
|
|
78
|
+
diagnostics.failure(error, send);
|
|
79
|
+
return done(error);
|
|
80
|
+
}
|
|
74
81
|
|
|
75
82
|
try {
|
|
76
83
|
consumer?.markUsed('browse');
|
|
@@ -124,6 +131,10 @@ module.exports = function registerBrowseNode(RED) {
|
|
|
124
131
|
}
|
|
125
132
|
|
|
126
133
|
const partial = browseDiagnostics?.truncated || browseDiagnostics?.failures?.length;
|
|
134
|
+
if (partial) diagnostics.emit('warn', 'browse_partial', 'OPC UA Browse returned partial results', {
|
|
135
|
+
failed: browseDiagnostics?.failures?.length || 0, total: points.length,
|
|
136
|
+
failures: browseDiagnostics?.failures || [],
|
|
137
|
+
}, send);
|
|
127
138
|
node.status({
|
|
128
139
|
fill: partial ? 'yellow' : 'green', shape: partial ? 'ring' : 'dot',
|
|
129
140
|
text: `${points.length} points` +
|
|
@@ -133,6 +144,7 @@ module.exports = function registerBrowseNode(RED) {
|
|
|
133
144
|
done();
|
|
134
145
|
} catch (error) {
|
|
135
146
|
node.status({ fill: 'red', shape: 'ring', text: error.message });
|
|
147
|
+
diagnostics.failure(error, send);
|
|
136
148
|
done(error);
|
|
137
149
|
}
|
|
138
150
|
});
|
package/nodes/connection.html
CHANGED
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
<div class="form-row"><label></label><button type="button" class="red-ui-button" id="node-config-input-discoverEndpoints">Discover endpoints</button> <button type="button" class="red-ui-button" id="node-config-input-testConnection">Test connection</button></div>
|
|
16
16
|
<div class="opcua-discovery-result" id="node-config-input-discoveryResult" style="display:none"><select id="node-config-input-discoveredEndpoint"></select></div>
|
|
17
17
|
<div class="opcua-standard-note" id="node-config-input-testResult"></div>
|
|
18
|
+
<div class="form-row"><label for="node-config-input-compatibilityProfile">Server profile</label><select id="node-config-input-compatibilityProfile"><option value="standard">Standard OPC UA</option><option value="siemens-wincc">Siemens WinCC</option></select></div>
|
|
19
|
+
<div class="opcua-standard-note">Siemens WinCC uses conservative 200-item service batches and disables unsupported Subscription Deadband filters when the Server does not advertise usable limits.</div>
|
|
18
20
|
<div class="form-row"><label for="node-config-input-securityMode">Security</label><select id="node-config-input-securityMode"><option value="None">None</option><option value="Sign">Sign</option><option value="SignAndEncrypt">SignAndEncrypt</option></select></div>
|
|
19
21
|
<div class="opcua-standard-note">Standard defaults include automatic reconnect, connectivity checks, and bounded command/NodeId queues.</div>
|
|
20
22
|
<details class="opcua-advanced">
|
|
21
23
|
<summary>Security and advanced connection settings</summary>
|
|
22
|
-
<div class="form-row"><label for="node-config-input-securityPolicy">Policy</label><select id="node-config-input-securityPolicy"><option value="None">None</option><option value="Basic256Sha256">Basic256Sha256</option></select></div>
|
|
24
|
+
<div class="form-row"><label for="node-config-input-securityPolicy">Policy</label><select id="node-config-input-securityPolicy"><option value="None">None</option><option value="Basic256Sha256">Basic256Sha256</option><option value="Aes128_Sha256_RsaOaep">Aes128_Sha256_RsaOaep</option><option value="Aes256_Sha256_RsaPss">Aes256_Sha256_RsaPss</option></select></div>
|
|
23
25
|
<div class="form-row"><label for="node-config-input-user">User</label><input type="text" id="node-config-input-user"></div>
|
|
24
26
|
<div class="form-row"><label for="node-config-input-password">Password</label><input type="password" id="node-config-input-password"></div>
|
|
25
27
|
<div class="form-row opcua-secure-field"><label for="node-config-input-certificate">Client certificate</label><input type="text" class="opcua-security-path" id="node-config-input-certificate" placeholder="Upload or enter /path/to/client.der"> <button type="button" class="red-ui-button" id="node-config-upload-certificate"><i class="fa fa-upload"></i> Upload</button><input type="file" class="opcua-hidden-file" id="node-config-file-certificate" accept=".der,.pem,.crt,.cer"></div>
|
|
@@ -50,8 +52,9 @@
|
|
|
50
52
|
<li><b>Endpoint</b>:服务器地址,例如 <code>opc.tcp://192.168.1.10:4840</code>。</li>
|
|
51
53
|
<li><b>Discover endpoints</b>:读取服务器公开的安全模式、安全策略和用户认证方式。</li>
|
|
52
54
|
<li><b>Test connection</b>:使用当前配置建立临时连接并验证认证。</li>
|
|
53
|
-
<li><b>
|
|
54
|
-
<li><b
|
|
55
|
+
<li><b>Server profile</b>:默认使用标准 OPC UA 行为;Siemens WinCC 模式在服务器未公布有效 OperationLimits 时按每批 200 点请求,并在订阅中禁用 WinCC 不支持的 Deadband。</li>
|
|
56
|
+
<li><b>Security</b>:支持 None、Sign、SignAndEncrypt,以及 Basic256Sha256、Aes128_Sha256_RsaOaep、Aes256_Sha256_RsaPss;安全模式、策略和认证方式必须与服务器 Endpoint 匹配。</li>
|
|
57
|
+
<li><b>证书</b>:安全连接可上传或指定客户端证书、私钥、信任证书和 CRL。连接前会检查有效期、证书和私钥是否匹配、RSA 密钥强度及 Application URI。若证书只包含一个 URI,可留空并自动采用。</li>
|
|
55
58
|
</ul>
|
|
56
59
|
<h4>建议</h4>
|
|
57
60
|
<p>一个 Connection 配置对应一条客户端 Session。多个业务节点应复用同一个 Connection,避免为相同 Endpoint 重复创建 Session。节点内置自动重连、连通性检查以及有界命令队列。</p>
|
|
@@ -65,8 +68,9 @@
|
|
|
65
68
|
<li><b>Endpoint</b>: Server URL, for example <code>opc.tcp://192.168.1.10:4840</code>.</li>
|
|
66
69
|
<li><b>Discover endpoints</b>: Reads advertised security modes, policies, and user token types.</li>
|
|
67
70
|
<li><b>Test connection</b>: Opens a temporary connection with the current settings and validates authentication.</li>
|
|
68
|
-
<li><b>
|
|
69
|
-
<li><b>
|
|
71
|
+
<li><b>Server profile</b>: Standard uses normal OPC UA behavior. Siemens WinCC falls back to 200-item service batches when usable OperationLimits are not advertised and removes unsupported Subscription Deadband filters.</li>
|
|
72
|
+
<li><b>Security</b>: Supports None, Sign, and SignAndEncrypt with Basic256Sha256, Aes128_Sha256_RsaOaep, or Aes256_Sha256_RsaPss. Mode, policy, and identity must match an advertised endpoint.</li>
|
|
73
|
+
<li><b>Certificates</b>: Secure connections can upload or reference a client certificate, private key, trust list, and CRLs. Validity, key matching, RSA strength, and Application URI are checked before connecting. An empty URI is filled automatically when the certificate contains exactly one URI.</li>
|
|
70
74
|
</ul>
|
|
71
75
|
<h4>Recommendation</h4>
|
|
72
76
|
<p>One Connection configuration owns one client Session. Reuse it across nodes targeting the same server instead of creating duplicate Sessions. Automatic reconnect, connectivity checks, and a bounded command queue are enabled.</p>
|
|
@@ -78,6 +82,7 @@
|
|
|
78
82
|
defaults: {
|
|
79
83
|
endpoint: { value: '', required: true },
|
|
80
84
|
securityMode: { value: 'None' }, securityPolicy: { value: 'None' },
|
|
85
|
+
compatibilityProfile: { value: 'standard' },
|
|
81
86
|
user: { value: '' }, certificate: { value: '' }, privateKey: { value: '' },
|
|
82
87
|
applicationUri: { value: '' }, trustedCertificates: { value: '' }, revocationList: { value: '' },
|
|
83
88
|
requestedSessionTimeout: { value: 60000, validate: RED.validators.number() },
|
|
@@ -207,6 +212,7 @@
|
|
|
207
212
|
endpoint: String($('#node-config-input-endpoint').val() || '').trim(),
|
|
208
213
|
securityMode: mode.val(),
|
|
209
214
|
securityPolicy: policy.val(),
|
|
215
|
+
compatibilityProfile: $('#node-config-input-compatibilityProfile').val(),
|
|
210
216
|
user: $('#node-config-input-user').val(),
|
|
211
217
|
password: $('#node-config-input-password').val(),
|
|
212
218
|
certificate: $('#node-config-input-certificate').val(),
|
|
@@ -245,9 +251,12 @@
|
|
|
245
251
|
const allEndpoints = Array.isArray(response.endpoints) ? response.endpoints : [];
|
|
246
252
|
const identityType = String($('#node-config-input-user').val() || '').trim()
|
|
247
253
|
? 'Username' : 'Anonymous';
|
|
254
|
+
const supportedPolicies = new Set([
|
|
255
|
+
'None', 'Basic256Sha256',
|
|
256
|
+
'Aes128_Sha256_RsaOaep', 'Aes256_Sha256_RsaPss',
|
|
257
|
+
]);
|
|
248
258
|
const supported = allEndpoints.filter(function (item) {
|
|
249
|
-
|
|
250
|
-
return itemPolicy === 'None' || itemPolicy === 'Basic256Sha256';
|
|
259
|
+
return supportedPolicies.has(policyName(item.securityPolicyUri));
|
|
251
260
|
});
|
|
252
261
|
const matchingIdentity = supported.filter(function (item) {
|
|
253
262
|
return (item.userIdentityTokens || []).some(function (token) {
|
|
@@ -271,7 +280,7 @@
|
|
|
271
280
|
.appendTo(discovered);
|
|
272
281
|
});
|
|
273
282
|
if (!endpoints.length) {
|
|
274
|
-
RED.notify('The server returned no supported
|
|
283
|
+
RED.notify('The server returned no endpoint supported by this client', 'warning');
|
|
275
284
|
result.hide();
|
|
276
285
|
return;
|
|
277
286
|
}
|
|
@@ -293,7 +302,8 @@
|
|
|
293
302
|
if (!selected) return;
|
|
294
303
|
const selectedPolicy = policyName(selected.securityPolicyUri);
|
|
295
304
|
mode.val(selected.securityMode);
|
|
296
|
-
if (
|
|
305
|
+
if (['None', 'Basic256Sha256', 'Aes128_Sha256_RsaOaep',
|
|
306
|
+
'Aes256_Sha256_RsaPss'].includes(selectedPolicy)) {
|
|
297
307
|
policy.val(selectedPolicy);
|
|
298
308
|
}
|
|
299
309
|
updateSecurityFields();
|
package/nodes/connection.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { OpcUaClient, discoverEndpoints } = require('../lib/client');
|
|
4
4
|
const { ConnectionRegistry } = require('../lib/connection-registry');
|
|
5
5
|
const { SecurityFileStore } = require('../lib/security-file-store');
|
|
6
|
+
const { normalizeCompatibilityProfile } = require('../lib/compatibility');
|
|
6
7
|
|
|
7
8
|
function pathList(value) {
|
|
8
9
|
const source = Array.isArray(value) ? value : String(value || '').split(/\r?\n|;/);
|
|
@@ -14,6 +15,7 @@ function clientOptions(config, credentials = {}) {
|
|
|
14
15
|
endpoint: config.endpoint,
|
|
15
16
|
securityMode: config.securityMode || 'None',
|
|
16
17
|
securityPolicy: config.securityPolicy || 'None',
|
|
18
|
+
compatibilityProfile: normalizeCompatibilityProfile(config.compatibilityProfile),
|
|
17
19
|
user: config.user || '',
|
|
18
20
|
password: credentials.password || '',
|
|
19
21
|
certificate: config.certificate || '',
|
package/nodes/read.html
CHANGED
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
</script>
|
|
46
46
|
<script type="text/html" data-help-name="tier0-opcua-read">
|
|
47
47
|
<section lang="zh-CN">
|
|
48
|
+
<p>输出 2 为独立状态/异常消息:payload.level 为 info、warn 或 error。可连接 Debug、文件或日志服务;原有 Catch 和日志仍然有效。同类事件每 30 秒最多输出一次,下一次包含 suppressed 计数。输出不含原始值、凭据或完整错误文本。</p>
|
|
48
49
|
<h3>读取(Read)</h3>
|
|
49
50
|
<p>每收到一条输入消息执行一次 OPC UA Read。它不是定时器;读取频率由上游 Inject、Function 或其他节点的触发频率决定。</p>
|
|
50
51
|
<h4>输入</h4>
|
|
@@ -65,6 +66,7 @@
|
|
|
65
66
|
</section>
|
|
66
67
|
<hr>
|
|
67
68
|
<section lang="en">
|
|
69
|
+
<p>Output 2 emits status/error events with payload.level (info, warn, error). Connect it to Debug, file or logging nodes. Catch and existing logs remain supported. Repeats are limited to one per 30 seconds; the next matching event includes suppressed counts. Raw values, credentials and SDK error text are omitted.</p>
|
|
68
70
|
<h3>Read</h3>
|
|
69
71
|
<p>Executes one OPC UA Read for each input message. It is not a timer; the upstream Inject, Function, or other node controls the read frequency.</p>
|
|
70
72
|
<h4>Input</h4>
|
|
@@ -86,7 +88,7 @@
|
|
|
86
88
|
</script>
|
|
87
89
|
<script type="text/javascript">
|
|
88
90
|
RED.nodes.registerType('tier0-opcua-read', {
|
|
89
|
-
category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs:
|
|
91
|
+
category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 2, outputLabels: ['Data', 'Status / errors'], icon: 'bridge.png',
|
|
90
92
|
defaults: {
|
|
91
93
|
connection: { value: '', type: 'tier0-opcua-connection', required: true },
|
|
92
94
|
nodeId: { value: '' }, pointList: { value: '' }, pointListFileName: { value: '' },
|
package/nodes/read.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { createDiagnostics } = require('../lib/diagnostics');
|
|
4
|
+
|
|
3
5
|
const { looksLikePointList, messagePoints } = require('../lib/points');
|
|
4
6
|
|
|
5
7
|
const COMPACT_RESULT_FIELDS = Object.freeze([
|
|
@@ -27,12 +29,17 @@ module.exports = function registerReadNode(RED) {
|
|
|
27
29
|
RED.nodes.createNode(this, config);
|
|
28
30
|
const node = this;
|
|
29
31
|
node.connection = RED.nodes.getNode(config.connection);
|
|
32
|
+
const diagnostics = createDiagnostics(node, config, 'read');
|
|
30
33
|
const consumer = node.connection?.registerConsumer?.('read', {
|
|
31
34
|
nodeId: config.id, name: config.name,
|
|
32
35
|
});
|
|
33
36
|
|
|
34
37
|
node.on('input', async (msg, send, done) => {
|
|
35
|
-
if (!node.connection)
|
|
38
|
+
if (!node.connection) {
|
|
39
|
+
const error = new Error('OPCUA_CONNECTION_NOT_CONFIGURED');
|
|
40
|
+
diagnostics.failure(error, send);
|
|
41
|
+
return done(error);
|
|
42
|
+
}
|
|
36
43
|
|
|
37
44
|
try {
|
|
38
45
|
consumer?.markUsed('read');
|
|
@@ -55,6 +62,7 @@ module.exports = function registerReadNode(RED) {
|
|
|
55
62
|
|
|
56
63
|
// Undefined preserves the original output for Flow JSON created before
|
|
57
64
|
// resultDetail was introduced. New editor nodes explicitly save compact.
|
|
65
|
+
const failed = diagnostics.results(results, send);
|
|
58
66
|
const resultDetail = config.resultDetail || 'full';
|
|
59
67
|
const outputResults = resultDetail === 'compact'
|
|
60
68
|
? results.map(compactResult)
|
|
@@ -96,10 +104,12 @@ module.exports = function registerReadNode(RED) {
|
|
|
96
104
|
send(msg);
|
|
97
105
|
}
|
|
98
106
|
|
|
99
|
-
node.status({ fill: 'green', shape: '
|
|
107
|
+
node.status({ fill: failed ? 'yellow' : 'green', shape: failed ? 'ring' : 'dot',
|
|
108
|
+
text: `${results.length - failed}/${results.length} read` + (failed ? ` / ${failed} failed` : '') });
|
|
100
109
|
done();
|
|
101
110
|
} catch (error) {
|
|
102
111
|
node.status({ fill: 'red', shape: 'ring', text: error.message });
|
|
112
|
+
diagnostics.failure(error, send);
|
|
103
113
|
done(error);
|
|
104
114
|
}
|
|
105
115
|
});
|
package/nodes/subscribe.html
CHANGED
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
<div class="form-row"><label for="node-input-timestampMode">Timestamps</label><select id="node-input-timestampMode"><option value="source">Source</option><option value="both">Source and server</option><option value="server">Server</option><option value="neither">None</option></select></div>
|
|
48
48
|
<div class="form-row"><label for="node-input-batchSize">Create batch size</label><input type="number" id="node-input-batchSize"></div>
|
|
49
49
|
<div class="form-row"><label for="node-input-maxItemsPerSubscription">Items / subscription</label><input type="number" id="node-input-maxItemsPerSubscription"></div>
|
|
50
|
+
<div class="form-row"><label for="node-input-statusOutput">Status messages</label><select id="node-input-statusOutput"><option value="both">Both outputs (legacy compatible)</option><option value="separate">Output 2 only</option></select></div>
|
|
50
51
|
<div class="form-row"><label for="node-input-outputMode">Change output</label><select id="node-input-outputMode"><option value="batch">Native batch</option><option value="single">One message per change</option></select></div>
|
|
51
52
|
<div class="form-row"><label for="node-input-resultDetail">Result detail</label><select id="node-input-resultDetail"><option value="compact">Compact</option><option value="value">Value only</option><option value="full">Full metadata</option></select></div>
|
|
52
53
|
<div class="form-row"><label for="node-input-callbackBatchSize">Output batch size</label><input type="number" id="node-input-callbackBatchSize"></div>
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
</script>
|
|
60
61
|
<script type="text/html" data-help-name="tier0-opcua-subscribe">
|
|
61
62
|
<section lang="zh-CN">
|
|
63
|
+
<p>输出 2 为独立状态/异常消息:payload.level 为 info、warn 或 error。可连接 Debug、文件或日志服务;原有 Catch 和日志仍然有效。同类事件每 30 秒最多输出一次,下一次包含 suppressed 计数。输出不含原始值、凭据或完整错误文本。</p>
|
|
62
64
|
<h3>订阅(Subscribe)</h3>
|
|
63
65
|
<p>创建 OPC UA Subscription 和 MonitoredItems,由服务器在值变化时推送通知;它不是客户端定时 Read。大量点位会按照服务器限制自动拆分到多个 Subscription。</p>
|
|
64
66
|
<h4>输入与命令</h4>
|
|
@@ -71,6 +73,7 @@
|
|
|
71
73
|
<li>服务器可以把请求间隔修订为它支持的值。</li>
|
|
72
74
|
</ul>
|
|
73
75
|
<p>新节点默认 Server queue size=1、Discard oldest=true、Queue mode=latest,适合实时状态采集。脉冲、报警转换或完整历史不能丢失时,应改为 all 并增大服务器队列。</p>
|
|
76
|
+
<p>Connection 选择 Siemens WinCC 后,如果服务器没有公布有效限制,每个 Subscription 最多建立 1000 个 MonitoredItem,并自动移除 WinCC 不支持的 Deadband 配置;节点状态消息会报告实际兼容策略。</p>
|
|
74
77
|
<h4>输出</h4>
|
|
75
78
|
<ul>
|
|
76
79
|
<li><b>Compact</b>:NodeId/alias、值、状态和源时间戳。</li>
|
|
@@ -81,6 +84,7 @@
|
|
|
81
84
|
</section>
|
|
82
85
|
<hr>
|
|
83
86
|
<section lang="en">
|
|
87
|
+
<p>Output 2 emits status/error events with payload.level (info, warn, error). Connect it to Debug, file or logging nodes. Catch and existing logs remain supported. Repeats are limited to one per 30 seconds; the next matching event includes suppressed counts. Raw values, credentials and SDK error text are omitted.</p>
|
|
84
88
|
<h3>Subscribe</h3>
|
|
85
89
|
<p>Creates one or more OPC UA subscriptions and MonitoredItems so the server sends data-change notifications. It is not a periodic client Read. Large point sets are automatically sharded across Subscriptions according to server limits.</p>
|
|
86
90
|
<h4>Input and commands</h4>
|
|
@@ -93,6 +97,7 @@
|
|
|
93
97
|
<li>The server may revise requested intervals to supported values.</li>
|
|
94
98
|
</ul>
|
|
95
99
|
<p>New nodes default to server queue size 1, discard-oldest, and local latest coalescing for real-time state. Select all and increase the server queue for pulses, alarm transitions, or history where every change must be retained.</p>
|
|
100
|
+
<p>With the Siemens WinCC Connection profile, each Subscription is conservatively capped at 1000 MonitoredItems when the server advertises no usable limit, and unsupported Deadband filters are removed automatically. Status output reports the active compatibility behavior.</p>
|
|
96
101
|
<h4>Output</h4>
|
|
97
102
|
<ul>
|
|
98
103
|
<li><b>Compact</b>: NodeId/alias, value, status, and source timestamp.</li>
|
|
@@ -104,8 +109,9 @@
|
|
|
104
109
|
</script>
|
|
105
110
|
<script type="text/javascript">
|
|
106
111
|
RED.nodes.registerType('tier0-opcua-subscribe', {
|
|
107
|
-
category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs:
|
|
112
|
+
category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 2, outputLabels: ['Data', 'Status / errors'], icon: 'bridge.png',
|
|
108
113
|
defaults: {
|
|
114
|
+
statusOutput: { value: 'both' },
|
|
109
115
|
connection: { value: '', type: 'tier0-opcua-connection', required: true },
|
|
110
116
|
nodeId: { value: '' }, pointList: { value: '' }, pointListFileName: { value: '' },
|
|
111
117
|
publishingInterval: { value: 1000, validate: RED.validators.number() }, samplingInterval: { value: 1000, validate: RED.validators.number() },
|
package/nodes/subscribe.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { createDiagnostics } = require('../lib/diagnostics');
|
|
4
|
+
|
|
3
5
|
const { looksLikePointList, messagePoints } = require('../lib/points');
|
|
6
|
+
const { SIEMENS_WINCC_DEFAULTS, isSiemensWinCC } = require('../lib/compatibility');
|
|
4
7
|
|
|
5
8
|
const NOTIFICATION_CONFIG_FIELDS = new Set([
|
|
6
9
|
'nodeId',
|
|
@@ -55,19 +58,19 @@ function positiveInteger(value, fallback) {
|
|
|
55
58
|
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
function monitoringSettings(point, config) {
|
|
61
|
+
function monitoringSettings(point, config, siemensWincc = false) {
|
|
59
62
|
return {
|
|
60
63
|
samplingInterval: Number(point.samplingInterval) || Number(config.samplingInterval) || 1000,
|
|
61
64
|
queueSize: positiveInteger(point.queueSize, positiveInteger(config.queueSize, 1)),
|
|
62
65
|
discardOldest: point.discardOldest !== false,
|
|
63
|
-
deadbandType: point.deadbandType || config.deadbandType || 'none',
|
|
64
|
-
deadbandValue: Number(point.deadbandValue ?? config.deadbandValue) || 0,
|
|
66
|
+
deadbandType: siemensWincc ? 'none' : point.deadbandType || config.deadbandType || 'none',
|
|
67
|
+
deadbandValue: siemensWincc ? 0 : Number(point.deadbandValue ?? config.deadbandValue) || 0,
|
|
65
68
|
};
|
|
66
69
|
}
|
|
67
70
|
|
|
68
|
-
function sameMonitoringSettings(left, right, config) {
|
|
69
|
-
const leftSettings = monitoringSettings(left, config);
|
|
70
|
-
const rightSettings = monitoringSettings(right, config);
|
|
71
|
+
function sameMonitoringSettings(left, right, config, siemensWincc = false) {
|
|
72
|
+
const leftSettings = monitoringSettings(left, config, siemensWincc);
|
|
73
|
+
const rightSettings = monitoringSettings(right, config, siemensWincc);
|
|
71
74
|
return Object.keys(leftSettings).every(
|
|
72
75
|
(key) => leftSettings[key] === rightSettings[key]
|
|
73
76
|
);
|
|
@@ -97,6 +100,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
97
100
|
RED.nodes.createNode(this, config);
|
|
98
101
|
const node = this;
|
|
99
102
|
node.connection = RED.nodes.getNode(config.connection);
|
|
103
|
+
const diagnostics = createDiagnostics(node, config, 'subscribe');
|
|
100
104
|
node.subscriptions = new Map();
|
|
101
105
|
node.desired = new Map();
|
|
102
106
|
node.monitored = new Map();
|
|
@@ -111,6 +115,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
111
115
|
node.closing = false;
|
|
112
116
|
node.lastDropped = 0;
|
|
113
117
|
node.lastStatusAt = 0;
|
|
118
|
+
node.compatibilityWarningIssued = false;
|
|
114
119
|
const consumer = node.connection?.registerConsumer?.('subscribe', {
|
|
115
120
|
nodeId: config.id, name: config.name,
|
|
116
121
|
});
|
|
@@ -118,7 +123,16 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
118
123
|
const batchSize = positiveInteger(config.batchSize, 500);
|
|
119
124
|
const callbackBatchSize = positiveInteger(config.callbackBatchSize, 500);
|
|
120
125
|
const callbackFlushIntervalMs = positiveInteger(config.callbackFlushIntervalMs, 50);
|
|
121
|
-
const
|
|
126
|
+
const siemensWincc = isSiemensWinCC(
|
|
127
|
+
node.connection?.client?.options?.compatibilityProfile
|
|
128
|
+
);
|
|
129
|
+
const requestedSubscriptionCapacity = positiveInteger(config.maxItemsPerSubscription, 5000);
|
|
130
|
+
const configuredSubscriptionCapacity = siemensWincc
|
|
131
|
+
? Math.min(
|
|
132
|
+
requestedSubscriptionCapacity,
|
|
133
|
+
SIEMENS_WINCC_DEFAULTS.monitoredItemsPerSubscription
|
|
134
|
+
)
|
|
135
|
+
: requestedSubscriptionCapacity;
|
|
122
136
|
// Undefined preserves the descriptor-rich output used by Flow JSON created
|
|
123
137
|
// before resultDetail existed. New editor nodes explicitly save compact.
|
|
124
138
|
const resultDetail = ['compact', 'value', 'full'].includes(config.resultDetail)
|
|
@@ -129,6 +143,24 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
129
143
|
return [...node.subscriptions.keys()];
|
|
130
144
|
}
|
|
131
145
|
|
|
146
|
+
function applyCompatibility(points) {
|
|
147
|
+
if (!siemensWincc) return points;
|
|
148
|
+
let removedDeadband = false;
|
|
149
|
+
const compatible = points.map((point) => {
|
|
150
|
+
const configuredType = point.deadbandType || config.deadbandType || 'none';
|
|
151
|
+
if (configuredType === 'none') return point;
|
|
152
|
+
removedDeadband = true;
|
|
153
|
+
return { ...point, deadbandType: 'none', deadbandValue: 0 };
|
|
154
|
+
});
|
|
155
|
+
if (removedDeadband && !node.compatibilityWarningIssued) {
|
|
156
|
+
node.compatibilityWarningIssued = true;
|
|
157
|
+
node.warn(
|
|
158
|
+
'Siemens WinCC does not support OPC UA DataChange Deadband filters; Deadband was disabled.'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return compatible;
|
|
162
|
+
}
|
|
163
|
+
|
|
132
164
|
function primarySubscriptionId() {
|
|
133
165
|
return subscriptionIds()[0] ?? null;
|
|
134
166
|
}
|
|
@@ -175,6 +207,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
175
207
|
function updateQueueWarning(diagnostics) {
|
|
176
208
|
const dropped = Number(diagnostics.dropped) || 0;
|
|
177
209
|
if (dropped > node.lastDropped) {
|
|
210
|
+
diagnostics.emit('warn', 'queue_overflow', 'OPC UA notification queue dropped changes', { dropped });
|
|
178
211
|
node.warn(`OPC UA notification queue dropped ${dropped - node.lastDropped} change(s); total ${dropped}`);
|
|
179
212
|
}
|
|
180
213
|
node.lastDropped = dropped;
|
|
@@ -243,9 +276,10 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
243
276
|
|
|
244
277
|
function onNativeBatch(record, batch) {
|
|
245
278
|
if (node.closing || !record.id || !node.subscriptions.has(record.id)) return;
|
|
246
|
-
const
|
|
247
|
-
node.subscriptionDiagnostics.set(record.id,
|
|
279
|
+
const queueDiagnostics = batch.diagnostics || {};
|
|
280
|
+
node.subscriptionDiagnostics.set(record.id, queueDiagnostics);
|
|
248
281
|
const sourceChanges = batch.changes || [];
|
|
282
|
+
diagnostics.results(sourceChanges.filter((change) => node.desired.has(change.nodeId)), undefined, node.desired);
|
|
249
283
|
const changes = [];
|
|
250
284
|
for (let index = 0; index < sourceChanges.length; index += 1) {
|
|
251
285
|
const change = sourceChanges[index];
|
|
@@ -325,8 +359,8 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
325
359
|
samplingInterval: Number(config.samplingInterval) || 1000,
|
|
326
360
|
queueSize: Number(config.queueSize) || 10,
|
|
327
361
|
discardOldest: config.discardOldest !== false,
|
|
328
|
-
deadbandType: config.deadbandType || 'none',
|
|
329
|
-
deadbandValue: Number(config.deadbandValue) || 0,
|
|
362
|
+
deadbandType: siemensWincc ? 'none' : config.deadbandType || 'none',
|
|
363
|
+
deadbandValue: siemensWincc ? 0 : Number(config.deadbandValue) || 0,
|
|
330
364
|
timestampMode: config.timestampMode || 'source',
|
|
331
365
|
},
|
|
332
366
|
batchSize
|
|
@@ -497,6 +531,11 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
497
531
|
queueSizeMin: revisedQueues.minimum,
|
|
498
532
|
queueSizeMax: revisedQueues.maximum,
|
|
499
533
|
},
|
|
534
|
+
compatibility: {
|
|
535
|
+
profile: siemensWincc ? 'siemens-wincc' : 'standard',
|
|
536
|
+
deadbandSupported: !siemensWincc,
|
|
537
|
+
monitoredItemsPerSubscription: configuredSubscriptionCapacity,
|
|
538
|
+
},
|
|
500
539
|
connectionGovernance: node.connection?.getConnectionGovernance?.(),
|
|
501
540
|
connectionUsage: node.connection?.getConnectionUsage?.(),
|
|
502
541
|
};
|
|
@@ -513,7 +552,9 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
513
552
|
let rebuilt = false;
|
|
514
553
|
if (node.subscriptions.size) {
|
|
515
554
|
let valid = false;
|
|
516
|
-
try { valid = await validateSubscriptions(); } catch {
|
|
555
|
+
try { valid = await validateSubscriptions(); } catch (error) {
|
|
556
|
+
diagnostics.failure(error, undefined, 'subscription_validation_failed', true);
|
|
557
|
+
}
|
|
517
558
|
if (!valid) {
|
|
518
559
|
await clearSubscriptions(true, true);
|
|
519
560
|
rebuilt = true;
|
|
@@ -534,6 +575,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
534
575
|
}
|
|
535
576
|
await pruneEmptySubscriptions();
|
|
536
577
|
|
|
578
|
+
diagnostics.results(failures, undefined, node.desired);
|
|
537
579
|
node.reconcileAttempt = 0;
|
|
538
580
|
node.status({
|
|
539
581
|
fill: failures.length ? 'yellow' : 'green',
|
|
@@ -542,7 +584,12 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
542
584
|
(failures.length ? ` / ${failures.length} failed` : ''),
|
|
543
585
|
});
|
|
544
586
|
if (reason !== 'command') {
|
|
545
|
-
|
|
587
|
+
diagnostics.emit(failures.length ? 'warn' : 'info', failures.length ? 'subscription_recovery_partial' : 'subscription_restored',
|
|
588
|
+
'OPC UA subscription reconciliation completed; data receipt is not yet confirmed', {
|
|
589
|
+
failed: failures.length, monitored: node.monitored.size, desired: node.desired.size,
|
|
590
|
+
rebuilt, dataGapPossible: rebuilt,
|
|
591
|
+
});
|
|
592
|
+
if (config.statusOutput !== 'separate') node.send({
|
|
546
593
|
payload: { ...statusPayload('recover'), rebuilt, reason },
|
|
547
594
|
opcua: {
|
|
548
595
|
operation: 'subscription-status',
|
|
@@ -563,7 +610,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
563
610
|
for (const [nodeId, monitored] of node.monitored) {
|
|
564
611
|
const previous = node.desired.get(nodeId);
|
|
565
612
|
const next = nextDesired.get(nodeId);
|
|
566
|
-
if (!next || !previous || !sameMonitoringSettings(previous, next, config)) {
|
|
613
|
+
if (!next || !previous || !sameMonitoringSettings(previous, next, config, siemensWincc)) {
|
|
567
614
|
changed.add(nodeId);
|
|
568
615
|
}
|
|
569
616
|
}
|
|
@@ -591,6 +638,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
591
638
|
try {
|
|
592
639
|
await runSerialized(() => reconcileSubscriptions(reason));
|
|
593
640
|
} catch (error) {
|
|
641
|
+
diagnostics.failure(error, undefined, 'subscription_recovery_failed', true);
|
|
594
642
|
node.warn(`OPC UA resubscribe failed: ${error.message}`);
|
|
595
643
|
node.status({ fill: 'yellow', shape: 'ring', text: 'waiting to recover' });
|
|
596
644
|
scheduleReconnect(reason);
|
|
@@ -609,6 +657,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
609
657
|
scheduleReconnect(state.reason || 'connected', true);
|
|
610
658
|
} else if (state.state === 'subscription-inactive' &&
|
|
611
659
|
(!state.subscriptionId || node.subscriptions.has(state.subscriptionId))) {
|
|
660
|
+
diagnostics.emit('warn', 'subscription_inactive', 'OPC UA subscription is inactive; recovery pending', { retrying: true, subscriptionId: state.subscriptionId });
|
|
612
661
|
node.status({ fill: 'yellow', shape: 'ring', text: 'subscription inactive' });
|
|
613
662
|
scheduleReconnect('subscription-inactive');
|
|
614
663
|
} else if (['reconnecting', 'backoff', 'failed'].includes(state.state)) {
|
|
@@ -619,21 +668,25 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
619
668
|
node.connection?.client.on('state', onState);
|
|
620
669
|
|
|
621
670
|
node.on('input', async (msg, send, done) => {
|
|
622
|
-
if (!node.connection)
|
|
671
|
+
if (!node.connection) {
|
|
672
|
+
const error = new Error('OPCUA_CONNECTION_NOT_CONFIGURED');
|
|
673
|
+
diagnostics.failure(error, send);
|
|
674
|
+
return done(error);
|
|
675
|
+
}
|
|
623
676
|
try {
|
|
624
677
|
const command = msg.opcua?.command || 'replace';
|
|
625
678
|
const dynamicNodeId = msg.nodeId || msg.topic;
|
|
626
679
|
const configuredPayload = !looksLikePointList(msg.payload) && !Array.isArray(msg.items) &&
|
|
627
680
|
!dynamicNodeId ? config.pointList : '';
|
|
628
681
|
const staticNodeId = dynamicNodeId ? '' : config.nodeId;
|
|
629
|
-
const points = command === 'clear' ? [] : messagePoints(msg, staticNodeId, {
|
|
682
|
+
const points = command === 'clear' ? [] : applyCompatibility(messagePoints(msg, staticNodeId, {
|
|
630
683
|
samplingInterval: Number(config.samplingInterval) || 1000,
|
|
631
684
|
queueSize: Number(config.queueSize) || 10,
|
|
632
685
|
discardOldest: config.discardOldest !== false,
|
|
633
686
|
}, {
|
|
634
687
|
configuredPayload,
|
|
635
688
|
format: 'auto',
|
|
636
|
-
});
|
|
689
|
+
}));
|
|
637
690
|
|
|
638
691
|
node.status({ fill: 'blue', shape: 'dot', text: `${command} ${points.length}` });
|
|
639
692
|
await runSerialized(async () => {
|
|
@@ -642,7 +695,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
642
695
|
const changed = new Set();
|
|
643
696
|
points.forEach((point) => {
|
|
644
697
|
const previous = node.desired.get(point.nodeId);
|
|
645
|
-
if (previous && !sameMonitoringSettings(previous, point, config)) {
|
|
698
|
+
if (previous && !sameMonitoringSettings(previous, point, config, siemensWincc)) {
|
|
646
699
|
changed.add(point.nodeId);
|
|
647
700
|
}
|
|
648
701
|
node.desired.set(point.nodeId, point);
|
|
@@ -666,7 +719,10 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
666
719
|
text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub` +
|
|
667
720
|
(failed ? ` / ${failed} failed` : ''),
|
|
668
721
|
});
|
|
669
|
-
|
|
722
|
+
diagnostics.emit(failed ? 'warn' : 'info', 'subscription_updated', 'OPC UA subscription configuration updated', {
|
|
723
|
+
failed, monitored: node.monitored.size, desired: node.desired.size,
|
|
724
|
+
}, send);
|
|
725
|
+
if (config.statusOutput !== 'separate') send({
|
|
670
726
|
...msg,
|
|
671
727
|
payload: statusPayload(command),
|
|
672
728
|
opcua: { operation: 'subscription-status' },
|
|
@@ -674,6 +730,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
674
730
|
done();
|
|
675
731
|
} catch (error) {
|
|
676
732
|
node.status({ fill: 'red', shape: 'ring', text: error.message });
|
|
733
|
+
diagnostics.failure(error, send);
|
|
677
734
|
done(error);
|
|
678
735
|
}
|
|
679
736
|
});
|
|
@@ -710,16 +767,17 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
710
767
|
if (node.closing) return;
|
|
711
768
|
let points;
|
|
712
769
|
try {
|
|
713
|
-
points = messagePoints({}, config.nodeId, {
|
|
770
|
+
points = applyCompatibility(messagePoints({}, config.nodeId, {
|
|
714
771
|
samplingInterval: Number(config.samplingInterval) || 1000,
|
|
715
772
|
queueSize: Number(config.queueSize) || 10,
|
|
716
773
|
discardOldest: config.discardOldest !== false,
|
|
717
774
|
}, {
|
|
718
775
|
configuredPayload: config.pointList,
|
|
719
776
|
format: 'auto',
|
|
720
|
-
});
|
|
777
|
+
}));
|
|
721
778
|
} catch (error) {
|
|
722
779
|
node.status({ fill: 'red', shape: 'ring', text: error.message });
|
|
780
|
+
diagnostics.failure(error, undefined, 'subscription_configuration_failed');
|
|
723
781
|
node.warn(`OPC UA saved subscription point list is invalid: ${error.message}`);
|
|
724
782
|
return;
|
|
725
783
|
}
|
|
@@ -730,6 +788,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
730
788
|
});
|
|
731
789
|
}).catch((error) => {
|
|
732
790
|
node.status({ fill: 'yellow', shape: 'ring', text: 'waiting to subscribe' });
|
|
791
|
+
diagnostics.failure(error, undefined, 'subscription_startup_failed', true);
|
|
733
792
|
node.warn(`OPC UA startup subscription failed: ${error.message}`);
|
|
734
793
|
scheduleReconnect('startup');
|
|
735
794
|
});
|
package/nodes/write.html
CHANGED
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
</script>
|
|
48
48
|
<script type="text/html" data-help-name="tier0-opcua-write">
|
|
49
49
|
<section lang="zh-CN">
|
|
50
|
+
<p>输出 2 为独立状态/异常消息:payload.level 为 info、warn 或 error。可连接 Debug、文件或日志服务;原有 Catch 和日志仍然有效。同类事件每 30 秒最多输出一次,下一次包含 suppressed 计数。输出不含原始值、凭据或完整错误文本。</p>
|
|
50
51
|
<h3>写入(Write)</h3>
|
|
51
52
|
<p>向一个或多个 OPC UA Variable 写值。服务器必须允许当前用户写入,值也必须能够转换为目标数据类型。</p>
|
|
52
53
|
<h4>输入</h4>
|
|
@@ -62,6 +63,7 @@
|
|
|
62
63
|
</section>
|
|
63
64
|
<hr>
|
|
64
65
|
<section lang="en">
|
|
66
|
+
<p>Output 2 emits status/error events with payload.level (info, warn, error). Connect it to Debug, file or logging nodes. Catch and existing logs remain supported. Repeats are limited to one per 30 seconds; the next matching event includes suppressed counts. Raw values, credentials and SDK error text are omitted.</p>
|
|
65
67
|
<h3>Write</h3>
|
|
66
68
|
<p>Writes values to one or more OPC UA Variables. The current user must have write access and each value must be convertible to the target data type.</p>
|
|
67
69
|
<h4>Input</h4>
|
|
@@ -78,7 +80,7 @@
|
|
|
78
80
|
</script>
|
|
79
81
|
<script type="text/javascript">
|
|
80
82
|
RED.nodes.registerType('tier0-opcua-write', {
|
|
81
|
-
category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs:
|
|
83
|
+
category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 2, outputLabels: ['Data', 'Status / errors'], icon: 'bridge.png',
|
|
82
84
|
defaults: {
|
|
83
85
|
connection: { value: '', type: 'tier0-opcua-connection', required: true },
|
|
84
86
|
nodeId: { value: '' }, dataType: { value: 'String' },
|
package/nodes/write.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { createDiagnostics } = require('../lib/diagnostics');
|
|
4
|
+
|
|
3
5
|
const { messageWritePoints } = require('../lib/points');
|
|
4
6
|
|
|
5
7
|
module.exports = function registerWriteNode(RED) {
|
|
@@ -7,12 +9,17 @@ module.exports = function registerWriteNode(RED) {
|
|
|
7
9
|
RED.nodes.createNode(this, config);
|
|
8
10
|
const node = this;
|
|
9
11
|
node.connection = RED.nodes.getNode(config.connection);
|
|
12
|
+
const diagnostics = createDiagnostics(node, config, 'write');
|
|
10
13
|
const consumer = node.connection?.registerConsumer?.('write', {
|
|
11
14
|
nodeId: config.id, name: config.name,
|
|
12
15
|
});
|
|
13
16
|
|
|
14
17
|
node.on('input', async (msg, send, done) => {
|
|
15
|
-
if (!node.connection)
|
|
18
|
+
if (!node.connection) {
|
|
19
|
+
const error = new Error('OPCUA_CONNECTION_NOT_CONFIGURED');
|
|
20
|
+
diagnostics.failure(error, send);
|
|
21
|
+
return done(error);
|
|
22
|
+
}
|
|
16
23
|
|
|
17
24
|
try {
|
|
18
25
|
consumer?.markUsed('write');
|
|
@@ -23,6 +30,7 @@ module.exports = function registerWriteNode(RED) {
|
|
|
23
30
|
Number(config.batchSize) || 500
|
|
24
31
|
);
|
|
25
32
|
|
|
33
|
+
const failed = diagnostics.results(results, send, new Map(points.map((point) => [point.nodeId, point])));
|
|
26
34
|
if ((config.outputMode || 'batch') === 'split') {
|
|
27
35
|
results.forEach((result) => send({ ...msg, topic: result.nodeId, opcua: result }));
|
|
28
36
|
} else {
|
|
@@ -38,10 +46,12 @@ module.exports = function registerWriteNode(RED) {
|
|
|
38
46
|
send(msg);
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
node.status({ fill: 'green', shape: '
|
|
49
|
+
node.status({ fill: failed ? 'yellow' : 'green', shape: failed ? 'ring' : 'dot',
|
|
50
|
+
text: `${results.length - failed}/${results.length} written` + (failed ? ` / ${failed} failed` : '') });
|
|
42
51
|
done();
|
|
43
52
|
} catch (error) {
|
|
44
53
|
node.status({ fill: 'red', shape: 'ring', text: error.message });
|
|
54
|
+
diagnostics.failure(error, send);
|
|
45
55
|
done(error);
|
|
46
56
|
}
|
|
47
57
|
});
|
package/package.json
CHANGED
|
Binary file
|
package/src/addon.cc
CHANGED
|
@@ -49,6 +49,14 @@ std::string SecurityPolicyUri(const std::string& policy) {
|
|
|
49
49
|
policy == "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256") {
|
|
50
50
|
return "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256";
|
|
51
51
|
}
|
|
52
|
+
if (policy == "Aes128_Sha256_RsaOaep" ||
|
|
53
|
+
policy == "http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep") {
|
|
54
|
+
return "http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep";
|
|
55
|
+
}
|
|
56
|
+
if (policy == "Aes256_Sha256_RsaPss" ||
|
|
57
|
+
policy == "http://opcfoundation.org/UA/SecurityPolicy#Aes256_Sha256_RsaPss") {
|
|
58
|
+
return "http://opcfoundation.org/UA/SecurityPolicy#Aes256_Sha256_RsaPss";
|
|
59
|
+
}
|
|
52
60
|
return "";
|
|
53
61
|
}
|
|
54
62
|
|