@tier0/opcua-client 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/BENCHMARK.md +53 -2
  2. package/README.md +180 -14
  3. package/THIRD_PARTY_NOTICES.md +20 -0
  4. package/binding.gyp +1 -1
  5. package/lib/client.js +48 -7
  6. package/lib/connection-registry.js +68 -0
  7. package/lib/native.js +25 -10
  8. package/lib/points.js +187 -21
  9. package/lib/prebuild.js +42 -0
  10. package/lib/security-file-store.js +119 -0
  11. package/lib/security.js +96 -0
  12. package/node_modules/node-addon-api/LICENSE.md +9 -0
  13. package/node_modules/node-addon-api/README.md +95 -0
  14. package/node_modules/node-addon-api/common.gypi +21 -0
  15. package/node_modules/node-addon-api/except.gypi +25 -0
  16. package/node_modules/node-addon-api/index.js +14 -0
  17. package/node_modules/node-addon-api/napi-inl.deprecated.h +186 -0
  18. package/node_modules/node-addon-api/napi-inl.h +7244 -0
  19. package/node_modules/node-addon-api/napi.h +3420 -0
  20. package/node_modules/node-addon-api/node_addon_api.gyp +42 -0
  21. package/node_modules/node-addon-api/node_api.gyp +9 -0
  22. package/node_modules/node-addon-api/noexcept.gypi +26 -0
  23. package/node_modules/node-addon-api/nothing.c +0 -0
  24. package/node_modules/node-addon-api/package-support.json +21 -0
  25. package/node_modules/node-addon-api/package.json +481 -0
  26. package/node_modules/node-addon-api/tools/README.md +73 -0
  27. package/node_modules/node-addon-api/tools/check-napi.js +99 -0
  28. package/node_modules/node-addon-api/tools/clang-format.js +71 -0
  29. package/node_modules/node-addon-api/tools/conversion.js +301 -0
  30. package/nodes/browse.html +27 -2
  31. package/nodes/connection.html +293 -6
  32. package/nodes/connection.js +160 -23
  33. package/nodes/read.html +42 -3
  34. package/nodes/read.js +51 -12
  35. package/nodes/subscribe.html +57 -5
  36. package/nodes/subscribe.js +386 -99
  37. package/nodes/write.html +29 -2
  38. package/nodes/write.js +2 -1
  39. package/package.json +15 -4
  40. package/prebuilds/linux-x64-musl/opcua_open62541.node +0 -0
  41. package/scripts/build-offline-prebuild.sh +15 -0
  42. package/scripts/build-open62541.sh +14 -4
  43. package/scripts/build-test-server.sh +23 -0
  44. package/scripts/install.js +28 -0
  45. package/scripts/verify-offline-package.js +20 -0
  46. package/scripts/verify-runtime.js +20 -0
  47. package/src/addon.cc +707 -119
  48. package/vendor/open62541-LICENSE +373 -0
  49. package/vendor/open62541-v1.4.16.tar.gz +0 -0
@@ -2,88 +2,277 @@
2
2
 
3
3
  const { messagePoints } = require('../lib/points');
4
4
 
5
+ const NOTIFICATION_CONFIG_FIELDS = new Set([
6
+ 'nodeId',
7
+ 'samplingInterval',
8
+ 'queueSize',
9
+ 'discardOldest',
10
+ 'deadbandType',
11
+ 'deadbandValue',
12
+ 'enabled',
13
+ ]);
14
+
15
+ const COMPACT_NOTIFICATION_FIELDS = Object.freeze([
16
+ 'nodeId',
17
+ 'value',
18
+ 'statusCode',
19
+ 'sourceTimestamp',
20
+ ]);
21
+
22
+ function valueOnly(change) {
23
+ return Object.prototype.hasOwnProperty.call(change, 'value') ? change.value : null;
24
+ }
25
+
26
+ function compactChange(change, point) {
27
+ const output = {};
28
+ for (const field of COMPACT_NOTIFICATION_FIELDS) {
29
+ if (Object.prototype.hasOwnProperty.call(change, field)) output[field] = change[field];
30
+ }
31
+ if (point.alias != null && point.alias !== '') output.alias = point.alias;
32
+ return output;
33
+ }
34
+
35
+ function valueChange(change, point) {
36
+ return {
37
+ nodeId: change.nodeId,
38
+ ...(point.alias != null && point.alias !== '' ? { alias: point.alias } : {}),
39
+ ...(Object.prototype.hasOwnProperty.call(change, 'value') ? { value: change.value } : {}),
40
+ };
41
+ }
42
+
43
+ function fullChange(change, point, subscriptionId) {
44
+ change.subscriptionId = subscriptionId;
45
+ for (const key in point) {
46
+ if (!NOTIFICATION_CONFIG_FIELDS.has(key) && !Object.hasOwn(change, key)) {
47
+ change[key] = point[key];
48
+ }
49
+ }
50
+ return change;
51
+ }
52
+
53
+ function positiveInteger(value, fallback) {
54
+ const number = Number(value);
55
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
56
+ }
57
+
5
58
  module.exports = function registerSubscribeNode(RED) {
6
59
  function OpcUaSubscribeNode(config) {
7
60
  RED.nodes.createNode(this, config);
8
61
  const node = this;
9
62
  node.connection = RED.nodes.getNode(config.connection);
10
- node.subscriptionId = null;
63
+ node.subscriptions = new Map();
11
64
  node.desired = new Map();
12
65
  node.monitored = new Map();
66
+ node.subscriptionDiagnostics = new Map();
67
+ node.pendingChanges = [];
68
+ node.flushTimer = null;
13
69
  node.reconnectTimer = null;
14
70
  node.reconcileChain = Promise.resolve();
15
71
  node.reconcileAttempt = 0;
16
72
  node.lastSessionGeneration = 0;
17
- node.lastRecoveryReason = '';
18
73
  node.closing = false;
19
- node.lastDiagnostics = {};
20
74
  node.lastDropped = 0;
21
75
  node.lastStatusAt = 0;
22
76
 
23
- const batchSize = Number(config.batchSize) || 500;
77
+ const batchSize = positiveInteger(config.batchSize, 500);
78
+ const callbackBatchSize = positiveInteger(config.callbackBatchSize, 500);
79
+ const callbackFlushIntervalMs = positiveInteger(config.callbackFlushIntervalMs, 50);
80
+ const configuredSubscriptionCapacity = positiveInteger(config.maxItemsPerSubscription, 5000);
81
+ // Undefined preserves the descriptor-rich output used by Flow JSON created
82
+ // before resultDetail existed. New editor nodes explicitly save compact.
83
+ const resultDetail = ['compact', 'value', 'full'].includes(config.resultDetail)
84
+ ? config.resultDetail
85
+ : 'full';
24
86
 
25
- function emitBatch(batch) {
26
- const diagnostics = batch.diagnostics || {};
27
- const results = batch.changes || [];
28
- for (const change of results) {
29
- const point = node.desired.get(change.nodeId);
30
- if (!point) continue;
31
- for (const key in point) {
32
- if (!Object.hasOwn(change, key)) change[key] = point[key];
33
- }
87
+ function subscriptionIds() {
88
+ return [...node.subscriptions.keys()];
89
+ }
90
+
91
+ function primarySubscriptionId() {
92
+ return subscriptionIds()[0] ?? null;
93
+ }
94
+
95
+ function subscriptionCapacity() {
96
+ const diagnostics = node.connection?.client.getDiagnostics?.() || {};
97
+ const serverLimit = Number(
98
+ diagnostics.operationLimits?.maxMonitoredItemsPerSubscription
99
+ );
100
+ return Number.isFinite(serverLimit) && serverLimit > 0
101
+ ? Math.min(configuredSubscriptionCapacity, Math.floor(serverLimit))
102
+ : configuredSubscriptionCapacity;
103
+ }
104
+
105
+ function combinedDiagnostics() {
106
+ const output = {
107
+ queueDepth: 0,
108
+ queueCapacity: 0,
109
+ maxQueueDepth: 0,
110
+ enqueued: 0,
111
+ delivered: 0,
112
+ dropped: 0,
113
+ coalesced: 0,
114
+ batches: 0,
115
+ maxLagMs: 0,
116
+ subscriptionCount: node.subscriptions.size,
117
+ mode: config.queueMode || 'all',
118
+ overflowPolicy: config.overflowPolicy || 'drop-oldest',
119
+ };
120
+ for (const diagnostics of node.subscriptionDiagnostics.values()) {
121
+ output.queueDepth += Number(diagnostics.queueDepth) || 0;
122
+ output.queueCapacity += Number(diagnostics.queueCapacity) || 0;
123
+ output.maxQueueDepth += Number(diagnostics.maxQueueDepth) || 0;
124
+ output.enqueued += Number(diagnostics.enqueued) || 0;
125
+ output.delivered += Number(diagnostics.delivered) || 0;
126
+ output.dropped += Number(diagnostics.dropped) || 0;
127
+ output.coalesced += Number(diagnostics.coalesced) || 0;
128
+ output.batches += Number(diagnostics.batchSequence) || 0;
129
+ output.maxLagMs = Math.max(output.maxLagMs, Number(diagnostics.maxLagMs) || 0);
34
130
  }
35
- node.lastDiagnostics = diagnostics;
131
+ return output;
132
+ }
133
+
134
+ function updateQueueWarning(diagnostics) {
36
135
  const dropped = Number(diagnostics.dropped) || 0;
37
136
  if (dropped > node.lastDropped) {
38
137
  node.warn(`OPC UA notification queue dropped ${dropped - node.lastDropped} change(s); total ${dropped}`);
39
138
  }
40
139
  node.lastDropped = dropped;
140
+ }
41
141
 
42
- if (results.length) {
43
- if ((config.outputMode || 'batch') === 'batch') {
44
- node.send({
45
- payload: results,
46
- opcua: { operation: 'change', total: results.length, queue: diagnostics },
47
- });
48
- } else {
49
- results.forEach((result, index) => node.send({
50
- topic: result.nodeId,
51
- payload: result.value,
52
- opcua: index === 0 ? { ...result, queue: diagnostics } : result,
53
- }));
142
+ function sendBatch(changes) {
143
+ let accepted = 0;
144
+ for (let index = 0; index < changes.length; index += 1) {
145
+ if (node.desired.has(changes[index].nodeId)) {
146
+ changes[accepted] = changes[index];
147
+ accepted += 1;
54
148
  }
55
149
  }
150
+ changes.length = accepted;
151
+ if (!changes.length || node.closing) return;
152
+ const diagnostics = combinedDiagnostics();
153
+ updateQueueWarning(diagnostics);
154
+ const output = {
155
+ payload: resultDetail === 'value' ? changes.map(valueOnly) : changes,
156
+ opcua: {
157
+ operation: 'change',
158
+ total: changes.length,
159
+ subscriptionCount: node.subscriptions.size,
160
+ resultDetail,
161
+ queue: diagnostics,
162
+ },
163
+ };
164
+ if (resultDetail === 'value') {
165
+ // Subscription batches are sparse and may repeat one NodeId. A parallel
166
+ // identifier array preserves every change without repeating objects.
167
+ output.opcua.identifiers = changes.map((change) => change.alias || change.nodeId);
168
+ }
169
+ node.send({
170
+ ...output,
171
+ });
172
+ }
173
+
174
+ function clearFlushTimer() {
175
+ if (node.flushTimer) clearTimeout(node.flushTimer);
176
+ node.flushTimer = null;
177
+ }
178
+
179
+ function scheduleFlush() {
180
+ if (node.flushTimer || !node.pendingChanges.length || node.closing) return;
181
+ node.flushTimer = setTimeout(() => {
182
+ node.flushTimer = null;
183
+ while (node.pendingChanges.length) {
184
+ sendBatch(node.pendingChanges.splice(0, callbackBatchSize));
185
+ }
186
+ }, callbackFlushIntervalMs);
187
+ node.flushTimer.unref?.();
188
+ }
189
+
190
+ function flushFullBatches() {
191
+ while (node.pendingChanges.length >= callbackBatchSize) {
192
+ sendBatch(node.pendingChanges.splice(0, callbackBatchSize));
193
+ }
194
+ if (!node.pendingChanges.length) clearFlushTimer();
195
+ else scheduleFlush();
196
+ }
197
+
198
+ function discardPendingChanges() {
199
+ clearFlushTimer();
200
+ node.pendingChanges.length = 0;
201
+ }
202
+
203
+ function onNativeBatch(record, batch) {
204
+ if (node.closing || !record.id || !node.subscriptions.has(record.id)) return;
205
+ const diagnostics = batch.diagnostics || {};
206
+ node.subscriptionDiagnostics.set(record.id, diagnostics);
207
+ const sourceChanges = batch.changes || [];
208
+ const changes = [];
209
+ for (let index = 0; index < sourceChanges.length; index += 1) {
210
+ const change = sourceChanges[index];
211
+ const point = node.desired.get(change.nodeId);
212
+ if (!point) continue;
213
+ changes.push(resultDetail === 'compact'
214
+ ? compactChange(change, point)
215
+ : resultDetail === 'value'
216
+ ? valueChange(change, point)
217
+ : fullChange(change, point, record.id));
218
+ }
219
+
220
+ if ((config.outputMode || 'batch') === 'single') {
221
+ const queue = combinedDiagnostics();
222
+ updateQueueWarning(queue);
223
+ changes.forEach((change, index) => {
224
+ const output = {
225
+ topic: change.nodeId,
226
+ payload: valueOnly(change),
227
+ };
228
+ if (resultDetail === 'value') {
229
+ if (index === 0) output.opcua = { operation: 'change', resultDetail, queue };
230
+ } else output.opcua = index === 0 ? { ...change, queue } : change;
231
+ node.send(output);
232
+ });
233
+ } else if (changes.length) {
234
+ node.pendingChanges.push(...changes);
235
+ flushFullBatches();
236
+ scheduleFlush();
237
+ }
56
238
 
57
239
  const now = Date.now();
58
- if (dropped > 0 || now - node.lastStatusAt >= 1000) {
240
+ const queue = combinedDiagnostics();
241
+ if (queue.dropped > 0 || now - node.lastStatusAt >= 1000) {
59
242
  node.lastStatusAt = now;
60
243
  node.status({
61
- fill: dropped > 0 ? 'yellow' : 'green',
62
- shape: dropped > 0 ? 'ring' : 'dot',
63
- text: `${node.monitored.size} monitored q:${diagnostics.queueDepth || 0}/${diagnostics.queueCapacity || 0}`,
244
+ fill: queue.dropped > 0 ? 'yellow' : 'green',
245
+ shape: queue.dropped > 0 ? 'ring' : 'dot',
246
+ text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub q:${queue.queueDepth}/${queue.queueCapacity}`,
64
247
  });
65
248
  }
66
249
  }
67
250
 
68
- async function ensureSubscription() {
69
- if (node.subscriptionId != null) return;
251
+ async function createSubscription() {
252
+ const record = {
253
+ id: null,
254
+ monitored: new Map(),
255
+ limit: subscriptionCapacity(),
256
+ };
70
257
  const result = await node.connection.client.createSubscription({
71
258
  publishingInterval: Number(config.publishingInterval) || 1000,
72
259
  lifetimeCount: Number(config.lifetimeCount) || 120,
73
260
  maxKeepAliveCount: Number(config.maxKeepAliveCount) || 10,
74
261
  maxNotificationsPerPublish: Number(config.maxNotificationsPerPublish) || 0,
75
- callbackQueueSize: Number(config.callbackQueueSize) || 10000,
76
- callbackBatchSize: Number(config.callbackBatchSize) || 500,
262
+ callbackQueueSize: positiveInteger(config.callbackQueueSize, 10000),
263
+ callbackBatchSize,
77
264
  queueMode: config.queueMode || 'all',
78
265
  overflowPolicy: config.overflowPolicy || 'drop-oldest',
79
- }, emitBatch);
80
- node.subscriptionId = result.subscriptionId;
266
+ }, (batch) => onNativeBatch(record, batch));
267
+ record.id = result.subscriptionId;
268
+ node.subscriptions.set(record.id, record);
269
+ return record;
81
270
  }
82
271
 
83
- async function createMonitoredItems(points) {
272
+ async function addMonitoredItems(record, points) {
84
273
  if (!points.length) return [];
85
274
  const results = await node.connection.client.addMonitoredItems(
86
- node.subscriptionId,
275
+ record.id,
87
276
  points,
88
277
  {
89
278
  samplingInterval: Number(config.samplingInterval) || 1000,
@@ -91,70 +280,178 @@ module.exports = function registerSubscribeNode(RED) {
91
280
  discardOldest: config.discardOldest !== false,
92
281
  deadbandType: config.deadbandType || 'none',
93
282
  deadbandValue: Number(config.deadbandValue) || 0,
283
+ timestampMode: config.timestampMode || 'source',
94
284
  },
95
285
  batchSize
96
286
  );
97
287
  results.forEach((result) => {
98
- if (result.statusCode === 'Good') node.monitored.set(result.nodeId, result.monitoredItemId);
288
+ result.subscriptionId = record.id;
289
+ if (result.statusCode !== 'Good') return;
290
+ const monitored = {
291
+ subscriptionId: record.id,
292
+ monitoredItemId: result.monitoredItemId,
293
+ clientHandle: result.clientHandle,
294
+ };
295
+ record.monitored.set(result.nodeId, monitored);
296
+ node.monitored.set(result.nodeId, monitored);
99
297
  });
100
298
  return results;
101
299
  }
102
300
 
103
- async function reconcileSubscription(reason = 'state') {
104
- if (node.closing) return;
105
- if (!node.desired.size) {
106
- if (node.subscriptionId != null) await clearSubscription(true);
107
- return;
108
- }
301
+ function findAvailableSubscription() {
302
+ return [...node.subscriptions.values()].find(
303
+ (record) => record.monitored.size < record.limit
304
+ );
305
+ }
109
306
 
110
- let rebuilt = false;
111
- if (node.subscriptionId != null) {
112
- const validation = await node.connection.client.validateSubscription(node.subscriptionId);
113
- if (!validation.valid) {
114
- try {
115
- await node.connection.client.deleteSubscription(node.subscriptionId);
116
- } catch {}
117
- node.subscriptionId = null;
118
- node.monitored.clear();
119
- rebuilt = true;
307
+ async function addDesiredPoints(points) {
308
+ const remaining = [...points];
309
+ const failures = [];
310
+ while (remaining.length) {
311
+ const record = findAvailableSubscription() || await createSubscription();
312
+ const room = Math.max(0, record.limit - record.monitored.size);
313
+ if (!room) continue;
314
+ const candidates = remaining.splice(0, room);
315
+ const byNodeId = new Map(candidates.map((point) => [point.nodeId, point]));
316
+ const results = await addMonitoredItems(record, candidates);
317
+ const returnedNodeIds = new Set(results.map((result) => result.nodeId));
318
+ for (const point of candidates) {
319
+ if (!returnedNodeIds.has(point.nodeId)) {
320
+ failures.push({ nodeId: point.nodeId, statusCode: 'BadUnexpectedError' });
321
+ }
322
+ }
323
+ const capacityFailures = [];
324
+ results.forEach((result) => {
325
+ if (result.statusCode === 'Good') return;
326
+ const point = byNodeId.get(result.nodeId);
327
+ if (/TooManyMonitoredItems/i.test(result.statusCode) && point) {
328
+ capacityFailures.push(point);
329
+ } else failures.push(result);
330
+ });
331
+ if (capacityFailures.length) {
332
+ if (record.monitored.size === 0) {
333
+ failures.push(...capacityFailures.map((point) => ({
334
+ nodeId: point.nodeId,
335
+ statusCode: 'BadTooManyMonitoredItems',
336
+ })));
337
+ } else {
338
+ record.limit = record.monitored.size;
339
+ remaining.unshift(...capacityFailures);
340
+ }
120
341
  }
121
342
  }
343
+ return failures;
344
+ }
122
345
 
123
- await ensureSubscription();
124
- const removals = [];
125
- for (const [nodeId, monitoredItemId] of node.monitored) {
126
- if (!node.desired.has(nodeId)) removals.push({ nodeId, monitoredItemId });
346
+ async function removeUndesiredPoints() {
347
+ const grouped = new Map();
348
+ for (const [nodeId, monitored] of node.monitored) {
349
+ if (node.desired.has(nodeId)) continue;
350
+ if (!grouped.has(monitored.subscriptionId)) grouped.set(monitored.subscriptionId, []);
351
+ grouped.get(monitored.subscriptionId).push({ nodeId, ...monitored });
127
352
  }
128
- if (removals.length) {
353
+ for (const [subscriptionId, removals] of grouped) {
354
+ const record = node.subscriptions.get(subscriptionId);
355
+ if (!record) continue;
129
356
  const results = await node.connection.client.removeMonitoredItems(
130
- node.subscriptionId,
357
+ subscriptionId,
131
358
  removals.map((item) => item.monitoredItemId),
132
359
  batchSize
133
360
  );
134
361
  results.forEach((result, index) => {
135
- if (result.statusCode === 'Good') node.monitored.delete(removals[index].nodeId);
362
+ if (result.statusCode !== 'Good') return;
363
+ const nodeId = removals[index].nodeId;
364
+ record.monitored.delete(nodeId);
365
+ node.monitored.delete(nodeId);
136
366
  });
137
367
  }
368
+ }
369
+
370
+ async function pruneEmptySubscriptions() {
371
+ const empty = [...node.subscriptions.values()].filter(
372
+ (record) => record.monitored.size === 0
373
+ );
374
+ for (const record of empty) {
375
+ if (node.subscriptions.size <= 1 && node.desired.size > 0) break;
376
+ node.subscriptions.delete(record.id);
377
+ node.subscriptionDiagnostics.delete(record.id);
378
+ await node.connection.client.deleteSubscription(record.id);
379
+ }
380
+ }
381
+
382
+ async function clearSubscriptions(keepDesired = false, ignoreErrors = false) {
383
+ if (!keepDesired) node.desired.clear();
384
+ discardPendingChanges();
385
+ const records = [...node.subscriptions.values()];
386
+ node.subscriptions.clear();
387
+ node.monitored.clear();
388
+ node.subscriptionDiagnostics.clear();
389
+ const results = await Promise.allSettled(
390
+ records.map((record) => node.connection.client.deleteSubscription(record.id))
391
+ );
392
+ if (!ignoreErrors) {
393
+ const rejected = results.find((result) => result.status === 'rejected');
394
+ if (rejected) throw rejected.reason;
395
+ }
396
+ }
397
+
398
+ async function validateSubscriptions() {
399
+ for (const record of node.subscriptions.values()) {
400
+ const validation = await node.connection.client.validateSubscription(record.id);
401
+ if (!validation.valid) return false;
402
+ }
403
+ return true;
404
+ }
405
+
406
+ function statusPayload(command, failed = 0) {
407
+ const ids = subscriptionIds();
408
+ return {
409
+ command,
410
+ desired: node.desired.size,
411
+ monitored: node.monitored.size,
412
+ failed,
413
+ subscriptionCount: ids.length,
414
+ subscriptionId: ids[0] ?? null,
415
+ subscriptionIds: ids,
416
+ connectionGovernance: node.connection?.getConnectionGovernance?.(),
417
+ };
418
+ }
419
+
420
+ async function reconcileSubscriptions(reason = 'state') {
421
+ if (node.closing) return;
422
+ if (!node.desired.size) {
423
+ if (node.subscriptions.size) await clearSubscriptions(true);
424
+ return;
425
+ }
138
426
 
427
+ let rebuilt = false;
428
+ if (node.subscriptions.size) {
429
+ let valid = false;
430
+ try { valid = await validateSubscriptions(); } catch {}
431
+ if (!valid) {
432
+ await clearSubscriptions(true, true);
433
+ rebuilt = true;
434
+ }
435
+ }
436
+
437
+ await removeUndesiredPoints();
139
438
  const additions = [...node.desired.values()].filter(
140
439
  (point) => !node.monitored.has(point.nodeId)
141
440
  );
142
- const results = await createMonitoredItems(additions);
143
- const failed = results.filter((result) => result.statusCode !== 'Good');
144
- if (failed.length) throw new Error(`${failed.length} monitored item(s) failed to recover`);
441
+ const failures = await addDesiredPoints(additions);
442
+ await pruneEmptySubscriptions();
443
+ if (failures.length) {
444
+ throw new Error(`${failures.length} monitored item(s) failed to reconcile`);
445
+ }
145
446
 
146
447
  node.reconcileAttempt = 0;
147
- node.status({ fill: 'green', shape: 'dot', text: `${node.monitored.size} monitored` });
448
+ node.status({
449
+ fill: 'green', shape: 'dot',
450
+ text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub`,
451
+ });
148
452
  if (reason !== 'command') {
149
453
  node.send({
150
- payload: {
151
- command: 'recover',
152
- desired: node.desired.size,
153
- monitored: node.monitored.size,
154
- subscriptionId: node.subscriptionId,
155
- rebuilt,
156
- reason,
157
- },
454
+ payload: { ...statusPayload('recover'), rebuilt, reason },
158
455
  opcua: {
159
456
  operation: 'subscription-status',
160
457
  recovered: true,
@@ -167,19 +464,10 @@ module.exports = function registerSubscribeNode(RED) {
167
464
  }
168
465
  }
169
466
 
170
- async function clearSubscription(keepDesired = false) {
171
- if (!keepDesired) node.desired.clear();
172
- if (node.subscriptionId != null) {
173
- await node.connection.client.deleteSubscription(node.subscriptionId);
174
- }
175
- node.subscriptionId = null;
176
- node.monitored.clear();
177
- }
178
-
179
467
  async function replacePoints(points) {
180
468
  node.desired = new Map(points.map((point) => [point.nodeId, point]));
181
- await clearSubscription(true);
182
- await reconcileSubscription('command');
469
+ await clearSubscriptions(true);
470
+ await reconcileSubscriptions('command');
183
471
  }
184
472
 
185
473
  function runSerialized(task) {
@@ -193,17 +481,17 @@ module.exports = function registerSubscribeNode(RED) {
193
481
  const base = Math.max(100, Number(config.reconnectInterval) || 5000);
194
482
  const delay = immediate ? 0 : Math.min(30000, base * (2 ** node.reconcileAttempt));
195
483
  node.reconcileAttempt += 1;
196
- node.lastRecoveryReason = reason;
197
484
  node.reconnectTimer = setTimeout(async () => {
198
485
  node.reconnectTimer = null;
199
486
  try {
200
- await runSerialized(() => reconcileSubscription(reason));
487
+ await runSerialized(() => reconcileSubscriptions(reason));
201
488
  } catch (error) {
202
489
  node.warn(`OPC UA resubscribe failed: ${error.message}`);
203
490
  node.status({ fill: 'yellow', shape: 'ring', text: 'waiting to recover' });
204
491
  scheduleReconnect(reason);
205
492
  }
206
493
  }, delay);
494
+ node.reconnectTimer.unref?.();
207
495
  }
208
496
 
209
497
  const onFault = () => scheduleReconnect('service-fault');
@@ -215,7 +503,7 @@ module.exports = function registerSubscribeNode(RED) {
215
503
  if (state.state === 'connected') {
216
504
  scheduleReconnect(state.reason || 'connected', true);
217
505
  } else if (state.state === 'subscription-inactive' &&
218
- (!state.subscriptionId || state.subscriptionId === node.subscriptionId)) {
506
+ (!state.subscriptionId || node.subscriptions.has(state.subscriptionId))) {
219
507
  node.status({ fill: 'yellow', shape: 'ring', text: 'subscription inactive' });
220
508
  scheduleReconnect('subscription-inactive');
221
509
  } else if (['reconnecting', 'backoff', 'failed'].includes(state.state)) {
@@ -240,23 +528,21 @@ module.exports = function registerSubscribeNode(RED) {
240
528
  if (command === 'replace') await replacePoints(points);
241
529
  else if (command === 'add') {
242
530
  points.forEach((point) => node.desired.set(point.nodeId, point));
243
- await reconcileSubscription('command');
531
+ await reconcileSubscriptions('command');
244
532
  } else if (command === 'remove') {
245
533
  points.forEach((point) => node.desired.delete(point.nodeId));
246
- await reconcileSubscription('command');
247
- } else if (command === 'clear') await clearSubscription();
534
+ await reconcileSubscriptions('command');
535
+ } else if (command === 'clear') await clearSubscriptions();
248
536
  else throw new Error(`Unsupported subscription command: ${command}`);
249
537
  });
250
538
 
251
- node.status({ fill: 'green', shape: 'dot', text: `${node.monitored.size} monitored` });
539
+ node.status({
540
+ fill: 'green', shape: 'dot',
541
+ text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub`,
542
+ });
252
543
  send({
253
544
  ...msg,
254
- payload: {
255
- command,
256
- desired: node.desired.size,
257
- monitored: node.monitored.size,
258
- subscriptionId: node.subscriptionId,
259
- },
545
+ payload: statusPayload(command),
260
546
  opcua: { operation: 'subscription-status' },
261
547
  });
262
548
  done();
@@ -269,11 +555,12 @@ module.exports = function registerSubscribeNode(RED) {
269
555
  node.on('close', async (done) => {
270
556
  node.closing = true;
271
557
  if (node.reconnectTimer) clearTimeout(node.reconnectTimer);
558
+ discardPendingChanges();
272
559
  node.connection?.client.off('fault', onFault);
273
560
  node.connection?.client.off('state', onState);
274
561
  node.desired.clear();
275
562
  try {
276
- await clearSubscription();
563
+ await runSerialized(() => clearSubscriptions());
277
564
  } catch (error) {
278
565
  node.warn(`OPC UA subscription cleanup failed: ${error.message}`);
279
566
  }
package/nodes/write.html CHANGED
@@ -39,14 +39,40 @@
39
39
  <details class="opcua-advanced">
40
40
  <summary>Advanced settings</summary>
41
41
  <div class="form-row"><label for="node-input-dataType">Default type</label><select id="node-input-dataType"><option>String</option><option>Boolean</option><option>SByte</option><option>Byte</option><option>Int16</option><option>UInt16</option><option>Int32</option><option>UInt32</option><option>Int64</option><option>UInt64</option><option>Float</option><option>Double</option><option>DateTime</option><option>ByteString</option></select></div>
42
+ <div class="form-row"><label for="node-input-indexRange">Index range</label><input type="text" id="node-input-indexRange" placeholder="For example 0 or 2:5"></div>
42
43
  <div class="form-row"><label for="node-input-batchSize">Batch size</label><input type="number" id="node-input-batchSize"></div>
43
44
  <div class="form-row"><label for="node-input-outputMode">Output</label><select id="node-input-outputMode"><option value="batch">Batch</option><option value="split">One message per point</option></select></div>
44
45
  </details>
45
46
  <div class="form-row"><label for="node-input-name">Name</label><input type="text" id="node-input-name"></div>
46
47
  </script>
47
48
  <script type="text/html" data-help-name="tier0-opcua-write">
48
- <p>Add NodeId, value, and data type rows or import JSON/CSV. Imported points are parsed and shown in the same list.</p>
49
- <p>CSV requires <code>nodeId</code> and <code>value</code>; <code>dataType</code> is optional. A JSON/CSV point list may also arrive dynamically in <code>msg.payload</code>.</p>
49
+ <section lang="zh-CN">
50
+ <h3>写入(Write)</h3>
51
+ <p>向一个或多个 OPC UA Variable 写值。服务器必须允许当前用户写入,值也必须能够转换为目标数据类型。</p>
52
+ <h4>输入</h4>
53
+ <ul>
54
+ <li>单点:NodeId 来自节点配置、<code>msg.nodeId</code> 或 <code>msg.topic</code>,写入值来自 <code>msg.payload</code>。</li>
55
+ <li>批量:通过编辑器、JSON 或 CSV 提供 <code>nodeId</code>、<code>value</code> 和可选 <code>dataType</code>。</li>
56
+ <li>CSV 必须包含 <code>nodeId</code> 和 <code>value</code>;未指定类型时使用节点默认类型。</li>
57
+ <li><code>indexRange</code> 可部分写入数组或矩阵。</li>
58
+ </ul>
59
+ <h4>输出</h4>
60
+ <p>Batch 模式在 <code>msg.payload</code> 中返回每个 NodeId 的 StatusCode;Split 模式每个写入结果发送一条消息。应检查是否为 <code>Good</code>,不要只依赖节点未报异常。</p>
61
+ </section>
62
+ <hr>
63
+ <section lang="en">
64
+ <h3>Write</h3>
65
+ <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>
66
+ <h4>Input</h4>
67
+ <ul>
68
+ <li>Single point: NodeId comes from the node setting, <code>msg.nodeId</code>, or <code>msg.topic</code>; the value comes from <code>msg.payload</code>.</li>
69
+ <li>Batch: Supply <code>nodeId</code>, <code>value</code>, and optional <code>dataType</code> through the editor, JSON, or CSV.</li>
70
+ <li>CSV requires <code>nodeId</code> and <code>value</code>. The node default type is used when <code>dataType</code> is omitted.</li>
71
+ <li>Use <code>indexRange</code> for partial array or matrix writes.</li>
72
+ </ul>
73
+ <h4>Output</h4>
74
+ <p>Batch mode returns one StatusCode per NodeId in <code>msg.payload</code>. Split mode sends one message per write result. Check for <code>Good</code>; do not treat the absence of a node error as proof that every item succeeded.</p>
75
+ </section>
50
76
  </script>
51
77
  <script type="text/javascript">
52
78
  RED.nodes.registerType('tier0-opcua-write', {
@@ -54,6 +80,7 @@
54
80
  defaults: {
55
81
  connection: { value: '', type: 'tier0-opcua-connection', required: true },
56
82
  nodeId: { value: '' }, dataType: { value: 'String' },
83
+ indexRange: { value: '' },
57
84
  pointList: { value: '' }, pointListFileName: { value: '' },
58
85
  batchSize: { value: 500, validate: RED.validators.number() },
59
86
  outputMode: { value: 'batch' }, name: { value: '' }