@tier0/opcua-client 0.4.1 → 0.6.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.
- package/BENCHMARK.md +27 -0
- package/README.md +48 -6
- package/lib/client.js +61 -10
- package/lib/compatibility.js +28 -0
- package/lib/points.js +40 -5
- package/lib/security.js +182 -2
- package/nodes/browse.html +13 -2
- package/nodes/browse.js +29 -2
- package/nodes/connection.html +19 -9
- package/nodes/connection.js +71 -0
- package/nodes/read.html +2 -0
- package/nodes/read.js +10 -0
- package/nodes/subscribe.html +165 -5
- package/nodes/subscribe.js +218 -23
- package/nodes/write.html +2 -0
- package/nodes/write.js +10 -0
- package/package.json +3 -2
- package/prebuilds/linux-x64-musl/opcua_open62541.node +0 -0
- package/scripts/verify-offline-package.js +21 -1
- package/src/addon.cc +343 -49
package/nodes/subscribe.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { messagePoints } = require('../lib/points');
|
|
3
|
+
const { looksLikePointList, messagePoints } = require('../lib/points');
|
|
4
|
+
const { SIEMENS_WINCC_DEFAULTS, isSiemensWinCC } = require('../lib/compatibility');
|
|
4
5
|
|
|
5
6
|
const NOTIFICATION_CONFIG_FIELDS = new Set([
|
|
6
7
|
'nodeId',
|
|
@@ -55,6 +56,43 @@ function positiveInteger(value, fallback) {
|
|
|
55
56
|
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
function monitoringSettings(point, config, siemensWincc = false) {
|
|
60
|
+
return {
|
|
61
|
+
samplingInterval: Number(point.samplingInterval) || Number(config.samplingInterval) || 1000,
|
|
62
|
+
queueSize: positiveInteger(point.queueSize, positiveInteger(config.queueSize, 1)),
|
|
63
|
+
discardOldest: point.discardOldest !== false,
|
|
64
|
+
deadbandType: siemensWincc ? 'none' : point.deadbandType || config.deadbandType || 'none',
|
|
65
|
+
deadbandValue: siemensWincc ? 0 : Number(point.deadbandValue ?? config.deadbandValue) || 0,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sameMonitoringSettings(left, right, config, siemensWincc = false) {
|
|
70
|
+
const leftSettings = monitoringSettings(left, config, siemensWincc);
|
|
71
|
+
const rightSettings = monitoringSettings(right, config, siemensWincc);
|
|
72
|
+
return Object.keys(leftSettings).every(
|
|
73
|
+
(key) => leftSettings[key] === rightSettings[key]
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function numericRange(items, field) {
|
|
78
|
+
let count = 0;
|
|
79
|
+
let minimum = Infinity;
|
|
80
|
+
let maximum = -Infinity;
|
|
81
|
+
for (const item of items) {
|
|
82
|
+
if (item[field] == null) continue;
|
|
83
|
+
const value = Number(item[field]);
|
|
84
|
+
if (!Number.isFinite(value)) continue;
|
|
85
|
+
count += 1;
|
|
86
|
+
minimum = Math.min(minimum, value);
|
|
87
|
+
maximum = Math.max(maximum, value);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
count,
|
|
91
|
+
minimum: count ? minimum : null,
|
|
92
|
+
maximum: count ? maximum : null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
58
96
|
module.exports = function registerSubscribeNode(RED) {
|
|
59
97
|
function OpcUaSubscribeNode(config) {
|
|
60
98
|
RED.nodes.createNode(this, config);
|
|
@@ -63,6 +101,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
63
101
|
node.subscriptions = new Map();
|
|
64
102
|
node.desired = new Map();
|
|
65
103
|
node.monitored = new Map();
|
|
104
|
+
node.failed = new Map();
|
|
66
105
|
node.subscriptionDiagnostics = new Map();
|
|
67
106
|
node.pendingChanges = [];
|
|
68
107
|
node.flushTimer = null;
|
|
@@ -73,11 +112,24 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
73
112
|
node.closing = false;
|
|
74
113
|
node.lastDropped = 0;
|
|
75
114
|
node.lastStatusAt = 0;
|
|
115
|
+
node.compatibilityWarningIssued = false;
|
|
116
|
+
const consumer = node.connection?.registerConsumer?.('subscribe', {
|
|
117
|
+
nodeId: config.id, name: config.name,
|
|
118
|
+
});
|
|
76
119
|
|
|
77
120
|
const batchSize = positiveInteger(config.batchSize, 500);
|
|
78
121
|
const callbackBatchSize = positiveInteger(config.callbackBatchSize, 500);
|
|
79
122
|
const callbackFlushIntervalMs = positiveInteger(config.callbackFlushIntervalMs, 50);
|
|
80
|
-
const
|
|
123
|
+
const siemensWincc = isSiemensWinCC(
|
|
124
|
+
node.connection?.client?.options?.compatibilityProfile
|
|
125
|
+
);
|
|
126
|
+
const requestedSubscriptionCapacity = positiveInteger(config.maxItemsPerSubscription, 5000);
|
|
127
|
+
const configuredSubscriptionCapacity = siemensWincc
|
|
128
|
+
? Math.min(
|
|
129
|
+
requestedSubscriptionCapacity,
|
|
130
|
+
SIEMENS_WINCC_DEFAULTS.monitoredItemsPerSubscription
|
|
131
|
+
)
|
|
132
|
+
: requestedSubscriptionCapacity;
|
|
81
133
|
// Undefined preserves the descriptor-rich output used by Flow JSON created
|
|
82
134
|
// before resultDetail existed. New editor nodes explicitly save compact.
|
|
83
135
|
const resultDetail = ['compact', 'value', 'full'].includes(config.resultDetail)
|
|
@@ -88,6 +140,24 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
88
140
|
return [...node.subscriptions.keys()];
|
|
89
141
|
}
|
|
90
142
|
|
|
143
|
+
function applyCompatibility(points) {
|
|
144
|
+
if (!siemensWincc) return points;
|
|
145
|
+
let removedDeadband = false;
|
|
146
|
+
const compatible = points.map((point) => {
|
|
147
|
+
const configuredType = point.deadbandType || config.deadbandType || 'none';
|
|
148
|
+
if (configuredType === 'none') return point;
|
|
149
|
+
removedDeadband = true;
|
|
150
|
+
return { ...point, deadbandType: 'none', deadbandValue: 0 };
|
|
151
|
+
});
|
|
152
|
+
if (removedDeadband && !node.compatibilityWarningIssued) {
|
|
153
|
+
node.compatibilityWarningIssued = true;
|
|
154
|
+
node.warn(
|
|
155
|
+
'Siemens WinCC does not support OPC UA DataChange Deadband filters; Deadband was disabled.'
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return compatible;
|
|
159
|
+
}
|
|
160
|
+
|
|
91
161
|
function primarySubscriptionId() {
|
|
92
162
|
return subscriptionIds()[0] ?? null;
|
|
93
163
|
}
|
|
@@ -253,6 +323,9 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
253
323
|
id: null,
|
|
254
324
|
monitored: new Map(),
|
|
255
325
|
limit: subscriptionCapacity(),
|
|
326
|
+
revisedPublishingInterval: null,
|
|
327
|
+
revisedLifetimeCount: null,
|
|
328
|
+
revisedMaxKeepAliveCount: null,
|
|
256
329
|
};
|
|
257
330
|
const result = await node.connection.client.createSubscription({
|
|
258
331
|
publishingInterval: Number(config.publishingInterval) || 1000,
|
|
@@ -265,6 +338,9 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
265
338
|
overflowPolicy: config.overflowPolicy || 'drop-oldest',
|
|
266
339
|
}, (batch) => onNativeBatch(record, batch));
|
|
267
340
|
record.id = result.subscriptionId;
|
|
341
|
+
record.revisedPublishingInterval = result.revisedPublishingInterval ?? null;
|
|
342
|
+
record.revisedLifetimeCount = result.revisedLifetimeCount ?? null;
|
|
343
|
+
record.revisedMaxKeepAliveCount = result.revisedMaxKeepAliveCount ?? null;
|
|
268
344
|
node.subscriptions.set(record.id, record);
|
|
269
345
|
return record;
|
|
270
346
|
}
|
|
@@ -278,8 +354,8 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
278
354
|
samplingInterval: Number(config.samplingInterval) || 1000,
|
|
279
355
|
queueSize: Number(config.queueSize) || 10,
|
|
280
356
|
discardOldest: config.discardOldest !== false,
|
|
281
|
-
deadbandType: config.deadbandType || 'none',
|
|
282
|
-
deadbandValue: Number(config.deadbandValue) || 0,
|
|
357
|
+
deadbandType: siemensWincc ? 'none' : config.deadbandType || 'none',
|
|
358
|
+
deadbandValue: siemensWincc ? 0 : Number(config.deadbandValue) || 0,
|
|
283
359
|
timestampMode: config.timestampMode || 'source',
|
|
284
360
|
},
|
|
285
361
|
batchSize
|
|
@@ -291,6 +367,8 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
291
367
|
subscriptionId: record.id,
|
|
292
368
|
monitoredItemId: result.monitoredItemId,
|
|
293
369
|
clientHandle: result.clientHandle,
|
|
370
|
+
revisedSamplingInterval: result.revisedSamplingInterval ?? null,
|
|
371
|
+
revisedQueueSize: result.revisedQueueSize ?? null,
|
|
294
372
|
};
|
|
295
373
|
record.monitored.set(result.nodeId, monitored);
|
|
296
374
|
node.monitored.set(result.nodeId, monitored);
|
|
@@ -343,10 +421,10 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
343
421
|
return failures;
|
|
344
422
|
}
|
|
345
423
|
|
|
346
|
-
async function
|
|
424
|
+
async function removeMonitoredPoints(nodeIds) {
|
|
347
425
|
const grouped = new Map();
|
|
348
426
|
for (const [nodeId, monitored] of node.monitored) {
|
|
349
|
-
if (
|
|
427
|
+
if (!nodeIds.has(nodeId)) continue;
|
|
350
428
|
if (!grouped.has(monitored.subscriptionId)) grouped.set(monitored.subscriptionId, []);
|
|
351
429
|
grouped.get(monitored.subscriptionId).push({ nodeId, ...monitored });
|
|
352
430
|
}
|
|
@@ -367,6 +445,13 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
367
445
|
}
|
|
368
446
|
}
|
|
369
447
|
|
|
448
|
+
async function removeUndesiredPoints() {
|
|
449
|
+
const nodeIds = new Set(
|
|
450
|
+
[...node.monitored.keys()].filter((nodeId) => !node.desired.has(nodeId))
|
|
451
|
+
);
|
|
452
|
+
await removeMonitoredPoints(nodeIds);
|
|
453
|
+
}
|
|
454
|
+
|
|
370
455
|
async function pruneEmptySubscriptions() {
|
|
371
456
|
const empty = [...node.subscriptions.values()].filter(
|
|
372
457
|
(record) => record.monitored.size === 0
|
|
@@ -380,7 +465,10 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
380
465
|
}
|
|
381
466
|
|
|
382
467
|
async function clearSubscriptions(keepDesired = false, ignoreErrors = false) {
|
|
383
|
-
if (!keepDesired)
|
|
468
|
+
if (!keepDesired) {
|
|
469
|
+
node.desired.clear();
|
|
470
|
+
node.failed.clear();
|
|
471
|
+
}
|
|
384
472
|
discardPendingChanges();
|
|
385
473
|
const records = [...node.subscriptions.values()];
|
|
386
474
|
node.subscriptions.clear();
|
|
@@ -403,22 +491,54 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
403
491
|
return true;
|
|
404
492
|
}
|
|
405
493
|
|
|
406
|
-
function
|
|
494
|
+
function failurePayload() {
|
|
495
|
+
const failures = [...node.failed.values()];
|
|
496
|
+
return {
|
|
497
|
+
failed: failures.length,
|
|
498
|
+
failures: failures.slice(0, 100),
|
|
499
|
+
failuresTruncated: failures.length > 100,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function statusPayload(command) {
|
|
407
504
|
const ids = subscriptionIds();
|
|
505
|
+
const monitoredValues = [...node.monitored.values()];
|
|
506
|
+
const revisedSampling = numericRange(monitoredValues, 'revisedSamplingInterval');
|
|
507
|
+
const revisedQueues = numericRange(monitoredValues, 'revisedQueueSize');
|
|
408
508
|
return {
|
|
409
509
|
command,
|
|
410
510
|
desired: node.desired.size,
|
|
411
511
|
monitored: node.monitored.size,
|
|
412
|
-
|
|
512
|
+
...failurePayload(),
|
|
413
513
|
subscriptionCount: ids.length,
|
|
414
514
|
subscriptionId: ids[0] ?? null,
|
|
415
515
|
subscriptionIds: ids,
|
|
516
|
+
revisions: [...node.subscriptions.values()].map((record) => ({
|
|
517
|
+
subscriptionId: record.id,
|
|
518
|
+
publishingInterval: record.revisedPublishingInterval,
|
|
519
|
+
lifetimeCount: record.revisedLifetimeCount,
|
|
520
|
+
maxKeepAliveCount: record.revisedMaxKeepAliveCount,
|
|
521
|
+
})),
|
|
522
|
+
monitoredRevisions: {
|
|
523
|
+
count: Math.max(revisedSampling.count, revisedQueues.count),
|
|
524
|
+
samplingIntervalMin: revisedSampling.minimum,
|
|
525
|
+
samplingIntervalMax: revisedSampling.maximum,
|
|
526
|
+
queueSizeMin: revisedQueues.minimum,
|
|
527
|
+
queueSizeMax: revisedQueues.maximum,
|
|
528
|
+
},
|
|
529
|
+
compatibility: {
|
|
530
|
+
profile: siemensWincc ? 'siemens-wincc' : 'standard',
|
|
531
|
+
deadbandSupported: !siemensWincc,
|
|
532
|
+
monitoredItemsPerSubscription: configuredSubscriptionCapacity,
|
|
533
|
+
},
|
|
416
534
|
connectionGovernance: node.connection?.getConnectionGovernance?.(),
|
|
535
|
+
connectionUsage: node.connection?.getConnectionUsage?.(),
|
|
417
536
|
};
|
|
418
537
|
}
|
|
419
538
|
|
|
420
539
|
async function reconcileSubscriptions(reason = 'state') {
|
|
421
540
|
if (node.closing) return;
|
|
541
|
+
consumer?.markUsed('subscribe-reconcile');
|
|
422
542
|
if (!node.desired.size) {
|
|
423
543
|
if (node.subscriptions.size) await clearSubscriptions(true);
|
|
424
544
|
return;
|
|
@@ -439,15 +559,21 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
439
559
|
(point) => !node.monitored.has(point.nodeId)
|
|
440
560
|
);
|
|
441
561
|
const failures = await addDesiredPoints(additions);
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
562
|
+
for (const point of additions) node.failed.delete(point.nodeId);
|
|
563
|
+
for (const failure of failures) {
|
|
564
|
+
node.failed.set(failure.nodeId, {
|
|
565
|
+
nodeId: failure.nodeId,
|
|
566
|
+
statusCode: failure.statusCode || 'BadUnexpectedError',
|
|
567
|
+
});
|
|
445
568
|
}
|
|
569
|
+
await pruneEmptySubscriptions();
|
|
446
570
|
|
|
447
571
|
node.reconcileAttempt = 0;
|
|
448
572
|
node.status({
|
|
449
|
-
fill: '
|
|
450
|
-
|
|
573
|
+
fill: failures.length ? 'yellow' : 'green',
|
|
574
|
+
shape: failures.length ? 'ring' : 'dot',
|
|
575
|
+
text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub` +
|
|
576
|
+
(failures.length ? ` / ${failures.length} failed` : ''),
|
|
451
577
|
});
|
|
452
578
|
if (reason !== 'command') {
|
|
453
579
|
node.send({
|
|
@@ -462,12 +588,25 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
462
588
|
},
|
|
463
589
|
});
|
|
464
590
|
}
|
|
591
|
+
return { rebuilt, failures };
|
|
465
592
|
}
|
|
466
593
|
|
|
467
594
|
async function replacePoints(points) {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
595
|
+
const nextDesired = new Map(points.map((point) => [point.nodeId, point]));
|
|
596
|
+
const changed = new Set();
|
|
597
|
+
for (const [nodeId, monitored] of node.monitored) {
|
|
598
|
+
const previous = node.desired.get(nodeId);
|
|
599
|
+
const next = nextDesired.get(nodeId);
|
|
600
|
+
if (!next || !previous || !sameMonitoringSettings(previous, next, config, siemensWincc)) {
|
|
601
|
+
changed.add(nodeId);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
node.desired = nextDesired;
|
|
605
|
+
for (const nodeId of [...node.failed.keys()]) {
|
|
606
|
+
if (!node.desired.has(nodeId)) node.failed.delete(nodeId);
|
|
607
|
+
}
|
|
608
|
+
await removeMonitoredPoints(changed);
|
|
609
|
+
return reconcileSubscriptions('command');
|
|
471
610
|
}
|
|
472
611
|
|
|
473
612
|
function runSerialized(task) {
|
|
@@ -517,28 +656,49 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
517
656
|
if (!node.connection) return done(new Error('OPC UA connection not configured'));
|
|
518
657
|
try {
|
|
519
658
|
const command = msg.opcua?.command || 'replace';
|
|
520
|
-
const
|
|
659
|
+
const dynamicNodeId = msg.nodeId || msg.topic;
|
|
660
|
+
const configuredPayload = !looksLikePointList(msg.payload) && !Array.isArray(msg.items) &&
|
|
661
|
+
!dynamicNodeId ? config.pointList : '';
|
|
662
|
+
const staticNodeId = dynamicNodeId ? '' : config.nodeId;
|
|
663
|
+
const points = command === 'clear' ? [] : applyCompatibility(messagePoints(msg, staticNodeId, {
|
|
521
664
|
samplingInterval: Number(config.samplingInterval) || 1000,
|
|
522
665
|
queueSize: Number(config.queueSize) || 10,
|
|
523
666
|
discardOldest: config.discardOldest !== false,
|
|
524
|
-
}
|
|
667
|
+
}, {
|
|
668
|
+
configuredPayload,
|
|
669
|
+
format: 'auto',
|
|
670
|
+
}));
|
|
525
671
|
|
|
526
672
|
node.status({ fill: 'blue', shape: 'dot', text: `${command} ${points.length}` });
|
|
527
673
|
await runSerialized(async () => {
|
|
528
674
|
if (command === 'replace') await replacePoints(points);
|
|
529
675
|
else if (command === 'add') {
|
|
530
|
-
|
|
676
|
+
const changed = new Set();
|
|
677
|
+
points.forEach((point) => {
|
|
678
|
+
const previous = node.desired.get(point.nodeId);
|
|
679
|
+
if (previous && !sameMonitoringSettings(previous, point, config, siemensWincc)) {
|
|
680
|
+
changed.add(point.nodeId);
|
|
681
|
+
}
|
|
682
|
+
node.desired.set(point.nodeId, point);
|
|
683
|
+
node.failed.delete(point.nodeId);
|
|
684
|
+
});
|
|
685
|
+
await removeMonitoredPoints(changed);
|
|
531
686
|
await reconcileSubscriptions('command');
|
|
532
687
|
} else if (command === 'remove') {
|
|
533
|
-
points.forEach((point) =>
|
|
688
|
+
points.forEach((point) => {
|
|
689
|
+
node.desired.delete(point.nodeId);
|
|
690
|
+
node.failed.delete(point.nodeId);
|
|
691
|
+
});
|
|
534
692
|
await reconcileSubscriptions('command');
|
|
535
693
|
} else if (command === 'clear') await clearSubscriptions();
|
|
536
694
|
else throw new Error(`Unsupported subscription command: ${command}`);
|
|
537
695
|
});
|
|
538
696
|
|
|
697
|
+
const failed = node.failed.size;
|
|
539
698
|
node.status({
|
|
540
|
-
fill: 'green', shape: 'dot',
|
|
541
|
-
text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub
|
|
699
|
+
fill: failed ? 'yellow' : 'green', shape: failed ? 'ring' : 'dot',
|
|
700
|
+
text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub` +
|
|
701
|
+
(failed ? ` / ${failed} failed` : ''),
|
|
542
702
|
});
|
|
543
703
|
send({
|
|
544
704
|
...msg,
|
|
@@ -560,6 +720,8 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
560
720
|
node.connection?.client.off('fault', onFault);
|
|
561
721
|
node.connection?.client.off('state', onState);
|
|
562
722
|
node.desired.clear();
|
|
723
|
+
node.failed.clear();
|
|
724
|
+
consumer?.release();
|
|
563
725
|
await runSerialized(() => clearSubscriptions());
|
|
564
726
|
}
|
|
565
727
|
|
|
@@ -574,6 +736,39 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
574
736
|
}
|
|
575
737
|
done();
|
|
576
738
|
});
|
|
739
|
+
|
|
740
|
+
const configuredPoints = String(config.pointList || '').trim() ||
|
|
741
|
+
String(config.nodeId || '').trim();
|
|
742
|
+
if (configuredPoints) {
|
|
743
|
+
setImmediate(() => {
|
|
744
|
+
if (node.closing) return;
|
|
745
|
+
let points;
|
|
746
|
+
try {
|
|
747
|
+
points = applyCompatibility(messagePoints({}, config.nodeId, {
|
|
748
|
+
samplingInterval: Number(config.samplingInterval) || 1000,
|
|
749
|
+
queueSize: Number(config.queueSize) || 10,
|
|
750
|
+
discardOldest: config.discardOldest !== false,
|
|
751
|
+
}, {
|
|
752
|
+
configuredPayload: config.pointList,
|
|
753
|
+
format: 'auto',
|
|
754
|
+
}));
|
|
755
|
+
} catch (error) {
|
|
756
|
+
node.status({ fill: 'red', shape: 'ring', text: error.message });
|
|
757
|
+
node.warn(`OPC UA saved subscription point list is invalid: ${error.message}`);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
void runSerialized(() => replacePoints(points)).then(() => {
|
|
761
|
+
node.send({
|
|
762
|
+
payload: statusPayload('startup'),
|
|
763
|
+
opcua: { operation: 'subscription-status', startup: true },
|
|
764
|
+
});
|
|
765
|
+
}).catch((error) => {
|
|
766
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'waiting to subscribe' });
|
|
767
|
+
node.warn(`OPC UA startup subscription failed: ${error.message}`);
|
|
768
|
+
scheduleReconnect('startup');
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
}
|
|
577
772
|
}
|
|
578
773
|
|
|
579
774
|
RED.nodes.registerType('tier0-opcua-subscribe', OpcUaSubscribeNode);
|
package/nodes/write.html
CHANGED
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
<li>单点:NodeId 来自节点配置、<code>msg.nodeId</code> 或 <code>msg.topic</code>,写入值来自 <code>msg.payload</code>。</li>
|
|
55
55
|
<li>批量:通过编辑器、JSON 或 CSV 提供 <code>nodeId</code>、<code>value</code> 和可选 <code>dataType</code>。</li>
|
|
56
56
|
<li>CSV 必须包含 <code>nodeId</code> 和 <code>value</code>;未指定类型时使用节点默认类型。</li>
|
|
57
|
+
<li>Int64/UInt64 建议使用十进制字符串;超范围、小数和不安全的 Number 会在发送前被拒绝。</li>
|
|
57
58
|
<li><code>indexRange</code> 可部分写入数组或矩阵。</li>
|
|
58
59
|
</ul>
|
|
59
60
|
<h4>输出</h4>
|
|
@@ -68,6 +69,7 @@
|
|
|
68
69
|
<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
70
|
<li>Batch: Supply <code>nodeId</code>, <code>value</code>, and optional <code>dataType</code> through the editor, JSON, or CSV.</li>
|
|
70
71
|
<li>CSV requires <code>nodeId</code> and <code>value</code>. The node default type is used when <code>dataType</code> is omitted.</li>
|
|
72
|
+
<li>Use decimal strings for Int64/UInt64. Out-of-range, fractional, and unsafe Number values are rejected before transmission.</li>
|
|
71
73
|
<li>Use <code>indexRange</code> for partial array or matrix writes.</li>
|
|
72
74
|
</ul>
|
|
73
75
|
<h4>Output</h4>
|
package/nodes/write.js
CHANGED
|
@@ -7,11 +7,15 @@ module.exports = function registerWriteNode(RED) {
|
|
|
7
7
|
RED.nodes.createNode(this, config);
|
|
8
8
|
const node = this;
|
|
9
9
|
node.connection = RED.nodes.getNode(config.connection);
|
|
10
|
+
const consumer = node.connection?.registerConsumer?.('write', {
|
|
11
|
+
nodeId: config.id, name: config.name,
|
|
12
|
+
});
|
|
10
13
|
|
|
11
14
|
node.on('input', async (msg, send, done) => {
|
|
12
15
|
if (!node.connection) return done(new Error('OPC UA connection not configured'));
|
|
13
16
|
|
|
14
17
|
try {
|
|
18
|
+
consumer?.markUsed('write');
|
|
15
19
|
const points = messageWritePoints(msg, config);
|
|
16
20
|
node.status({ fill: 'blue', shape: 'dot', text: `writing ${points.length}` });
|
|
17
21
|
const results = await node.connection.client.writeMany(
|
|
@@ -41,6 +45,12 @@ module.exports = function registerWriteNode(RED) {
|
|
|
41
45
|
done(error);
|
|
42
46
|
}
|
|
43
47
|
});
|
|
48
|
+
|
|
49
|
+
node.on('close', (removed, done) => {
|
|
50
|
+
if (typeof removed === 'function') done = removed;
|
|
51
|
+
consumer?.release();
|
|
52
|
+
done?.();
|
|
53
|
+
});
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
RED.nodes.registerType('tier0-opcua-write', OpcUaWriteNode);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tier0/opcua-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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": {
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"test:stress": "node test/stress.js",
|
|
39
39
|
"test:nodered": "node test/nodered.integration.js",
|
|
40
40
|
"test:nodered:reconnect": "OPCUA_NODERED_RECONNECT=1 node test/nodered.integration.js",
|
|
41
|
-
"
|
|
41
|
+
"test:release": "npm test && npm run test:native && npm run test:offline-runtime && npm run test:nodered",
|
|
42
|
+
"prepack": "npm test && node scripts/verify-offline-package.js"
|
|
42
43
|
},
|
|
43
44
|
"keywords": [
|
|
44
45
|
"node-red",
|
|
Binary file
|
|
@@ -17,4 +17,24 @@ if (missing.length) {
|
|
|
17
17
|
process.exit(1);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
21
|
+
const prebuildSize = fs.statSync(
|
|
22
|
+
path.join(root, 'prebuilds/linux-x64-musl/opcua_open62541.node')
|
|
23
|
+
).size;
|
|
24
|
+
const sourceSize = fs.statSync(
|
|
25
|
+
path.join(root, 'vendor/open62541-v1.4.16.tar.gz')
|
|
26
|
+
).size;
|
|
27
|
+
if (!packageJson.files?.includes('prebuilds') || !packageJson.files?.includes('vendor')) {
|
|
28
|
+
throw new Error('package.json files must include prebuilds and vendor for offline installation');
|
|
29
|
+
}
|
|
30
|
+
if (!packageJson.bundledDependencies?.includes('node-addon-api')) {
|
|
31
|
+
throw new Error('node-addon-api must remain a bundled dependency');
|
|
32
|
+
}
|
|
33
|
+
if (prebuildSize < 100000 || sourceSize < 1000000) {
|
|
34
|
+
throw new Error('Bundled native addon or open62541 source archive is unexpectedly small');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(
|
|
38
|
+
`Offline package inputs verified for ${packageJson.name}@${packageJson.version}: ` +
|
|
39
|
+
`prebuild=${prebuildSize} bytes, open62541=${sourceSize} bytes`
|
|
40
|
+
);
|