@tier0/opcua-client 0.6.0 → 0.6.3

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 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: 1, icon: 'search.png',
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) return done(new Error('OPC UA connection not configured'));
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: 1, icon: 'bridge.png',
91
+ category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 2, outputLabels: ['Data', 'Status / errors'], icon: 'font-awesome/fa-arrow-circle-down',
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) return done(new Error('OPC UA connection not configured'));
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: 'dot', text: `${results.length} read` });
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
  });
@@ -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: 1, icon: 'bridge.png',
112
+ category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 2, outputLabels: ['Data', 'Status / errors'], icon: 'font-awesome/fa-rss',
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() },
@@ -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 diagnostics = batch.diagnostics || {};
276
- node.subscriptionDiagnostics.set(record.id, diagnostics);
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
- node.send({
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) return done(new Error('OPC UA connection not configured'));
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
- send({
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: 1, icon: 'bridge.png',
83
+ category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 2, outputLabels: ['Data', 'Status / errors'], icon: 'font-awesome/fa-pencil',
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) return done(new Error('OPC UA connection not configured'));
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: 'dot', text: `${results.length} written` });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tier0/opcua-client",
3
- "version": "0.6.0",
3
+ "version": "0.6.3",
4
4
  "description": "High-scale OPC UA client nodes for Node-RED powered by open62541",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {