@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.
- package/BENCHMARK.md +53 -2
- package/README.md +131 -14
- package/THIRD_PARTY_NOTICES.md +20 -0
- package/binding.gyp +1 -1
- package/lib/client.js +48 -7
- package/lib/native.js +25 -10
- package/lib/points.js +187 -21
- package/lib/prebuild.js +42 -0
- package/lib/security.js +96 -0
- package/node_modules/node-addon-api/LICENSE.md +9 -0
- package/node_modules/node-addon-api/README.md +95 -0
- package/node_modules/node-addon-api/common.gypi +21 -0
- package/node_modules/node-addon-api/except.gypi +25 -0
- package/node_modules/node-addon-api/index.js +14 -0
- package/node_modules/node-addon-api/napi-inl.deprecated.h +186 -0
- package/node_modules/node-addon-api/napi-inl.h +7244 -0
- package/node_modules/node-addon-api/napi.h +3420 -0
- package/node_modules/node-addon-api/node_addon_api.gyp +42 -0
- package/node_modules/node-addon-api/node_api.gyp +9 -0
- package/node_modules/node-addon-api/noexcept.gypi +26 -0
- package/node_modules/node-addon-api/nothing.c +0 -0
- package/node_modules/node-addon-api/package-support.json +21 -0
- package/node_modules/node-addon-api/package.json +481 -0
- package/node_modules/node-addon-api/tools/README.md +73 -0
- package/node_modules/node-addon-api/tools/check-napi.js +99 -0
- package/node_modules/node-addon-api/tools/clang-format.js +71 -0
- package/node_modules/node-addon-api/tools/conversion.js +301 -0
- package/nodes/connection.html +165 -6
- package/nodes/connection.js +99 -20
- package/nodes/read.html +3 -0
- package/nodes/read.js +3 -1
- package/nodes/subscribe.html +9 -3
- package/nodes/subscribe.js +330 -99
- package/nodes/write.html +3 -0
- package/package.json +15 -4
- package/prebuilds/linux-x64-musl/opcua_open62541.node +0 -0
- package/scripts/build-offline-prebuild.sh +15 -0
- package/scripts/build-open62541.sh +14 -4
- package/scripts/build-test-server.sh +23 -0
- package/scripts/install.js +28 -0
- package/scripts/verify-offline-package.js +20 -0
- package/scripts/verify-runtime.js +20 -0
- package/src/addon.cc +707 -119
- package/vendor/open62541-LICENSE +373 -0
- package/vendor/open62541-v1.4.16.tar.gz +0 -0
package/nodes/subscribe.js
CHANGED
|
@@ -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.
|
|
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 =
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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:${
|
|
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
|
|
69
|
-
|
|
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:
|
|
76
|
-
callbackBatchSize
|
|
207
|
+
callbackQueueSize: positiveInteger(config.callbackQueueSize, 10000),
|
|
208
|
+
callbackBatchSize,
|
|
77
209
|
queueMode: config.queueMode || 'all',
|
|
78
210
|
overflowPolicy: config.overflowPolicy || 'drop-oldest',
|
|
79
|
-
},
|
|
80
|
-
|
|
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
|
|
217
|
+
async function addMonitoredItems(record, points) {
|
|
84
218
|
if (!points.length) return [];
|
|
85
219
|
const results = await node.connection.client.addMonitoredItems(
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
246
|
+
function findAvailableSubscription() {
|
|
247
|
+
return [...node.subscriptions.values()].find(
|
|
248
|
+
(record) => record.monitored.size < record.limit
|
|
249
|
+
);
|
|
250
|
+
}
|
|
109
251
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
const
|
|
125
|
-
for (const [nodeId,
|
|
126
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
302
|
+
subscriptionId,
|
|
131
303
|
removals.map((item) => item.monitoredItemId),
|
|
132
304
|
batchSize
|
|
133
305
|
);
|
|
134
306
|
results.forEach((result, index) => {
|
|
135
|
-
if (result.statusCode
|
|
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
|
|
143
|
-
|
|
144
|
-
if (
|
|
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({
|
|
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
|
|
182
|
-
await
|
|
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(() =>
|
|
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
|
|
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
|
|
475
|
+
await reconcileSubscriptions('command');
|
|
244
476
|
} else if (command === 'remove') {
|
|
245
477
|
points.forEach((point) => node.desired.delete(point.nodeId));
|
|
246
|
-
await
|
|
247
|
-
} else if (command === 'clear') await
|
|
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({
|
|
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
|
|
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.
|
|
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": "
|
|
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
|
}
|
|
Binary file
|
|
@@ -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}"
|