@tier0/opcua-client 0.6.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 +53 -0
- package/lib/diagnostics.js +123 -0
- package/nodes/browse.html +3 -1
- package/nodes/browse.js +13 -1
- package/nodes/read.html +3 -1
- package/nodes/read.js +12 -2
- package/nodes/subscribe.html +5 -1
- package/nodes/subscribe.js +31 -6
- package/nodes/write.html +3 -1
- package/nodes/write.js +12 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -347,3 +347,56 @@ See [BENCHMARK.md](BENCHMARK.md) for the recorded 1,000, 5,000, 10,000, and sust
|
|
|
347
347
|
- Native command queue: 256 pending commands per connection.
|
|
348
348
|
|
|
349
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.
|
|
@@ -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/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/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>
|
|
@@ -82,6 +84,7 @@
|
|
|
82
84
|
</section>
|
|
83
85
|
<hr>
|
|
84
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>
|
|
85
88
|
<h3>Subscribe</h3>
|
|
86
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>
|
|
87
90
|
<h4>Input and commands</h4>
|
|
@@ -106,8 +109,9 @@
|
|
|
106
109
|
</script>
|
|
107
110
|
<script type="text/javascript">
|
|
108
111
|
RED.nodes.registerType('tier0-opcua-subscribe', {
|
|
109
|
-
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',
|
|
110
113
|
defaults: {
|
|
114
|
+
statusOutput: { value: 'both' },
|
|
111
115
|
connection: { value: '', type: 'tier0-opcua-connection', required: true },
|
|
112
116
|
nodeId: { value: '' }, pointList: { value: '' }, pointListFileName: { value: '' },
|
|
113
117
|
publishingInterval: { value: 1000, validate: RED.validators.number() }, samplingInterval: { value: 1000, validate: RED.validators.number() },
|
package/nodes/subscribe.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
|
const { SIEMENS_WINCC_DEFAULTS, isSiemensWinCC } = require('../lib/compatibility');
|
|
5
7
|
|
|
@@ -98,6 +100,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
98
100
|
RED.nodes.createNode(this, config);
|
|
99
101
|
const node = this;
|
|
100
102
|
node.connection = RED.nodes.getNode(config.connection);
|
|
103
|
+
const diagnostics = createDiagnostics(node, config, 'subscribe');
|
|
101
104
|
node.subscriptions = new Map();
|
|
102
105
|
node.desired = new Map();
|
|
103
106
|
node.monitored = new Map();
|
|
@@ -204,6 +207,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
204
207
|
function updateQueueWarning(diagnostics) {
|
|
205
208
|
const dropped = Number(diagnostics.dropped) || 0;
|
|
206
209
|
if (dropped > node.lastDropped) {
|
|
210
|
+
diagnostics.emit('warn', 'queue_overflow', 'OPC UA notification queue dropped changes', { dropped });
|
|
207
211
|
node.warn(`OPC UA notification queue dropped ${dropped - node.lastDropped} change(s); total ${dropped}`);
|
|
208
212
|
}
|
|
209
213
|
node.lastDropped = dropped;
|
|
@@ -272,9 +276,10 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
272
276
|
|
|
273
277
|
function onNativeBatch(record, batch) {
|
|
274
278
|
if (node.closing || !record.id || !node.subscriptions.has(record.id)) return;
|
|
275
|
-
const
|
|
276
|
-
node.subscriptionDiagnostics.set(record.id,
|
|
279
|
+
const queueDiagnostics = batch.diagnostics || {};
|
|
280
|
+
node.subscriptionDiagnostics.set(record.id, queueDiagnostics);
|
|
277
281
|
const sourceChanges = batch.changes || [];
|
|
282
|
+
diagnostics.results(sourceChanges.filter((change) => node.desired.has(change.nodeId)), undefined, node.desired);
|
|
278
283
|
const changes = [];
|
|
279
284
|
for (let index = 0; index < sourceChanges.length; index += 1) {
|
|
280
285
|
const change = sourceChanges[index];
|
|
@@ -547,7 +552,9 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
547
552
|
let rebuilt = false;
|
|
548
553
|
if (node.subscriptions.size) {
|
|
549
554
|
let valid = false;
|
|
550
|
-
try { valid = await validateSubscriptions(); } catch {
|
|
555
|
+
try { valid = await validateSubscriptions(); } catch (error) {
|
|
556
|
+
diagnostics.failure(error, undefined, 'subscription_validation_failed', true);
|
|
557
|
+
}
|
|
551
558
|
if (!valid) {
|
|
552
559
|
await clearSubscriptions(true, true);
|
|
553
560
|
rebuilt = true;
|
|
@@ -568,6 +575,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
568
575
|
}
|
|
569
576
|
await pruneEmptySubscriptions();
|
|
570
577
|
|
|
578
|
+
diagnostics.results(failures, undefined, node.desired);
|
|
571
579
|
node.reconcileAttempt = 0;
|
|
572
580
|
node.status({
|
|
573
581
|
fill: failures.length ? 'yellow' : 'green',
|
|
@@ -576,7 +584,12 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
576
584
|
(failures.length ? ` / ${failures.length} failed` : ''),
|
|
577
585
|
});
|
|
578
586
|
if (reason !== 'command') {
|
|
579
|
-
|
|
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({
|
|
580
593
|
payload: { ...statusPayload('recover'), rebuilt, reason },
|
|
581
594
|
opcua: {
|
|
582
595
|
operation: 'subscription-status',
|
|
@@ -625,6 +638,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
625
638
|
try {
|
|
626
639
|
await runSerialized(() => reconcileSubscriptions(reason));
|
|
627
640
|
} catch (error) {
|
|
641
|
+
diagnostics.failure(error, undefined, 'subscription_recovery_failed', true);
|
|
628
642
|
node.warn(`OPC UA resubscribe failed: ${error.message}`);
|
|
629
643
|
node.status({ fill: 'yellow', shape: 'ring', text: 'waiting to recover' });
|
|
630
644
|
scheduleReconnect(reason);
|
|
@@ -643,6 +657,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
643
657
|
scheduleReconnect(state.reason || 'connected', true);
|
|
644
658
|
} else if (state.state === 'subscription-inactive' &&
|
|
645
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 });
|
|
646
661
|
node.status({ fill: 'yellow', shape: 'ring', text: 'subscription inactive' });
|
|
647
662
|
scheduleReconnect('subscription-inactive');
|
|
648
663
|
} else if (['reconnecting', 'backoff', 'failed'].includes(state.state)) {
|
|
@@ -653,7 +668,11 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
653
668
|
node.connection?.client.on('state', onState);
|
|
654
669
|
|
|
655
670
|
node.on('input', async (msg, send, done) => {
|
|
656
|
-
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
|
+
}
|
|
657
676
|
try {
|
|
658
677
|
const command = msg.opcua?.command || 'replace';
|
|
659
678
|
const dynamicNodeId = msg.nodeId || msg.topic;
|
|
@@ -700,7 +719,10 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
700
719
|
text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub` +
|
|
701
720
|
(failed ? ` / ${failed} failed` : ''),
|
|
702
721
|
});
|
|
703
|
-
|
|
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({
|
|
704
726
|
...msg,
|
|
705
727
|
payload: statusPayload(command),
|
|
706
728
|
opcua: { operation: 'subscription-status' },
|
|
@@ -708,6 +730,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
708
730
|
done();
|
|
709
731
|
} catch (error) {
|
|
710
732
|
node.status({ fill: 'red', shape: 'ring', text: error.message });
|
|
733
|
+
diagnostics.failure(error, send);
|
|
711
734
|
done(error);
|
|
712
735
|
}
|
|
713
736
|
});
|
|
@@ -754,6 +777,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
754
777
|
}));
|
|
755
778
|
} catch (error) {
|
|
756
779
|
node.status({ fill: 'red', shape: 'ring', text: error.message });
|
|
780
|
+
diagnostics.failure(error, undefined, 'subscription_configuration_failed');
|
|
757
781
|
node.warn(`OPC UA saved subscription point list is invalid: ${error.message}`);
|
|
758
782
|
return;
|
|
759
783
|
}
|
|
@@ -764,6 +788,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
764
788
|
});
|
|
765
789
|
}).catch((error) => {
|
|
766
790
|
node.status({ fill: 'yellow', shape: 'ring', text: 'waiting to subscribe' });
|
|
791
|
+
diagnostics.failure(error, undefined, 'subscription_startup_failed', true);
|
|
767
792
|
node.warn(`OPC UA startup subscription failed: ${error.message}`);
|
|
768
793
|
scheduleReconnect('startup');
|
|
769
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
|
});
|