@tier0/opcua-client 0.6.2 → 0.6.4

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
@@ -364,13 +364,25 @@ Messages contain payload.level, event, message, time (ISO 8601), nodeId,
364
364
  connectionId, operation, retrying and optional statusCode/counts. Subscription
365
365
  recovery includes rebuilt and dataGapPossible. The envelope contains
366
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.
367
+ certificates or raw SDK error text are copied to this output.
368
+
369
+ Background connection and subscription startup/recovery failures also call
370
+ `node.error(error, msg)` on each affected operation node. Connect a Catch node
371
+ in the same flow to Debug or your error-handling logic; scope Catch to the UA
372
+ operation nodes as needed. The shared Connection configuration no longer emits
373
+ duplicate state/fault warnings. A continuing background failure is reported to
374
+ Catch once per operation node until recovery. Connection recovery re-arms
375
+ connection errors; subscription errors require successful reconciliation.
376
+ Failed input requests still call `done(error)` for each request, preserving the
377
+ original message. A concurrent connection failure reported by that input does
378
+ not also generate a background Catch error. Point-level non-Good results remain
379
+ available on output 2; configuration advice and cleanup warnings remain warnings.
369
380
 
370
381
  Identical event/status-code pairs are emitted at most once per 30 seconds per
371
382
  node. The next emitted matching event includes suppressed repeats; there is
372
383
  no delayed timer flush. Connection restoration resets this suppression window.
373
- This limit applies only to diagnostic output, not the existing runtime logs.
384
+ This 30-second limit applies only to diagnostic output; background Catch errors
385
+ use the recovery-based rule above.
374
386
 
375
387
  For Subscribe, **Status messages → Both outputs (legacy compatible)** is the
376
388
  default: existing subscription-status messages remain on output 1 and the new
@@ -26,6 +26,9 @@ function createDiagnostics(node, config, operation) {
26
26
  const recent = new Map();
27
27
  let closed = false;
28
28
  let interrupted = false;
29
+ const activeFailures = new Set();
30
+ const pendingFailures = new Map();
31
+ let inputs = 0;
29
32
  const client = node.connection?.client;
30
33
  function emit(level, event, message, details = {}, send) {
31
34
  if (closed) return;
@@ -76,6 +79,50 @@ function createDiagnostics(node, config, operation) {
76
79
  function failure(error, send, event = `${operation}_failed`, retrying = false) {
77
80
  emit('error', event, `OPC UA ${operation} failed`, { statusCode: statusCode(error), retrying }, send);
78
81
  }
82
+ // Background failures have no input message/done callback. Report from the
83
+ // operation node so Catch can scope the error to its flow. Retries belong to
84
+ // one failure episode, reset only by the corresponding recovery.
85
+ function backgroundFailure(error, event, group = 'subscription') {
86
+ if (closed) return;
87
+ if (interrupted) group = 'connection';
88
+ if (activeFailures.has(group)) return;
89
+ if (inputs) {
90
+ if (!pendingFailures.has(group)) pendingFailures.set(group, { error, event });
91
+ return;
92
+ }
93
+ activeFailures.add(group);
94
+ const code = statusCode(error);
95
+ const caught = new Error(`OPC UA ${event}: ${code}`);
96
+ caught.code = code;
97
+ node.error(caught, {
98
+ opcua: {
99
+ operation: 'diagnostic', event, statusCode: code,
100
+ nodeId: node.id || config.id, connectionId: config.connection,
101
+ },
102
+ });
103
+ }
104
+ function recovered(group) {
105
+ activeFailures.delete(group);
106
+ pendingFailures.delete(group);
107
+ }
108
+ function beginInput() {
109
+ inputs++;
110
+ }
111
+ function endInput(error) {
112
+ if (error) {
113
+ // done(error) already reports this input's failure to Catch.
114
+ for (const group of pendingFailures.keys()) activeFailures.add(group);
115
+ pendingFailures.clear();
116
+ }
117
+ inputs--;
118
+ if (!inputs) {
119
+ const pending = [...pendingFailures];
120
+ pendingFailures.clear();
121
+ for (const [group, failure] of pending) {
122
+ backgroundFailure(failure.error, failure.event, group);
123
+ }
124
+ }
125
+ }
79
126
  function results(items, send, descriptors) {
80
127
  const failed = items.filter((item) => item.statusCode && item.statusCode !== 'Good');
81
128
  const failures = failed.map((item) => ({
@@ -91,11 +138,13 @@ function createDiagnostics(node, config, operation) {
91
138
  }
92
139
  const onFault = (error) => {
93
140
  interrupted = true;
141
+ backgroundFailure(error, 'connection_fault', 'connection');
94
142
  emit('warn', 'connection_fault', 'OPC UA connection fault', { statusCode: statusCode(error), retrying: true });
95
143
  };
96
144
  const onState = (state) => {
97
145
  if (!state) return;
98
146
  if (state.state === 'connected') {
147
+ recovered('connection');
99
148
  if (interrupted) {
100
149
  recent.clear();
101
150
  emit('info', 'connection_restored', 'OPC UA connection restored; subscription recovery may still be pending');
@@ -103,6 +152,9 @@ function createDiagnostics(node, config, operation) {
103
152
  interrupted = false;
104
153
  } else if (['reconnecting', 'backoff', 'failed'].includes(state.state)) {
105
154
  interrupted = true;
155
+ if (state.state === 'failed') {
156
+ backgroundFailure(state.statusCode || 'OPCUA_CONNECTION_FAILED', 'connection_failed', 'connection');
157
+ }
106
158
  emit(state.state === 'failed' ? 'error' : 'warn', `connection_${state.state}`,
107
159
  'OPC UA connection unavailable; recovery pending', {
108
160
  retrying: true, ...(state.statusCode && state.statusCode !== 'Good' ? { statusCode: statusCode(state.statusCode) } : {}),
@@ -114,10 +166,12 @@ function createDiagnostics(node, config, operation) {
114
166
  node.on('close', () => {
115
167
  closed = true;
116
168
  recent.clear();
169
+ activeFailures.clear();
170
+ pendingFailures.clear();
117
171
  client?.removeListener?.('fault', onFault);
118
172
  client?.removeListener?.('state', onState);
119
173
  });
120
- return { emit, failure, results };
174
+ return { emit, failure, backgroundFailure, recovered, beginInput, endInput, results };
121
175
  }
122
176
 
123
177
  module.exports = { createDiagnostics, statusCode };
package/nodes/browse.js CHANGED
@@ -1,160 +1,165 @@
1
- 'use strict';
2
-
3
- const { createDiagnostics } = require('../lib/diagnostics');
4
-
5
- const { chunks } = require('../lib/points');
6
- const { buildBrowseTree, selectedVariables } = require('../lib/browse-tree');
7
-
8
- const activeEditorBrowses = new Set();
9
-
10
- function positiveInteger(value, fallback, maximum) {
11
- const number = Number(value);
12
- if (!Number.isFinite(number) || number <= 0) return fallback;
13
- return Math.min(Math.floor(number), maximum);
14
- }
15
-
16
- module.exports = function registerBrowseNode(RED) {
17
- RED.httpAdmin.post(
18
- '/opcua-client/browse',
19
- RED.auth.needsPermission('opcua-client.browse'),
20
- async (req, res) => {
21
- const body = req.body || {};
22
- const connectionId = String(body.connectionId || '');
23
- const connection = RED.nodes.getNode(connectionId);
24
- if (!connection?.client) {
25
- res.status(404).send({ error: 'Connection is not deployed. Save and deploy it before loading the node tree.' });
26
- return;
27
- }
28
- if (activeEditorBrowses.has(connectionId)) {
29
- res.status(409).send({ error: 'A node-tree Browse is already running for this connection.' });
30
- return;
31
- }
32
-
33
- const rootNodeId = String(body.rootNodeId || 'ns=0;i=85').trim();
34
- if (!rootNodeId || rootNodeId.length > 1024) {
35
- res.status(400).send({ error: 'Invalid root NodeId.' });
36
- return;
37
- }
38
-
39
- activeEditorBrowses.add(connectionId);
40
- try {
41
- connection.markUsed?.('editor-browse');
42
- const items = await connection.client.browse({
43
- rootNodeId,
44
- recursive: true,
45
- variablesOnly: false,
46
- maxDepth: positiveInteger(body.maxDepth, 10, 50),
47
- maxNodes: positiveInteger(body.maxNodes, 10000, 100000),
48
- browseBatchSize: positiveInteger(body.browseBatchSize, 100, 5000),
49
- referencesPerNode: positiveInteger(body.referencesPerNode, 1000, 10000),
50
- attributeBatchSize: positiveInteger(body.attributeBatchSize, 200, 5000),
51
- });
52
- const tree = buildBrowseTree(items, rootNodeId);
53
- res.send({
54
- tree, total: items.length, rootNodeId,
55
- ...(items.diagnostics || {}),
56
- });
57
- } catch (error) {
58
- const status = error?.code === 'OPCUA_RECONNECTING' ? 503 : 500;
59
- res.status(status).send({ error: error.message || String(error) });
60
- } finally {
61
- activeEditorBrowses.delete(connectionId);
62
- }
63
- }
64
- );
65
-
66
- function OpcUaBrowseNode(config) {
67
- RED.nodes.createNode(this, config);
68
- const node = this;
69
- node.connection = RED.nodes.getNode(config.connection);
70
- const diagnostics = createDiagnostics(node, config, 'browse');
71
- const consumer = node.connection?.registerConsumer?.('browse', {
72
- nodeId: config.id, name: config.name,
73
- });
74
-
75
- node.on('input', async (msg, send, done) => {
76
- if (!node.connection) {
77
- const error = new Error('OPCUA_CONNECTION_NOT_CONFIGURED');
78
- diagnostics.failure(error, send);
79
- return done(error);
80
- }
81
-
82
- try {
83
- consumer?.markUsed('browse');
84
- const rootNodeId = msg.nodeId || msg.topic || config.rootNodeId || 'ns=0;i=85';
85
- node.status({ fill: 'blue', shape: 'dot', text: 'browsing' });
86
- const configuredSelection = selectedVariables(config.selectedNodes);
87
- const useSelection = configuredSelection.length > 0 && msg.opcua?.liveBrowse !== true;
88
- const points = useSelection ? configuredSelection : await node.connection.client.browse({
89
- rootNodeId,
90
- recursive: config.recursive !== false,
91
- maxDepth: Number(config.maxDepth) || 10,
92
- maxNodes: Number(config.maxNodes) || 10000,
93
- variablesOnly: config.variablesOnly !== false,
94
- namespaceIndexes: msg.opcua?.namespaceIndexes || [],
95
- browseBatchSize: Number(config.browseBatchSize) || 100,
96
- referencesPerNode: Number(config.referencesPerNode) || 1000,
97
- attributeBatchSize: Number(config.attributeBatchSize) || 200,
98
- });
99
- const browseDiagnostics = useSelection ? null : points.diagnostics || null;
100
-
101
- const outputMode = config.outputMode || 'all';
102
- if (outputMode === 'paged') {
103
- const pages = chunks(points, Number(config.pageSize) || 500);
104
- pages.forEach((page, index) => send({
105
- ...msg,
106
- payload: page,
107
- opcua: {
108
- ...(msg.opcua || {}),
109
- command: index === 0 ? 'replace' : 'add',
110
- source: useSelection ? 'browse-selection' : 'browse',
111
- rootNodeId,
112
- page: index + 1,
113
- pages: pages.length,
114
- done: index === pages.length - 1,
115
- total: points.length,
116
- ...(browseDiagnostics ? { browse: browseDiagnostics } : {}),
117
- },
118
- }));
119
- } else {
120
- msg.payload = points;
121
- msg.opcua = {
122
- ...(msg.opcua || {}),
123
- command: 'replace',
124
- source: useSelection ? 'browse-selection' : 'browse',
125
- rootNodeId,
126
- done: true,
127
- total: points.length,
128
- ...(browseDiagnostics ? { browse: browseDiagnostics } : {}),
129
- };
130
- send(msg);
131
- }
132
-
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);
138
- node.status({
139
- fill: partial ? 'yellow' : 'green', shape: partial ? 'ring' : 'dot',
140
- text: `${points.length} points` +
141
- (browseDiagnostics?.truncated
142
- ? ` / truncated: ${browseDiagnostics.truncationReason}` : ''),
143
- });
144
- done();
145
- } catch (error) {
146
- node.status({ fill: 'red', shape: 'ring', text: error.message });
147
- diagnostics.failure(error, send);
148
- done(error);
149
- }
150
- });
151
-
152
- node.on('close', (removed, done) => {
153
- if (typeof removed === 'function') done = removed;
154
- consumer?.release();
155
- done?.();
156
- });
157
- }
158
-
159
- RED.nodes.registerType('tier0-opcua-browse', OpcUaBrowseNode);
160
- };
1
+ 'use strict';
2
+
3
+ const { createDiagnostics } = require('../lib/diagnostics');
4
+
5
+ const { chunks } = require('../lib/points');
6
+ const { buildBrowseTree, selectedVariables } = require('../lib/browse-tree');
7
+
8
+ const activeEditorBrowses = new Set();
9
+
10
+ function positiveInteger(value, fallback, maximum) {
11
+ const number = Number(value);
12
+ if (!Number.isFinite(number) || number <= 0) return fallback;
13
+ return Math.min(Math.floor(number), maximum);
14
+ }
15
+
16
+ module.exports = function registerBrowseNode(RED) {
17
+ RED.httpAdmin.post(
18
+ '/opcua-client/browse',
19
+ RED.auth.needsPermission('opcua-client.browse'),
20
+ async (req, res) => {
21
+ const body = req.body || {};
22
+ const connectionId = String(body.connectionId || '');
23
+ const connection = RED.nodes.getNode(connectionId);
24
+ if (!connection?.client) {
25
+ res.status(404).send({ error: 'Connection is not deployed. Save and deploy it before loading the node tree.' });
26
+ return;
27
+ }
28
+ if (activeEditorBrowses.has(connectionId)) {
29
+ res.status(409).send({ error: 'A node-tree Browse is already running for this connection.' });
30
+ return;
31
+ }
32
+
33
+ const rootNodeId = String(body.rootNodeId || 'ns=0;i=85').trim();
34
+ if (!rootNodeId || rootNodeId.length > 1024) {
35
+ res.status(400).send({ error: 'Invalid root NodeId.' });
36
+ return;
37
+ }
38
+
39
+ activeEditorBrowses.add(connectionId);
40
+ try {
41
+ connection.markUsed?.('editor-browse');
42
+ const items = await connection.client.browse({
43
+ rootNodeId,
44
+ recursive: true,
45
+ variablesOnly: false,
46
+ maxDepth: positiveInteger(body.maxDepth, 10, 50),
47
+ maxNodes: positiveInteger(body.maxNodes, 10000, 100000),
48
+ browseBatchSize: positiveInteger(body.browseBatchSize, 100, 5000),
49
+ referencesPerNode: positiveInteger(body.referencesPerNode, 1000, 10000),
50
+ attributeBatchSize: positiveInteger(body.attributeBatchSize, 200, 5000),
51
+ });
52
+ const tree = buildBrowseTree(items, rootNodeId);
53
+ res.send({
54
+ tree, total: items.length, rootNodeId,
55
+ ...(items.diagnostics || {}),
56
+ });
57
+ } catch (error) {
58
+ const status = error?.code === 'OPCUA_RECONNECTING' ? 503 : 500;
59
+ res.status(status).send({ error: error.message || String(error) });
60
+ } finally {
61
+ activeEditorBrowses.delete(connectionId);
62
+ }
63
+ }
64
+ );
65
+
66
+ function OpcUaBrowseNode(config) {
67
+ RED.nodes.createNode(this, config);
68
+ const node = this;
69
+ node.connection = RED.nodes.getNode(config.connection);
70
+ const diagnostics = createDiagnostics(node, config, 'browse');
71
+ const consumer = node.connection?.registerConsumer?.('browse', {
72
+ nodeId: config.id, name: config.name,
73
+ });
74
+
75
+ node.on('input', async (msg, send, done) => {
76
+ if (!node.connection) {
77
+ const error = new Error('OPCUA_CONNECTION_NOT_CONFIGURED');
78
+ diagnostics.failure(error, send);
79
+ return done(error);
80
+ }
81
+
82
+ diagnostics.beginInput();
83
+ let inputError;
84
+ try {
85
+ consumer?.markUsed('browse');
86
+ const rootNodeId = msg.nodeId || msg.topic || config.rootNodeId || 'ns=0;i=85';
87
+ node.status({ fill: 'blue', shape: 'dot', text: 'browsing' });
88
+ const configuredSelection = selectedVariables(config.selectedNodes);
89
+ const useSelection = configuredSelection.length > 0 && msg.opcua?.liveBrowse !== true;
90
+ const points = useSelection ? configuredSelection : await node.connection.client.browse({
91
+ rootNodeId,
92
+ recursive: config.recursive !== false,
93
+ maxDepth: Number(config.maxDepth) || 10,
94
+ maxNodes: Number(config.maxNodes) || 10000,
95
+ variablesOnly: config.variablesOnly !== false,
96
+ namespaceIndexes: msg.opcua?.namespaceIndexes || [],
97
+ browseBatchSize: Number(config.browseBatchSize) || 100,
98
+ referencesPerNode: Number(config.referencesPerNode) || 1000,
99
+ attributeBatchSize: Number(config.attributeBatchSize) || 200,
100
+ });
101
+ const browseDiagnostics = useSelection ? null : points.diagnostics || null;
102
+
103
+ const outputMode = config.outputMode || 'all';
104
+ if (outputMode === 'paged') {
105
+ const pages = chunks(points, Number(config.pageSize) || 500);
106
+ pages.forEach((page, index) => send({
107
+ ...msg,
108
+ payload: page,
109
+ opcua: {
110
+ ...(msg.opcua || {}),
111
+ command: index === 0 ? 'replace' : 'add',
112
+ source: useSelection ? 'browse-selection' : 'browse',
113
+ rootNodeId,
114
+ page: index + 1,
115
+ pages: pages.length,
116
+ done: index === pages.length - 1,
117
+ total: points.length,
118
+ ...(browseDiagnostics ? { browse: browseDiagnostics } : {}),
119
+ },
120
+ }));
121
+ } else {
122
+ msg.payload = points;
123
+ msg.opcua = {
124
+ ...(msg.opcua || {}),
125
+ command: 'replace',
126
+ source: useSelection ? 'browse-selection' : 'browse',
127
+ rootNodeId,
128
+ done: true,
129
+ total: points.length,
130
+ ...(browseDiagnostics ? { browse: browseDiagnostics } : {}),
131
+ };
132
+ send(msg);
133
+ }
134
+
135
+ const partial = browseDiagnostics?.truncated || browseDiagnostics?.failures?.length;
136
+ if (partial) diagnostics.emit('warn', 'browse_partial', 'OPC UA Browse returned partial results', {
137
+ failed: browseDiagnostics?.failures?.length || 0, total: points.length,
138
+ failures: browseDiagnostics?.failures || [],
139
+ }, send);
140
+ node.status({
141
+ fill: partial ? 'yellow' : 'green', shape: partial ? 'ring' : 'dot',
142
+ text: `${points.length} points` +
143
+ (browseDiagnostics?.truncated
144
+ ? ` / truncated: ${browseDiagnostics.truncationReason}` : ''),
145
+ });
146
+ done();
147
+ } catch (error) {
148
+ inputError = error;
149
+ node.status({ fill: 'red', shape: 'ring', text: error.message });
150
+ diagnostics.failure(error, send);
151
+ done(error);
152
+ } finally {
153
+ diagnostics.endInput(inputError);
154
+ }
155
+ });
156
+
157
+ node.on('close', (removed, done) => {
158
+ if (typeof removed === 'function') done = removed;
159
+ consumer?.release();
160
+ done?.();
161
+ });
162
+ }
163
+
164
+ RED.nodes.registerType('tier0-opcua-browse', OpcUaBrowseNode);
165
+ };
@@ -235,19 +235,13 @@ module.exports = function registerConnectionNode(RED) {
235
235
  );
236
236
  }
237
237
 
238
- const onFault = (error) => node.warn(`OPC UA connection fault: ${error.message}`);
239
- const onState = (state) => {
240
- if (state.state === 'failed') node.warn(`OPC UA connection failed: ${state.statusCode}`);
241
- };
242
- node.client.on('fault', onFault);
243
- node.client.on('state', onState);
238
+ // Operation nodes report connection faults to their own flow's Catch nodes.
239
+ // Logging here too duplicates each shared-connection failure globally.
244
240
  node.on('close', async (removed, done) => {
245
241
  if (typeof removed === 'function') {
246
242
  done = removed;
247
243
  removed = false;
248
244
  }
249
- node.client.off('fault', onFault);
250
- node.client.off('state', onState);
251
245
  registration.unregister();
252
246
  const cleanupResults = await Promise.allSettled(
253
247
  [...dependentCleanups].map(runDependentCleanup)
package/nodes/read.html CHANGED
@@ -88,7 +88,7 @@
88
88
  </script>
89
89
  <script type="text/javascript">
90
90
  RED.nodes.registerType('tier0-opcua-read', {
91
- category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 2, outputLabels: ['Data', 'Status / errors'], icon: 'bridge.png',
91
+ category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 2, outputLabels: ['Data', 'Status / errors'], icon: 'font-awesome/fa-arrow-circle-down',
92
92
  defaults: {
93
93
  connection: { value: '', type: 'tier0-opcua-connection', required: true },
94
94
  nodeId: { value: '' }, pointList: { value: '' }, pointListFileName: { value: '' },