@tier0/opcua-client 0.1.0 → 0.2.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 (45) hide show
  1. package/BENCHMARK.md +53 -2
  2. package/README.md +131 -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/native.js +25 -10
  7. package/lib/points.js +187 -21
  8. package/lib/prebuild.js +42 -0
  9. package/lib/security.js +96 -0
  10. package/node_modules/node-addon-api/LICENSE.md +9 -0
  11. package/node_modules/node-addon-api/README.md +95 -0
  12. package/node_modules/node-addon-api/common.gypi +21 -0
  13. package/node_modules/node-addon-api/except.gypi +25 -0
  14. package/node_modules/node-addon-api/index.js +14 -0
  15. package/node_modules/node-addon-api/napi-inl.deprecated.h +186 -0
  16. package/node_modules/node-addon-api/napi-inl.h +7244 -0
  17. package/node_modules/node-addon-api/napi.h +3420 -0
  18. package/node_modules/node-addon-api/node_addon_api.gyp +42 -0
  19. package/node_modules/node-addon-api/node_api.gyp +9 -0
  20. package/node_modules/node-addon-api/noexcept.gypi +26 -0
  21. package/node_modules/node-addon-api/nothing.c +0 -0
  22. package/node_modules/node-addon-api/package-support.json +21 -0
  23. package/node_modules/node-addon-api/package.json +481 -0
  24. package/node_modules/node-addon-api/tools/README.md +73 -0
  25. package/node_modules/node-addon-api/tools/check-napi.js +99 -0
  26. package/node_modules/node-addon-api/tools/clang-format.js +71 -0
  27. package/node_modules/node-addon-api/tools/conversion.js +301 -0
  28. package/nodes/connection.html +165 -6
  29. package/nodes/connection.js +99 -20
  30. package/nodes/read.html +3 -0
  31. package/nodes/read.js +3 -1
  32. package/nodes/subscribe.html +9 -3
  33. package/nodes/subscribe.js +330 -99
  34. package/nodes/write.html +3 -0
  35. package/package.json +15 -4
  36. package/prebuilds/linux-x64-musl/opcua_open62541.node +0 -0
  37. package/scripts/build-offline-prebuild.sh +15 -0
  38. package/scripts/build-open62541.sh +14 -4
  39. package/scripts/build-test-server.sh +23 -0
  40. package/scripts/install.js +28 -0
  41. package/scripts/verify-offline-package.js +20 -0
  42. package/scripts/verify-runtime.js +20 -0
  43. package/src/addon.cc +707 -119
  44. package/vendor/open62541-LICENSE +373 -0
  45. package/vendor/open62541-v1.4.16.tar.gz +0 -0
@@ -2,88 +2,222 @@
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
+ function positiveInteger(value, fallback) {
16
+ const number = Number(value);
17
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
18
+ }
19
+
5
20
  module.exports = function registerSubscribeNode(RED) {
6
21
  function OpcUaSubscribeNode(config) {
7
22
  RED.nodes.createNode(this, config);
8
23
  const node = this;
9
24
  node.connection = RED.nodes.getNode(config.connection);
10
- node.subscriptionId = null;
25
+ node.subscriptions = new Map();
11
26
  node.desired = new Map();
12
27
  node.monitored = new Map();
28
+ node.subscriptionDiagnostics = new Map();
29
+ node.pendingChanges = [];
30
+ node.flushTimer = null;
13
31
  node.reconnectTimer = null;
14
32
  node.reconcileChain = Promise.resolve();
15
33
  node.reconcileAttempt = 0;
16
34
  node.lastSessionGeneration = 0;
17
- node.lastRecoveryReason = '';
18
35
  node.closing = false;
19
- node.lastDiagnostics = {};
20
36
  node.lastDropped = 0;
21
37
  node.lastStatusAt = 0;
22
38
 
23
- const batchSize = Number(config.batchSize) || 500;
39
+ const batchSize = positiveInteger(config.batchSize, 500);
40
+ const callbackBatchSize = positiveInteger(config.callbackBatchSize, 500);
41
+ const callbackFlushIntervalMs = positiveInteger(config.callbackFlushIntervalMs, 50);
42
+ const configuredSubscriptionCapacity = positiveInteger(config.maxItemsPerSubscription, 5000);
24
43
 
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
- }
44
+ function subscriptionIds() {
45
+ return [...node.subscriptions.keys()];
46
+ }
47
+
48
+ function primarySubscriptionId() {
49
+ return subscriptionIds()[0] ?? null;
50
+ }
51
+
52
+ function subscriptionCapacity() {
53
+ const diagnostics = node.connection?.client.getDiagnostics?.() || {};
54
+ const serverLimit = Number(
55
+ diagnostics.operationLimits?.maxMonitoredItemsPerSubscription
56
+ );
57
+ return Number.isFinite(serverLimit) && serverLimit > 0
58
+ ? Math.min(configuredSubscriptionCapacity, Math.floor(serverLimit))
59
+ : configuredSubscriptionCapacity;
60
+ }
61
+
62
+ function combinedDiagnostics() {
63
+ const output = {
64
+ queueDepth: 0,
65
+ queueCapacity: 0,
66
+ maxQueueDepth: 0,
67
+ enqueued: 0,
68
+ delivered: 0,
69
+ dropped: 0,
70
+ coalesced: 0,
71
+ batches: 0,
72
+ maxLagMs: 0,
73
+ subscriptionCount: node.subscriptions.size,
74
+ mode: config.queueMode || 'all',
75
+ overflowPolicy: config.overflowPolicy || 'drop-oldest',
76
+ };
77
+ for (const diagnostics of node.subscriptionDiagnostics.values()) {
78
+ output.queueDepth += Number(diagnostics.queueDepth) || 0;
79
+ output.queueCapacity += Number(diagnostics.queueCapacity) || 0;
80
+ output.maxQueueDepth += Number(diagnostics.maxQueueDepth) || 0;
81
+ output.enqueued += Number(diagnostics.enqueued) || 0;
82
+ output.delivered += Number(diagnostics.delivered) || 0;
83
+ output.dropped += Number(diagnostics.dropped) || 0;
84
+ output.coalesced += Number(diagnostics.coalesced) || 0;
85
+ output.batches += Number(diagnostics.batchSequence) || 0;
86
+ output.maxLagMs = Math.max(output.maxLagMs, Number(diagnostics.maxLagMs) || 0);
34
87
  }
35
- node.lastDiagnostics = diagnostics;
88
+ return output;
89
+ }
90
+
91
+ function updateQueueWarning(diagnostics) {
36
92
  const dropped = Number(diagnostics.dropped) || 0;
37
93
  if (dropped > node.lastDropped) {
38
94
  node.warn(`OPC UA notification queue dropped ${dropped - node.lastDropped} change(s); total ${dropped}`);
39
95
  }
40
96
  node.lastDropped = dropped;
97
+ }
41
98
 
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
- }));
99
+ function sendBatch(changes) {
100
+ let accepted = 0;
101
+ for (let index = 0; index < changes.length; index += 1) {
102
+ if (node.desired.has(changes[index].nodeId)) {
103
+ changes[accepted] = changes[index];
104
+ accepted += 1;
54
105
  }
55
106
  }
107
+ changes.length = accepted;
108
+ if (!changes.length || node.closing) return;
109
+ const diagnostics = combinedDiagnostics();
110
+ updateQueueWarning(diagnostics);
111
+ node.send({
112
+ payload: changes,
113
+ opcua: {
114
+ operation: 'change',
115
+ total: changes.length,
116
+ subscriptionCount: node.subscriptions.size,
117
+ queue: diagnostics,
118
+ },
119
+ });
120
+ }
121
+
122
+ function clearFlushTimer() {
123
+ if (node.flushTimer) clearTimeout(node.flushTimer);
124
+ node.flushTimer = null;
125
+ }
126
+
127
+ function scheduleFlush() {
128
+ if (node.flushTimer || !node.pendingChanges.length || node.closing) return;
129
+ node.flushTimer = setTimeout(() => {
130
+ node.flushTimer = null;
131
+ while (node.pendingChanges.length) {
132
+ sendBatch(node.pendingChanges.splice(0, callbackBatchSize));
133
+ }
134
+ }, callbackFlushIntervalMs);
135
+ node.flushTimer.unref?.();
136
+ }
137
+
138
+ function flushFullBatches() {
139
+ while (node.pendingChanges.length >= callbackBatchSize) {
140
+ sendBatch(node.pendingChanges.splice(0, callbackBatchSize));
141
+ }
142
+ if (!node.pendingChanges.length) clearFlushTimer();
143
+ else scheduleFlush();
144
+ }
145
+
146
+ function discardPendingChanges() {
147
+ clearFlushTimer();
148
+ node.pendingChanges.length = 0;
149
+ }
150
+
151
+ function onNativeBatch(record, batch) {
152
+ if (node.closing || !record.id || !node.subscriptions.has(record.id)) return;
153
+ const diagnostics = batch.diagnostics || {};
154
+ node.subscriptionDiagnostics.set(record.id, diagnostics);
155
+ const sourceChanges = batch.changes || [];
156
+ const changes = [];
157
+ for (let index = 0; index < sourceChanges.length; index += 1) {
158
+ const change = sourceChanges[index];
159
+ const point = node.desired.get(change.nodeId);
160
+ if (!point) continue;
161
+ change.subscriptionId = record.id;
162
+ for (const key in point) {
163
+ if (!NOTIFICATION_CONFIG_FIELDS.has(key) && !Object.hasOwn(change, key)) {
164
+ change[key] = point[key];
165
+ }
166
+ }
167
+ changes.push(change);
168
+ }
169
+
170
+ if ((config.outputMode || 'batch') === 'single') {
171
+ const queue = combinedDiagnostics();
172
+ updateQueueWarning(queue);
173
+ changes.forEach((change, index) => node.send({
174
+ topic: change.nodeId,
175
+ payload: change.value,
176
+ opcua: index === 0 ? { ...change, queue } : change,
177
+ }));
178
+ } else if (changes.length) {
179
+ node.pendingChanges.push(...changes);
180
+ flushFullBatches();
181
+ scheduleFlush();
182
+ }
56
183
 
57
184
  const now = Date.now();
58
- if (dropped > 0 || now - node.lastStatusAt >= 1000) {
185
+ const queue = combinedDiagnostics();
186
+ if (queue.dropped > 0 || now - node.lastStatusAt >= 1000) {
59
187
  node.lastStatusAt = now;
60
188
  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}`,
189
+ fill: queue.dropped > 0 ? 'yellow' : 'green',
190
+ shape: queue.dropped > 0 ? 'ring' : 'dot',
191
+ text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub q:${queue.queueDepth}/${queue.queueCapacity}`,
64
192
  });
65
193
  }
66
194
  }
67
195
 
68
- async function ensureSubscription() {
69
- if (node.subscriptionId != null) return;
196
+ async function createSubscription() {
197
+ const record = {
198
+ id: null,
199
+ monitored: new Map(),
200
+ limit: subscriptionCapacity(),
201
+ };
70
202
  const result = await node.connection.client.createSubscription({
71
203
  publishingInterval: Number(config.publishingInterval) || 1000,
72
204
  lifetimeCount: Number(config.lifetimeCount) || 120,
73
205
  maxKeepAliveCount: Number(config.maxKeepAliveCount) || 10,
74
206
  maxNotificationsPerPublish: Number(config.maxNotificationsPerPublish) || 0,
75
- callbackQueueSize: Number(config.callbackQueueSize) || 10000,
76
- callbackBatchSize: Number(config.callbackBatchSize) || 500,
207
+ callbackQueueSize: positiveInteger(config.callbackQueueSize, 10000),
208
+ callbackBatchSize,
77
209
  queueMode: config.queueMode || 'all',
78
210
  overflowPolicy: config.overflowPolicy || 'drop-oldest',
79
- }, emitBatch);
80
- node.subscriptionId = result.subscriptionId;
211
+ }, (batch) => onNativeBatch(record, batch));
212
+ record.id = result.subscriptionId;
213
+ node.subscriptions.set(record.id, record);
214
+ return record;
81
215
  }
82
216
 
83
- async function createMonitoredItems(points) {
217
+ async function addMonitoredItems(record, points) {
84
218
  if (!points.length) return [];
85
219
  const results = await node.connection.client.addMonitoredItems(
86
- node.subscriptionId,
220
+ record.id,
87
221
  points,
88
222
  {
89
223
  samplingInterval: Number(config.samplingInterval) || 1000,
@@ -91,70 +225,177 @@ module.exports = function registerSubscribeNode(RED) {
91
225
  discardOldest: config.discardOldest !== false,
92
226
  deadbandType: config.deadbandType || 'none',
93
227
  deadbandValue: Number(config.deadbandValue) || 0,
228
+ timestampMode: config.timestampMode || 'source',
94
229
  },
95
230
  batchSize
96
231
  );
97
232
  results.forEach((result) => {
98
- if (result.statusCode === 'Good') node.monitored.set(result.nodeId, result.monitoredItemId);
233
+ result.subscriptionId = record.id;
234
+ if (result.statusCode !== 'Good') return;
235
+ const monitored = {
236
+ subscriptionId: record.id,
237
+ monitoredItemId: result.monitoredItemId,
238
+ clientHandle: result.clientHandle,
239
+ };
240
+ record.monitored.set(result.nodeId, monitored);
241
+ node.monitored.set(result.nodeId, monitored);
99
242
  });
100
243
  return results;
101
244
  }
102
245
 
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
- }
246
+ function findAvailableSubscription() {
247
+ return [...node.subscriptions.values()].find(
248
+ (record) => record.monitored.size < record.limit
249
+ );
250
+ }
109
251
 
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;
252
+ async function addDesiredPoints(points) {
253
+ const remaining = [...points];
254
+ const failures = [];
255
+ while (remaining.length) {
256
+ const record = findAvailableSubscription() || await createSubscription();
257
+ const room = Math.max(0, record.limit - record.monitored.size);
258
+ if (!room) continue;
259
+ const candidates = remaining.splice(0, room);
260
+ const byNodeId = new Map(candidates.map((point) => [point.nodeId, point]));
261
+ const results = await addMonitoredItems(record, candidates);
262
+ const returnedNodeIds = new Set(results.map((result) => result.nodeId));
263
+ for (const point of candidates) {
264
+ if (!returnedNodeIds.has(point.nodeId)) {
265
+ failures.push({ nodeId: point.nodeId, statusCode: 'BadUnexpectedError' });
266
+ }
267
+ }
268
+ const capacityFailures = [];
269
+ results.forEach((result) => {
270
+ if (result.statusCode === 'Good') return;
271
+ const point = byNodeId.get(result.nodeId);
272
+ if (/TooManyMonitoredItems/i.test(result.statusCode) && point) {
273
+ capacityFailures.push(point);
274
+ } else failures.push(result);
275
+ });
276
+ if (capacityFailures.length) {
277
+ if (record.monitored.size === 0) {
278
+ failures.push(...capacityFailures.map((point) => ({
279
+ nodeId: point.nodeId,
280
+ statusCode: 'BadTooManyMonitoredItems',
281
+ })));
282
+ } else {
283
+ record.limit = record.monitored.size;
284
+ remaining.unshift(...capacityFailures);
285
+ }
120
286
  }
121
287
  }
288
+ return failures;
289
+ }
122
290
 
123
- await ensureSubscription();
124
- const removals = [];
125
- for (const [nodeId, monitoredItemId] of node.monitored) {
126
- if (!node.desired.has(nodeId)) removals.push({ nodeId, monitoredItemId });
291
+ async function removeUndesiredPoints() {
292
+ const grouped = new Map();
293
+ for (const [nodeId, monitored] of node.monitored) {
294
+ if (node.desired.has(nodeId)) continue;
295
+ if (!grouped.has(monitored.subscriptionId)) grouped.set(monitored.subscriptionId, []);
296
+ grouped.get(monitored.subscriptionId).push({ nodeId, ...monitored });
127
297
  }
128
- if (removals.length) {
298
+ for (const [subscriptionId, removals] of grouped) {
299
+ const record = node.subscriptions.get(subscriptionId);
300
+ if (!record) continue;
129
301
  const results = await node.connection.client.removeMonitoredItems(
130
- node.subscriptionId,
302
+ subscriptionId,
131
303
  removals.map((item) => item.monitoredItemId),
132
304
  batchSize
133
305
  );
134
306
  results.forEach((result, index) => {
135
- if (result.statusCode === 'Good') node.monitored.delete(removals[index].nodeId);
307
+ if (result.statusCode !== 'Good') return;
308
+ const nodeId = removals[index].nodeId;
309
+ record.monitored.delete(nodeId);
310
+ node.monitored.delete(nodeId);
136
311
  });
137
312
  }
313
+ }
314
+
315
+ async function pruneEmptySubscriptions() {
316
+ const empty = [...node.subscriptions.values()].filter(
317
+ (record) => record.monitored.size === 0
318
+ );
319
+ for (const record of empty) {
320
+ if (node.subscriptions.size <= 1 && node.desired.size > 0) break;
321
+ node.subscriptions.delete(record.id);
322
+ node.subscriptionDiagnostics.delete(record.id);
323
+ await node.connection.client.deleteSubscription(record.id);
324
+ }
325
+ }
138
326
 
327
+ async function clearSubscriptions(keepDesired = false, ignoreErrors = false) {
328
+ if (!keepDesired) node.desired.clear();
329
+ discardPendingChanges();
330
+ const records = [...node.subscriptions.values()];
331
+ node.subscriptions.clear();
332
+ node.monitored.clear();
333
+ node.subscriptionDiagnostics.clear();
334
+ const results = await Promise.allSettled(
335
+ records.map((record) => node.connection.client.deleteSubscription(record.id))
336
+ );
337
+ if (!ignoreErrors) {
338
+ const rejected = results.find((result) => result.status === 'rejected');
339
+ if (rejected) throw rejected.reason;
340
+ }
341
+ }
342
+
343
+ async function validateSubscriptions() {
344
+ for (const record of node.subscriptions.values()) {
345
+ const validation = await node.connection.client.validateSubscription(record.id);
346
+ if (!validation.valid) return false;
347
+ }
348
+ return true;
349
+ }
350
+
351
+ function statusPayload(command, failed = 0) {
352
+ const ids = subscriptionIds();
353
+ return {
354
+ command,
355
+ desired: node.desired.size,
356
+ monitored: node.monitored.size,
357
+ failed,
358
+ subscriptionCount: ids.length,
359
+ subscriptionId: ids[0] ?? null,
360
+ subscriptionIds: ids,
361
+ };
362
+ }
363
+
364
+ async function reconcileSubscriptions(reason = 'state') {
365
+ if (node.closing) return;
366
+ if (!node.desired.size) {
367
+ if (node.subscriptions.size) await clearSubscriptions(true);
368
+ return;
369
+ }
370
+
371
+ let rebuilt = false;
372
+ if (node.subscriptions.size) {
373
+ let valid = false;
374
+ try { valid = await validateSubscriptions(); } catch {}
375
+ if (!valid) {
376
+ await clearSubscriptions(true, true);
377
+ rebuilt = true;
378
+ }
379
+ }
380
+
381
+ await removeUndesiredPoints();
139
382
  const additions = [...node.desired.values()].filter(
140
383
  (point) => !node.monitored.has(point.nodeId)
141
384
  );
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`);
385
+ const failures = await addDesiredPoints(additions);
386
+ await pruneEmptySubscriptions();
387
+ if (failures.length) {
388
+ throw new Error(`${failures.length} monitored item(s) failed to reconcile`);
389
+ }
145
390
 
146
391
  node.reconcileAttempt = 0;
147
- node.status({ fill: 'green', shape: 'dot', text: `${node.monitored.size} monitored` });
392
+ node.status({
393
+ fill: 'green', shape: 'dot',
394
+ text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub`,
395
+ });
148
396
  if (reason !== 'command') {
149
397
  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
- },
398
+ payload: { ...statusPayload('recover'), rebuilt, reason },
158
399
  opcua: {
159
400
  operation: 'subscription-status',
160
401
  recovered: true,
@@ -167,19 +408,10 @@ module.exports = function registerSubscribeNode(RED) {
167
408
  }
168
409
  }
169
410
 
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
411
  async function replacePoints(points) {
180
412
  node.desired = new Map(points.map((point) => [point.nodeId, point]));
181
- await clearSubscription(true);
182
- await reconcileSubscription('command');
413
+ await clearSubscriptions(true);
414
+ await reconcileSubscriptions('command');
183
415
  }
184
416
 
185
417
  function runSerialized(task) {
@@ -193,17 +425,17 @@ module.exports = function registerSubscribeNode(RED) {
193
425
  const base = Math.max(100, Number(config.reconnectInterval) || 5000);
194
426
  const delay = immediate ? 0 : Math.min(30000, base * (2 ** node.reconcileAttempt));
195
427
  node.reconcileAttempt += 1;
196
- node.lastRecoveryReason = reason;
197
428
  node.reconnectTimer = setTimeout(async () => {
198
429
  node.reconnectTimer = null;
199
430
  try {
200
- await runSerialized(() => reconcileSubscription(reason));
431
+ await runSerialized(() => reconcileSubscriptions(reason));
201
432
  } catch (error) {
202
433
  node.warn(`OPC UA resubscribe failed: ${error.message}`);
203
434
  node.status({ fill: 'yellow', shape: 'ring', text: 'waiting to recover' });
204
435
  scheduleReconnect(reason);
205
436
  }
206
437
  }, delay);
438
+ node.reconnectTimer.unref?.();
207
439
  }
208
440
 
209
441
  const onFault = () => scheduleReconnect('service-fault');
@@ -215,7 +447,7 @@ module.exports = function registerSubscribeNode(RED) {
215
447
  if (state.state === 'connected') {
216
448
  scheduleReconnect(state.reason || 'connected', true);
217
449
  } else if (state.state === 'subscription-inactive' &&
218
- (!state.subscriptionId || state.subscriptionId === node.subscriptionId)) {
450
+ (!state.subscriptionId || node.subscriptions.has(state.subscriptionId))) {
219
451
  node.status({ fill: 'yellow', shape: 'ring', text: 'subscription inactive' });
220
452
  scheduleReconnect('subscription-inactive');
221
453
  } else if (['reconnecting', 'backoff', 'failed'].includes(state.state)) {
@@ -240,23 +472,21 @@ module.exports = function registerSubscribeNode(RED) {
240
472
  if (command === 'replace') await replacePoints(points);
241
473
  else if (command === 'add') {
242
474
  points.forEach((point) => node.desired.set(point.nodeId, point));
243
- await reconcileSubscription('command');
475
+ await reconcileSubscriptions('command');
244
476
  } else if (command === 'remove') {
245
477
  points.forEach((point) => node.desired.delete(point.nodeId));
246
- await reconcileSubscription('command');
247
- } else if (command === 'clear') await clearSubscription();
478
+ await reconcileSubscriptions('command');
479
+ } else if (command === 'clear') await clearSubscriptions();
248
480
  else throw new Error(`Unsupported subscription command: ${command}`);
249
481
  });
250
482
 
251
- node.status({ fill: 'green', shape: 'dot', text: `${node.monitored.size} monitored` });
483
+ node.status({
484
+ fill: 'green', shape: 'dot',
485
+ text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub`,
486
+ });
252
487
  send({
253
488
  ...msg,
254
- payload: {
255
- command,
256
- desired: node.desired.size,
257
- monitored: node.monitored.size,
258
- subscriptionId: node.subscriptionId,
259
- },
489
+ payload: statusPayload(command),
260
490
  opcua: { operation: 'subscription-status' },
261
491
  });
262
492
  done();
@@ -269,11 +499,12 @@ module.exports = function registerSubscribeNode(RED) {
269
499
  node.on('close', async (done) => {
270
500
  node.closing = true;
271
501
  if (node.reconnectTimer) clearTimeout(node.reconnectTimer);
502
+ discardPendingChanges();
272
503
  node.connection?.client.off('fault', onFault);
273
504
  node.connection?.client.off('state', onState);
274
505
  node.desired.clear();
275
506
  try {
276
- await clearSubscription();
507
+ await runSerialized(() => clearSubscriptions());
277
508
  } catch (error) {
278
509
  node.warn(`OPC UA subscription cleanup failed: ${error.message}`);
279
510
  }
package/nodes/write.html CHANGED
@@ -39,6 +39,7 @@
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>
@@ -47,6 +48,7 @@
47
48
  <script type="text/html" data-help-name="tier0-opcua-write">
48
49
  <p>Add NodeId, value, and data type rows or import JSON/CSV. Imported points are parsed and shown in the same list.</p>
49
50
  <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>
51
+ <p>Use <code>indexRange</code> (or legacy <code>range</code>) per point or in <code>msg.indexRange</code> for partial array writes.</p>
50
52
  </script>
51
53
  <script type="text/javascript">
52
54
  RED.nodes.registerType('tier0-opcua-write', {
@@ -54,6 +56,7 @@
54
56
  defaults: {
55
57
  connection: { value: '', type: 'tier0-opcua-connection', required: true },
56
58
  nodeId: { value: '' }, dataType: { value: 'String' },
59
+ indexRange: { value: '' },
57
60
  pointList: { value: '' }, pointListFileName: { value: '' },
58
61
  batchSize: { value: 500, validate: RED.validators.number() },
59
62
  outputMode: { value: 'batch' }, name: { value: '' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tier0/opcua-client",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "High-scale OPC UA client nodes for Node-RED powered by open62541",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -16,21 +16,29 @@
16
16
  "nodes",
17
17
  "src",
18
18
  "scripts",
19
+ "prebuilds",
20
+ "vendor",
19
21
  "README.md",
20
22
  "BENCHMARK.md",
23
+ "THIRD_PARTY_NOTICES.md",
21
24
  "LICENSE"
22
25
  ],
23
26
  "scripts": {
24
- "install": "npm run build:open62541 && node-gyp rebuild",
27
+ "install": "node scripts/install.js",
25
28
  "build:open62541": "bash scripts/build-open62541.sh",
29
+ "build:offline-prebuild": "bash scripts/build-offline-prebuild.sh",
30
+ "build:test-server": "bash scripts/build-test-server.sh",
26
31
  "build": "node-gyp rebuild",
27
32
  "clean": "node-gyp clean",
28
33
  "test": "node --test test/*.test.js",
29
- "test:native": "node test/native.integration.js && node test/native.queue.integration.js && node test/reconnect.integration.js",
34
+ "test:native": "node test/native.integration.js && node test/native.queue.integration.js && node test/native.command-queue.integration.js && node test/reconnect.integration.js",
30
35
  "test:reconnect": "node test/reconnect.integration.js",
36
+ "test:secure": "node test/secure.integration.js",
37
+ "test:offline-runtime": "node scripts/verify-runtime.js",
31
38
  "test:stress": "node test/stress.js",
32
39
  "test:nodered": "node test/nodered.integration.js",
33
- "test:nodered:reconnect": "OPCUA_NODERED_RECONNECT=1 node test/nodered.integration.js"
40
+ "test:nodered:reconnect": "OPCUA_NODERED_RECONNECT=1 node test/nodered.integration.js",
41
+ "prepack": "node scripts/verify-offline-package.js"
34
42
  },
35
43
  "keywords": [
36
44
  "node-red",
@@ -55,6 +63,9 @@
55
63
  "dependencies": {
56
64
  "node-addon-api": "^8.3.1"
57
65
  },
66
+ "bundledDependencies": [
67
+ "node-addon-api"
68
+ ],
58
69
  "devDependencies": {
59
70
  "node-gyp": "^11.4.2"
60
71
  }
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
5
+ ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd -P)"
6
+
7
+ cd "${ROOT_DIR}"
8
+ npm run build:open62541
9
+ node-gyp rebuild "$@"
10
+
11
+ PREBUILD_KEY="$(node -p "require('./lib/prebuild').prebuildKey()")"
12
+ DESTINATION="${ROOT_DIR}/prebuilds/${PREBUILD_KEY}/opcua_open62541.node"
13
+ mkdir -p "$(dirname "${DESTINATION}")"
14
+ cp "${ROOT_DIR}/build/Release/opcua_open62541.node" "${DESTINATION}"
15
+ echo "Built ${DESTINATION}"