@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.
- package/BENCHMARK.md +53 -2
- package/README.md +180 -14
- package/THIRD_PARTY_NOTICES.md +20 -0
- package/binding.gyp +1 -1
- package/lib/client.js +48 -7
- package/lib/connection-registry.js +68 -0
- package/lib/native.js +25 -10
- package/lib/points.js +187 -21
- package/lib/prebuild.js +42 -0
- package/lib/security-file-store.js +119 -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/browse.html +27 -2
- package/nodes/connection.html +293 -6
- package/nodes/connection.js +160 -23
- package/nodes/read.html +42 -3
- package/nodes/read.js +51 -12
- package/nodes/subscribe.html +57 -5
- package/nodes/subscribe.js +386 -99
- package/nodes/write.html +29 -2
- package/nodes/write.js +2 -1
- 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,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.
|
|
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 =
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}));
|
|
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
|
-
|
|
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:${
|
|
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
|
|
69
|
-
|
|
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:
|
|
76
|
-
callbackBatchSize
|
|
262
|
+
callbackQueueSize: positiveInteger(config.callbackQueueSize, 10000),
|
|
263
|
+
callbackBatchSize,
|
|
77
264
|
queueMode: config.queueMode || 'all',
|
|
78
265
|
overflowPolicy: config.overflowPolicy || 'drop-oldest',
|
|
79
|
-
},
|
|
80
|
-
|
|
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
|
|
272
|
+
async function addMonitoredItems(record, points) {
|
|
84
273
|
if (!points.length) return [];
|
|
85
274
|
const results = await node.connection.client.addMonitoredItems(
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
301
|
+
function findAvailableSubscription() {
|
|
302
|
+
return [...node.subscriptions.values()].find(
|
|
303
|
+
(record) => record.monitored.size < record.limit
|
|
304
|
+
);
|
|
305
|
+
}
|
|
109
306
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
const
|
|
125
|
-
for (const [nodeId,
|
|
126
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
357
|
+
subscriptionId,
|
|
131
358
|
removals.map((item) => item.monitoredItemId),
|
|
132
359
|
batchSize
|
|
133
360
|
);
|
|
134
361
|
results.forEach((result, index) => {
|
|
135
|
-
if (result.statusCode
|
|
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
|
|
143
|
-
|
|
144
|
-
if (
|
|
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({
|
|
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
|
|
182
|
-
await
|
|
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(() =>
|
|
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
|
|
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
|
|
531
|
+
await reconcileSubscriptions('command');
|
|
244
532
|
} else if (command === 'remove') {
|
|
245
533
|
points.forEach((point) => node.desired.delete(point.nodeId));
|
|
246
|
-
await
|
|
247
|
-
} else if (command === 'clear') await
|
|
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({
|
|
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
|
|
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
|
-
<
|
|
49
|
-
|
|
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: '' }
|