@tier0/opcua-client 0.6.3 → 0.6.4
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/README.md +15 -3
- package/lib/diagnostics.js +55 -1
- package/nodes/browse.js +165 -160
- package/nodes/connection.js +2 -8
- package/nodes/read.js +130 -125
- package/nodes/subscribe.js +806 -800
- package/nodes/write.js +72 -67
- package/package.json +1 -1
package/nodes/subscribe.js
CHANGED
|
@@ -1,800 +1,806 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { createDiagnostics } = require('../lib/diagnostics');
|
|
4
|
-
|
|
5
|
-
const { looksLikePointList, messagePoints } = require('../lib/points');
|
|
6
|
-
const { SIEMENS_WINCC_DEFAULTS, isSiemensWinCC } = require('../lib/compatibility');
|
|
7
|
-
|
|
8
|
-
const NOTIFICATION_CONFIG_FIELDS = new Set([
|
|
9
|
-
'nodeId',
|
|
10
|
-
'samplingInterval',
|
|
11
|
-
'queueSize',
|
|
12
|
-
'discardOldest',
|
|
13
|
-
'deadbandType',
|
|
14
|
-
'deadbandValue',
|
|
15
|
-
'enabled',
|
|
16
|
-
]);
|
|
17
|
-
|
|
18
|
-
const COMPACT_NOTIFICATION_FIELDS = Object.freeze([
|
|
19
|
-
'nodeId',
|
|
20
|
-
'value',
|
|
21
|
-
'statusCode',
|
|
22
|
-
'sourceTimestamp',
|
|
23
|
-
]);
|
|
24
|
-
|
|
25
|
-
function valueOnly(change) {
|
|
26
|
-
return Object.prototype.hasOwnProperty.call(change, 'value') ? change.value : null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function compactChange(change, point) {
|
|
30
|
-
const output = {};
|
|
31
|
-
for (const field of COMPACT_NOTIFICATION_FIELDS) {
|
|
32
|
-
if (Object.prototype.hasOwnProperty.call(change, field)) output[field] = change[field];
|
|
33
|
-
}
|
|
34
|
-
if (point.alias != null && point.alias !== '') output.alias = point.alias;
|
|
35
|
-
return output;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function valueChange(change, point) {
|
|
39
|
-
return {
|
|
40
|
-
nodeId: change.nodeId,
|
|
41
|
-
...(point.alias != null && point.alias !== '' ? { alias: point.alias } : {}),
|
|
42
|
-
...(Object.prototype.hasOwnProperty.call(change, 'value') ? { value: change.value } : {}),
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function fullChange(change, point, subscriptionId) {
|
|
47
|
-
change.subscriptionId = subscriptionId;
|
|
48
|
-
for (const key in point) {
|
|
49
|
-
if (!NOTIFICATION_CONFIG_FIELDS.has(key) && !Object.hasOwn(change, key)) {
|
|
50
|
-
change[key] = point[key];
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return change;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function positiveInteger(value, fallback) {
|
|
57
|
-
const number = Number(value);
|
|
58
|
-
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function monitoringSettings(point, config, siemensWincc = false) {
|
|
62
|
-
return {
|
|
63
|
-
samplingInterval: Number(point.samplingInterval) || Number(config.samplingInterval) || 1000,
|
|
64
|
-
queueSize: positiveInteger(point.queueSize, positiveInteger(config.queueSize, 1)),
|
|
65
|
-
discardOldest: point.discardOldest !== false,
|
|
66
|
-
deadbandType: siemensWincc ? 'none' : point.deadbandType || config.deadbandType || 'none',
|
|
67
|
-
deadbandValue: siemensWincc ? 0 : Number(point.deadbandValue ?? config.deadbandValue) || 0,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function sameMonitoringSettings(left, right, config, siemensWincc = false) {
|
|
72
|
-
const leftSettings = monitoringSettings(left, config, siemensWincc);
|
|
73
|
-
const rightSettings = monitoringSettings(right, config, siemensWincc);
|
|
74
|
-
return Object.keys(leftSettings).every(
|
|
75
|
-
(key) => leftSettings[key] === rightSettings[key]
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function numericRange(items, field) {
|
|
80
|
-
let count = 0;
|
|
81
|
-
let minimum = Infinity;
|
|
82
|
-
let maximum = -Infinity;
|
|
83
|
-
for (const item of items) {
|
|
84
|
-
if (item[field] == null) continue;
|
|
85
|
-
const value = Number(item[field]);
|
|
86
|
-
if (!Number.isFinite(value)) continue;
|
|
87
|
-
count += 1;
|
|
88
|
-
minimum = Math.min(minimum, value);
|
|
89
|
-
maximum = Math.max(maximum, value);
|
|
90
|
-
}
|
|
91
|
-
return {
|
|
92
|
-
count,
|
|
93
|
-
minimum: count ? minimum : null,
|
|
94
|
-
maximum: count ? maximum : null,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
module.exports = function registerSubscribeNode(RED) {
|
|
99
|
-
function OpcUaSubscribeNode(config) {
|
|
100
|
-
RED.nodes.createNode(this, config);
|
|
101
|
-
const node = this;
|
|
102
|
-
node.connection = RED.nodes.getNode(config.connection);
|
|
103
|
-
const diagnostics = createDiagnostics(node, config, 'subscribe');
|
|
104
|
-
node.subscriptions = new Map();
|
|
105
|
-
node.desired = new Map();
|
|
106
|
-
node.monitored = new Map();
|
|
107
|
-
node.failed = new Map();
|
|
108
|
-
node.subscriptionDiagnostics = new Map();
|
|
109
|
-
node.pendingChanges = [];
|
|
110
|
-
node.flushTimer = null;
|
|
111
|
-
node.reconnectTimer = null;
|
|
112
|
-
node.reconcileChain = Promise.resolve();
|
|
113
|
-
node.reconcileAttempt = 0;
|
|
114
|
-
node.lastSessionGeneration = 0;
|
|
115
|
-
node.closing = false;
|
|
116
|
-
node.lastDropped = 0;
|
|
117
|
-
node.lastStatusAt = 0;
|
|
118
|
-
node.compatibilityWarningIssued = false;
|
|
119
|
-
const consumer = node.connection?.registerConsumer?.('subscribe', {
|
|
120
|
-
nodeId: config.id, name: config.name,
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
const batchSize = positiveInteger(config.batchSize, 500);
|
|
124
|
-
const callbackBatchSize = positiveInteger(config.callbackBatchSize, 500);
|
|
125
|
-
const callbackFlushIntervalMs = positiveInteger(config.callbackFlushIntervalMs, 50);
|
|
126
|
-
const siemensWincc = isSiemensWinCC(
|
|
127
|
-
node.connection?.client?.options?.compatibilityProfile
|
|
128
|
-
);
|
|
129
|
-
const requestedSubscriptionCapacity = positiveInteger(config.maxItemsPerSubscription, 5000);
|
|
130
|
-
const configuredSubscriptionCapacity = siemensWincc
|
|
131
|
-
? Math.min(
|
|
132
|
-
requestedSubscriptionCapacity,
|
|
133
|
-
SIEMENS_WINCC_DEFAULTS.monitoredItemsPerSubscription
|
|
134
|
-
)
|
|
135
|
-
: requestedSubscriptionCapacity;
|
|
136
|
-
// Undefined preserves the descriptor-rich output used by Flow JSON created
|
|
137
|
-
// before resultDetail existed. New editor nodes explicitly save compact.
|
|
138
|
-
const resultDetail = ['compact', 'value', 'full'].includes(config.resultDetail)
|
|
139
|
-
? config.resultDetail
|
|
140
|
-
: 'full';
|
|
141
|
-
|
|
142
|
-
function subscriptionIds() {
|
|
143
|
-
return [...node.subscriptions.keys()];
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function applyCompatibility(points) {
|
|
147
|
-
if (!siemensWincc) return points;
|
|
148
|
-
let removedDeadband = false;
|
|
149
|
-
const compatible = points.map((point) => {
|
|
150
|
-
const configuredType = point.deadbandType || config.deadbandType || 'none';
|
|
151
|
-
if (configuredType === 'none') return point;
|
|
152
|
-
removedDeadband = true;
|
|
153
|
-
return { ...point, deadbandType: 'none', deadbandValue: 0 };
|
|
154
|
-
});
|
|
155
|
-
if (removedDeadband && !node.compatibilityWarningIssued) {
|
|
156
|
-
node.compatibilityWarningIssued = true;
|
|
157
|
-
node.warn(
|
|
158
|
-
'Siemens WinCC does not support OPC UA DataChange Deadband filters; Deadband was disabled.'
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
return compatible;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function primarySubscriptionId() {
|
|
165
|
-
return subscriptionIds()[0] ?? null;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function subscriptionCapacity() {
|
|
169
|
-
const diagnostics = node.connection?.client.getDiagnostics?.() || {};
|
|
170
|
-
const serverLimit = Number(
|
|
171
|
-
diagnostics.operationLimits?.maxMonitoredItemsPerSubscription
|
|
172
|
-
);
|
|
173
|
-
return Number.isFinite(serverLimit) && serverLimit > 0
|
|
174
|
-
? Math.min(configuredSubscriptionCapacity, Math.floor(serverLimit))
|
|
175
|
-
: configuredSubscriptionCapacity;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function combinedDiagnostics() {
|
|
179
|
-
const output = {
|
|
180
|
-
queueDepth: 0,
|
|
181
|
-
queueCapacity: 0,
|
|
182
|
-
maxQueueDepth: 0,
|
|
183
|
-
enqueued: 0,
|
|
184
|
-
delivered: 0,
|
|
185
|
-
dropped: 0,
|
|
186
|
-
coalesced: 0,
|
|
187
|
-
batches: 0,
|
|
188
|
-
maxLagMs: 0,
|
|
189
|
-
subscriptionCount: node.subscriptions.size,
|
|
190
|
-
mode: config.queueMode || 'all',
|
|
191
|
-
overflowPolicy: config.overflowPolicy || 'drop-oldest',
|
|
192
|
-
};
|
|
193
|
-
for (const diagnostics of node.subscriptionDiagnostics.values()) {
|
|
194
|
-
output.queueDepth += Number(diagnostics.queueDepth) || 0;
|
|
195
|
-
output.queueCapacity += Number(diagnostics.queueCapacity) || 0;
|
|
196
|
-
output.maxQueueDepth += Number(diagnostics.maxQueueDepth) || 0;
|
|
197
|
-
output.enqueued += Number(diagnostics.enqueued) || 0;
|
|
198
|
-
output.delivered += Number(diagnostics.delivered) || 0;
|
|
199
|
-
output.dropped += Number(diagnostics.dropped) || 0;
|
|
200
|
-
output.coalesced += Number(diagnostics.coalesced) || 0;
|
|
201
|
-
output.batches += Number(diagnostics.batchSequence) || 0;
|
|
202
|
-
output.maxLagMs = Math.max(output.maxLagMs, Number(diagnostics.maxLagMs) || 0);
|
|
203
|
-
}
|
|
204
|
-
return output;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function updateQueueWarning(diagnostics) {
|
|
208
|
-
const dropped = Number(diagnostics.dropped) || 0;
|
|
209
|
-
if (dropped > node.lastDropped) {
|
|
210
|
-
diagnostics.emit('warn', 'queue_overflow', 'OPC UA notification queue dropped changes', { dropped });
|
|
211
|
-
node.warn(`OPC UA notification queue dropped ${dropped - node.lastDropped} change(s); total ${dropped}`);
|
|
212
|
-
}
|
|
213
|
-
node.lastDropped = dropped;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function sendBatch(changes) {
|
|
217
|
-
let accepted = 0;
|
|
218
|
-
for (let index = 0; index < changes.length; index += 1) {
|
|
219
|
-
if (node.desired.has(changes[index].nodeId)) {
|
|
220
|
-
changes[accepted] = changes[index];
|
|
221
|
-
accepted += 1;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
changes.length = accepted;
|
|
225
|
-
if (!changes.length || node.closing) return;
|
|
226
|
-
const diagnostics = combinedDiagnostics();
|
|
227
|
-
updateQueueWarning(diagnostics);
|
|
228
|
-
const output = {
|
|
229
|
-
payload: resultDetail === 'value' ? changes.map(valueOnly) : changes,
|
|
230
|
-
opcua: {
|
|
231
|
-
operation: 'change',
|
|
232
|
-
total: changes.length,
|
|
233
|
-
subscriptionCount: node.subscriptions.size,
|
|
234
|
-
resultDetail,
|
|
235
|
-
queue: diagnostics,
|
|
236
|
-
},
|
|
237
|
-
};
|
|
238
|
-
if (resultDetail === 'value') {
|
|
239
|
-
// Subscription batches are sparse and may repeat one NodeId. A parallel
|
|
240
|
-
// identifier array preserves every change without repeating objects.
|
|
241
|
-
output.opcua.identifiers = changes.map((change) => change.alias || change.nodeId);
|
|
242
|
-
}
|
|
243
|
-
node.send({
|
|
244
|
-
...output,
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function clearFlushTimer() {
|
|
249
|
-
if (node.flushTimer) clearTimeout(node.flushTimer);
|
|
250
|
-
node.flushTimer = null;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function scheduleFlush() {
|
|
254
|
-
if (node.flushTimer || !node.pendingChanges.length || node.closing) return;
|
|
255
|
-
node.flushTimer = setTimeout(() => {
|
|
256
|
-
node.flushTimer = null;
|
|
257
|
-
while (node.pendingChanges.length) {
|
|
258
|
-
sendBatch(node.pendingChanges.splice(0, callbackBatchSize));
|
|
259
|
-
}
|
|
260
|
-
}, callbackFlushIntervalMs);
|
|
261
|
-
node.flushTimer.unref?.();
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function flushFullBatches() {
|
|
265
|
-
while (node.pendingChanges.length >= callbackBatchSize) {
|
|
266
|
-
sendBatch(node.pendingChanges.splice(0, callbackBatchSize));
|
|
267
|
-
}
|
|
268
|
-
if (!node.pendingChanges.length) clearFlushTimer();
|
|
269
|
-
else scheduleFlush();
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function discardPendingChanges() {
|
|
273
|
-
clearFlushTimer();
|
|
274
|
-
node.pendingChanges.length = 0;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function onNativeBatch(record, batch) {
|
|
278
|
-
if (node.closing || !record.id || !node.subscriptions.has(record.id)) return;
|
|
279
|
-
const queueDiagnostics = batch.diagnostics || {};
|
|
280
|
-
node.subscriptionDiagnostics.set(record.id, queueDiagnostics);
|
|
281
|
-
const sourceChanges = batch.changes || [];
|
|
282
|
-
diagnostics.results(sourceChanges.filter((change) => node.desired.has(change.nodeId)), undefined, node.desired);
|
|
283
|
-
const changes = [];
|
|
284
|
-
for (let index = 0; index < sourceChanges.length; index += 1) {
|
|
285
|
-
const change = sourceChanges[index];
|
|
286
|
-
const point = node.desired.get(change.nodeId);
|
|
287
|
-
if (!point) continue;
|
|
288
|
-
changes.push(resultDetail === 'compact'
|
|
289
|
-
? compactChange(change, point)
|
|
290
|
-
: resultDetail === 'value'
|
|
291
|
-
? valueChange(change, point)
|
|
292
|
-
: fullChange(change, point, record.id));
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if ((config.outputMode || 'batch') === 'single') {
|
|
296
|
-
const queue = combinedDiagnostics();
|
|
297
|
-
updateQueueWarning(queue);
|
|
298
|
-
changes.forEach((change, index) => {
|
|
299
|
-
const output = {
|
|
300
|
-
topic: change.nodeId,
|
|
301
|
-
payload: valueOnly(change),
|
|
302
|
-
};
|
|
303
|
-
if (resultDetail === 'value') {
|
|
304
|
-
if (index === 0) output.opcua = { operation: 'change', resultDetail, queue };
|
|
305
|
-
} else output.opcua = index === 0 ? { ...change, queue } : change;
|
|
306
|
-
node.send(output);
|
|
307
|
-
});
|
|
308
|
-
} else if (changes.length) {
|
|
309
|
-
node.pendingChanges.push(...changes);
|
|
310
|
-
flushFullBatches();
|
|
311
|
-
scheduleFlush();
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const now = Date.now();
|
|
315
|
-
const queue = combinedDiagnostics();
|
|
316
|
-
if (queue.dropped > 0 || now - node.lastStatusAt >= 1000) {
|
|
317
|
-
node.lastStatusAt = now;
|
|
318
|
-
node.status({
|
|
319
|
-
fill: queue.dropped > 0 ? 'yellow' : 'green',
|
|
320
|
-
shape: queue.dropped > 0 ? 'ring' : 'dot',
|
|
321
|
-
text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub q:${queue.queueDepth}/${queue.queueCapacity}`,
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
async function createSubscription() {
|
|
327
|
-
const record = {
|
|
328
|
-
id: null,
|
|
329
|
-
monitored: new Map(),
|
|
330
|
-
limit: subscriptionCapacity(),
|
|
331
|
-
revisedPublishingInterval: null,
|
|
332
|
-
revisedLifetimeCount: null,
|
|
333
|
-
revisedMaxKeepAliveCount: null,
|
|
334
|
-
};
|
|
335
|
-
const result = await node.connection.client.createSubscription({
|
|
336
|
-
publishingInterval: Number(config.publishingInterval) || 1000,
|
|
337
|
-
lifetimeCount: Number(config.lifetimeCount) || 120,
|
|
338
|
-
maxKeepAliveCount: Number(config.maxKeepAliveCount) || 10,
|
|
339
|
-
maxNotificationsPerPublish: Number(config.maxNotificationsPerPublish) || 0,
|
|
340
|
-
callbackQueueSize: positiveInteger(config.callbackQueueSize, 10000),
|
|
341
|
-
callbackBatchSize,
|
|
342
|
-
queueMode: config.queueMode || 'all',
|
|
343
|
-
overflowPolicy: config.overflowPolicy || 'drop-oldest',
|
|
344
|
-
}, (batch) => onNativeBatch(record, batch));
|
|
345
|
-
record.id = result.subscriptionId;
|
|
346
|
-
record.revisedPublishingInterval = result.revisedPublishingInterval ?? null;
|
|
347
|
-
record.revisedLifetimeCount = result.revisedLifetimeCount ?? null;
|
|
348
|
-
record.revisedMaxKeepAliveCount = result.revisedMaxKeepAliveCount ?? null;
|
|
349
|
-
node.subscriptions.set(record.id, record);
|
|
350
|
-
return record;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
async function addMonitoredItems(record, points) {
|
|
354
|
-
if (!points.length) return [];
|
|
355
|
-
const results = await node.connection.client.addMonitoredItems(
|
|
356
|
-
record.id,
|
|
357
|
-
points,
|
|
358
|
-
{
|
|
359
|
-
samplingInterval: Number(config.samplingInterval) || 1000,
|
|
360
|
-
queueSize: Number(config.queueSize) || 10,
|
|
361
|
-
discardOldest: config.discardOldest !== false,
|
|
362
|
-
deadbandType: siemensWincc ? 'none' : config.deadbandType || 'none',
|
|
363
|
-
deadbandValue: siemensWincc ? 0 : Number(config.deadbandValue) || 0,
|
|
364
|
-
timestampMode: config.timestampMode || 'source',
|
|
365
|
-
},
|
|
366
|
-
batchSize
|
|
367
|
-
);
|
|
368
|
-
results.forEach((result) => {
|
|
369
|
-
result.subscriptionId = record.id;
|
|
370
|
-
if (result.statusCode !== 'Good') return;
|
|
371
|
-
const monitored = {
|
|
372
|
-
subscriptionId: record.id,
|
|
373
|
-
monitoredItemId: result.monitoredItemId,
|
|
374
|
-
clientHandle: result.clientHandle,
|
|
375
|
-
revisedSamplingInterval: result.revisedSamplingInterval ?? null,
|
|
376
|
-
revisedQueueSize: result.revisedQueueSize ?? null,
|
|
377
|
-
};
|
|
378
|
-
record.monitored.set(result.nodeId, monitored);
|
|
379
|
-
node.monitored.set(result.nodeId, monitored);
|
|
380
|
-
});
|
|
381
|
-
return results;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
function findAvailableSubscription() {
|
|
385
|
-
return [...node.subscriptions.values()].find(
|
|
386
|
-
(record) => record.monitored.size < record.limit
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
async function addDesiredPoints(points) {
|
|
391
|
-
const remaining = [...points];
|
|
392
|
-
const failures = [];
|
|
393
|
-
while (remaining.length) {
|
|
394
|
-
const record = findAvailableSubscription() || await createSubscription();
|
|
395
|
-
const room = Math.max(0, record.limit - record.monitored.size);
|
|
396
|
-
if (!room) continue;
|
|
397
|
-
const candidates = remaining.splice(0, room);
|
|
398
|
-
const byNodeId = new Map(candidates.map((point) => [point.nodeId, point]));
|
|
399
|
-
const results = await addMonitoredItems(record, candidates);
|
|
400
|
-
const returnedNodeIds = new Set(results.map((result) => result.nodeId));
|
|
401
|
-
for (const point of candidates) {
|
|
402
|
-
if (!returnedNodeIds.has(point.nodeId)) {
|
|
403
|
-
failures.push({ nodeId: point.nodeId, statusCode: 'BadUnexpectedError' });
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
const capacityFailures = [];
|
|
407
|
-
results.forEach((result) => {
|
|
408
|
-
if (result.statusCode === 'Good') return;
|
|
409
|
-
const point = byNodeId.get(result.nodeId);
|
|
410
|
-
if (/TooManyMonitoredItems/i.test(result.statusCode) && point) {
|
|
411
|
-
capacityFailures.push(point);
|
|
412
|
-
} else failures.push(result);
|
|
413
|
-
});
|
|
414
|
-
if (capacityFailures.length) {
|
|
415
|
-
if (record.monitored.size === 0) {
|
|
416
|
-
failures.push(...capacityFailures.map((point) => ({
|
|
417
|
-
nodeId: point.nodeId,
|
|
418
|
-
statusCode: 'BadTooManyMonitoredItems',
|
|
419
|
-
})));
|
|
420
|
-
} else {
|
|
421
|
-
record.limit = record.monitored.size;
|
|
422
|
-
remaining.unshift(...capacityFailures);
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
return failures;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
async function removeMonitoredPoints(nodeIds) {
|
|
430
|
-
const grouped = new Map();
|
|
431
|
-
for (const [nodeId, monitored] of node.monitored) {
|
|
432
|
-
if (!nodeIds.has(nodeId)) continue;
|
|
433
|
-
if (!grouped.has(monitored.subscriptionId)) grouped.set(monitored.subscriptionId, []);
|
|
434
|
-
grouped.get(monitored.subscriptionId).push({ nodeId, ...monitored });
|
|
435
|
-
}
|
|
436
|
-
for (const [subscriptionId, removals] of grouped) {
|
|
437
|
-
const record = node.subscriptions.get(subscriptionId);
|
|
438
|
-
if (!record) continue;
|
|
439
|
-
const results = await node.connection.client.removeMonitoredItems(
|
|
440
|
-
subscriptionId,
|
|
441
|
-
removals.map((item) => item.monitoredItemId),
|
|
442
|
-
batchSize
|
|
443
|
-
);
|
|
444
|
-
results.forEach((result, index) => {
|
|
445
|
-
if (result.statusCode !== 'Good') return;
|
|
446
|
-
const nodeId = removals[index].nodeId;
|
|
447
|
-
record.monitored.delete(nodeId);
|
|
448
|
-
node.monitored.delete(nodeId);
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
async function removeUndesiredPoints() {
|
|
454
|
-
const nodeIds = new Set(
|
|
455
|
-
[...node.monitored.keys()].filter((nodeId) => !node.desired.has(nodeId))
|
|
456
|
-
);
|
|
457
|
-
await removeMonitoredPoints(nodeIds);
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
async function pruneEmptySubscriptions() {
|
|
461
|
-
const empty = [...node.subscriptions.values()].filter(
|
|
462
|
-
(record) => record.monitored.size === 0
|
|
463
|
-
);
|
|
464
|
-
for (const record of empty) {
|
|
465
|
-
if (node.subscriptions.size <= 1 && node.desired.size > 0) break;
|
|
466
|
-
node.subscriptions.delete(record.id);
|
|
467
|
-
node.subscriptionDiagnostics.delete(record.id);
|
|
468
|
-
await node.connection.client.deleteSubscription(record.id);
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
async function clearSubscriptions(keepDesired = false, ignoreErrors = false) {
|
|
473
|
-
if (!keepDesired) {
|
|
474
|
-
node.desired.clear();
|
|
475
|
-
node.failed.clear();
|
|
476
|
-
}
|
|
477
|
-
discardPendingChanges();
|
|
478
|
-
const records = [...node.subscriptions.values()];
|
|
479
|
-
node.subscriptions.clear();
|
|
480
|
-
node.monitored.clear();
|
|
481
|
-
node.subscriptionDiagnostics.clear();
|
|
482
|
-
const results = await Promise.allSettled(
|
|
483
|
-
records.map((record) => node.connection.client.deleteSubscription(record.id))
|
|
484
|
-
);
|
|
485
|
-
if (!ignoreErrors) {
|
|
486
|
-
const rejected = results.find((result) => result.status === 'rejected');
|
|
487
|
-
if (rejected) throw rejected.reason;
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
async function validateSubscriptions() {
|
|
492
|
-
for (const record of node.subscriptions.values()) {
|
|
493
|
-
const validation = await node.connection.client.validateSubscription(record.id);
|
|
494
|
-
if (!validation.valid) return false;
|
|
495
|
-
}
|
|
496
|
-
return true;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
function failurePayload() {
|
|
500
|
-
const failures = [...node.failed.values()];
|
|
501
|
-
return {
|
|
502
|
-
failed: failures.length,
|
|
503
|
-
failures: failures.slice(0, 100),
|
|
504
|
-
failuresTruncated: failures.length > 100,
|
|
505
|
-
};
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
function statusPayload(command) {
|
|
509
|
-
const ids = subscriptionIds();
|
|
510
|
-
const monitoredValues = [...node.monitored.values()];
|
|
511
|
-
const revisedSampling = numericRange(monitoredValues, 'revisedSamplingInterval');
|
|
512
|
-
const revisedQueues = numericRange(monitoredValues, 'revisedQueueSize');
|
|
513
|
-
return {
|
|
514
|
-
command,
|
|
515
|
-
desired: node.desired.size,
|
|
516
|
-
monitored: node.monitored.size,
|
|
517
|
-
...failurePayload(),
|
|
518
|
-
subscriptionCount: ids.length,
|
|
519
|
-
subscriptionId: ids[0] ?? null,
|
|
520
|
-
subscriptionIds: ids,
|
|
521
|
-
revisions: [...node.subscriptions.values()].map((record) => ({
|
|
522
|
-
subscriptionId: record.id,
|
|
523
|
-
publishingInterval: record.revisedPublishingInterval,
|
|
524
|
-
lifetimeCount: record.revisedLifetimeCount,
|
|
525
|
-
maxKeepAliveCount: record.revisedMaxKeepAliveCount,
|
|
526
|
-
})),
|
|
527
|
-
monitoredRevisions: {
|
|
528
|
-
count: Math.max(revisedSampling.count, revisedQueues.count),
|
|
529
|
-
samplingIntervalMin: revisedSampling.minimum,
|
|
530
|
-
samplingIntervalMax: revisedSampling.maximum,
|
|
531
|
-
queueSizeMin: revisedQueues.minimum,
|
|
532
|
-
queueSizeMax: revisedQueues.maximum,
|
|
533
|
-
},
|
|
534
|
-
compatibility: {
|
|
535
|
-
profile: siemensWincc ? 'siemens-wincc' : 'standard',
|
|
536
|
-
deadbandSupported: !siemensWincc,
|
|
537
|
-
monitoredItemsPerSubscription: configuredSubscriptionCapacity,
|
|
538
|
-
},
|
|
539
|
-
connectionGovernance: node.connection?.getConnectionGovernance?.(),
|
|
540
|
-
connectionUsage: node.connection?.getConnectionUsage?.(),
|
|
541
|
-
};
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
async function reconcileSubscriptions(reason = 'state') {
|
|
545
|
-
if (node.closing) return;
|
|
546
|
-
consumer?.markUsed('subscribe-reconcile');
|
|
547
|
-
if (!node.desired.size) {
|
|
548
|
-
if (node.subscriptions.size) await clearSubscriptions(true);
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
let rebuilt = false;
|
|
553
|
-
if (node.subscriptions.size) {
|
|
554
|
-
let valid = false;
|
|
555
|
-
try { valid = await validateSubscriptions(); } catch (error) {
|
|
556
|
-
diagnostics.failure(error, undefined, 'subscription_validation_failed', true);
|
|
557
|
-
}
|
|
558
|
-
if (!valid) {
|
|
559
|
-
await clearSubscriptions(true, true);
|
|
560
|
-
rebuilt = true;
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
await removeUndesiredPoints();
|
|
565
|
-
const additions = [...node.desired.values()].filter(
|
|
566
|
-
(point) => !node.monitored.has(point.nodeId)
|
|
567
|
-
);
|
|
568
|
-
const failures = await addDesiredPoints(additions);
|
|
569
|
-
for (const point of additions) node.failed.delete(point.nodeId);
|
|
570
|
-
for (const failure of failures) {
|
|
571
|
-
node.failed.set(failure.nodeId, {
|
|
572
|
-
nodeId: failure.nodeId,
|
|
573
|
-
statusCode: failure.statusCode || 'BadUnexpectedError',
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
await pruneEmptySubscriptions();
|
|
577
|
-
|
|
578
|
-
diagnostics.results(failures, undefined, node.desired);
|
|
579
|
-
|
|
580
|
-
node.
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
node.reconcileChain
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
const
|
|
635
|
-
node.reconcileAttempt
|
|
636
|
-
node.
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
if (
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
node.connection?.client.on('
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
const
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
node.
|
|
746
|
-
node.
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
node.
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createDiagnostics } = require('../lib/diagnostics');
|
|
4
|
+
|
|
5
|
+
const { looksLikePointList, messagePoints } = require('../lib/points');
|
|
6
|
+
const { SIEMENS_WINCC_DEFAULTS, isSiemensWinCC } = require('../lib/compatibility');
|
|
7
|
+
|
|
8
|
+
const NOTIFICATION_CONFIG_FIELDS = new Set([
|
|
9
|
+
'nodeId',
|
|
10
|
+
'samplingInterval',
|
|
11
|
+
'queueSize',
|
|
12
|
+
'discardOldest',
|
|
13
|
+
'deadbandType',
|
|
14
|
+
'deadbandValue',
|
|
15
|
+
'enabled',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const COMPACT_NOTIFICATION_FIELDS = Object.freeze([
|
|
19
|
+
'nodeId',
|
|
20
|
+
'value',
|
|
21
|
+
'statusCode',
|
|
22
|
+
'sourceTimestamp',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function valueOnly(change) {
|
|
26
|
+
return Object.prototype.hasOwnProperty.call(change, 'value') ? change.value : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function compactChange(change, point) {
|
|
30
|
+
const output = {};
|
|
31
|
+
for (const field of COMPACT_NOTIFICATION_FIELDS) {
|
|
32
|
+
if (Object.prototype.hasOwnProperty.call(change, field)) output[field] = change[field];
|
|
33
|
+
}
|
|
34
|
+
if (point.alias != null && point.alias !== '') output.alias = point.alias;
|
|
35
|
+
return output;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function valueChange(change, point) {
|
|
39
|
+
return {
|
|
40
|
+
nodeId: change.nodeId,
|
|
41
|
+
...(point.alias != null && point.alias !== '' ? { alias: point.alias } : {}),
|
|
42
|
+
...(Object.prototype.hasOwnProperty.call(change, 'value') ? { value: change.value } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fullChange(change, point, subscriptionId) {
|
|
47
|
+
change.subscriptionId = subscriptionId;
|
|
48
|
+
for (const key in point) {
|
|
49
|
+
if (!NOTIFICATION_CONFIG_FIELDS.has(key) && !Object.hasOwn(change, key)) {
|
|
50
|
+
change[key] = point[key];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return change;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function positiveInteger(value, fallback) {
|
|
57
|
+
const number = Number(value);
|
|
58
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function monitoringSettings(point, config, siemensWincc = false) {
|
|
62
|
+
return {
|
|
63
|
+
samplingInterval: Number(point.samplingInterval) || Number(config.samplingInterval) || 1000,
|
|
64
|
+
queueSize: positiveInteger(point.queueSize, positiveInteger(config.queueSize, 1)),
|
|
65
|
+
discardOldest: point.discardOldest !== false,
|
|
66
|
+
deadbandType: siemensWincc ? 'none' : point.deadbandType || config.deadbandType || 'none',
|
|
67
|
+
deadbandValue: siemensWincc ? 0 : Number(point.deadbandValue ?? config.deadbandValue) || 0,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sameMonitoringSettings(left, right, config, siemensWincc = false) {
|
|
72
|
+
const leftSettings = monitoringSettings(left, config, siemensWincc);
|
|
73
|
+
const rightSettings = monitoringSettings(right, config, siemensWincc);
|
|
74
|
+
return Object.keys(leftSettings).every(
|
|
75
|
+
(key) => leftSettings[key] === rightSettings[key]
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function numericRange(items, field) {
|
|
80
|
+
let count = 0;
|
|
81
|
+
let minimum = Infinity;
|
|
82
|
+
let maximum = -Infinity;
|
|
83
|
+
for (const item of items) {
|
|
84
|
+
if (item[field] == null) continue;
|
|
85
|
+
const value = Number(item[field]);
|
|
86
|
+
if (!Number.isFinite(value)) continue;
|
|
87
|
+
count += 1;
|
|
88
|
+
minimum = Math.min(minimum, value);
|
|
89
|
+
maximum = Math.max(maximum, value);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
count,
|
|
93
|
+
minimum: count ? minimum : null,
|
|
94
|
+
maximum: count ? maximum : null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = function registerSubscribeNode(RED) {
|
|
99
|
+
function OpcUaSubscribeNode(config) {
|
|
100
|
+
RED.nodes.createNode(this, config);
|
|
101
|
+
const node = this;
|
|
102
|
+
node.connection = RED.nodes.getNode(config.connection);
|
|
103
|
+
const diagnostics = createDiagnostics(node, config, 'subscribe');
|
|
104
|
+
node.subscriptions = new Map();
|
|
105
|
+
node.desired = new Map();
|
|
106
|
+
node.monitored = new Map();
|
|
107
|
+
node.failed = new Map();
|
|
108
|
+
node.subscriptionDiagnostics = new Map();
|
|
109
|
+
node.pendingChanges = [];
|
|
110
|
+
node.flushTimer = null;
|
|
111
|
+
node.reconnectTimer = null;
|
|
112
|
+
node.reconcileChain = Promise.resolve();
|
|
113
|
+
node.reconcileAttempt = 0;
|
|
114
|
+
node.lastSessionGeneration = 0;
|
|
115
|
+
node.closing = false;
|
|
116
|
+
node.lastDropped = 0;
|
|
117
|
+
node.lastStatusAt = 0;
|
|
118
|
+
node.compatibilityWarningIssued = false;
|
|
119
|
+
const consumer = node.connection?.registerConsumer?.('subscribe', {
|
|
120
|
+
nodeId: config.id, name: config.name,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const batchSize = positiveInteger(config.batchSize, 500);
|
|
124
|
+
const callbackBatchSize = positiveInteger(config.callbackBatchSize, 500);
|
|
125
|
+
const callbackFlushIntervalMs = positiveInteger(config.callbackFlushIntervalMs, 50);
|
|
126
|
+
const siemensWincc = isSiemensWinCC(
|
|
127
|
+
node.connection?.client?.options?.compatibilityProfile
|
|
128
|
+
);
|
|
129
|
+
const requestedSubscriptionCapacity = positiveInteger(config.maxItemsPerSubscription, 5000);
|
|
130
|
+
const configuredSubscriptionCapacity = siemensWincc
|
|
131
|
+
? Math.min(
|
|
132
|
+
requestedSubscriptionCapacity,
|
|
133
|
+
SIEMENS_WINCC_DEFAULTS.monitoredItemsPerSubscription
|
|
134
|
+
)
|
|
135
|
+
: requestedSubscriptionCapacity;
|
|
136
|
+
// Undefined preserves the descriptor-rich output used by Flow JSON created
|
|
137
|
+
// before resultDetail existed. New editor nodes explicitly save compact.
|
|
138
|
+
const resultDetail = ['compact', 'value', 'full'].includes(config.resultDetail)
|
|
139
|
+
? config.resultDetail
|
|
140
|
+
: 'full';
|
|
141
|
+
|
|
142
|
+
function subscriptionIds() {
|
|
143
|
+
return [...node.subscriptions.keys()];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function applyCompatibility(points) {
|
|
147
|
+
if (!siemensWincc) return points;
|
|
148
|
+
let removedDeadband = false;
|
|
149
|
+
const compatible = points.map((point) => {
|
|
150
|
+
const configuredType = point.deadbandType || config.deadbandType || 'none';
|
|
151
|
+
if (configuredType === 'none') return point;
|
|
152
|
+
removedDeadband = true;
|
|
153
|
+
return { ...point, deadbandType: 'none', deadbandValue: 0 };
|
|
154
|
+
});
|
|
155
|
+
if (removedDeadband && !node.compatibilityWarningIssued) {
|
|
156
|
+
node.compatibilityWarningIssued = true;
|
|
157
|
+
node.warn(
|
|
158
|
+
'Siemens WinCC does not support OPC UA DataChange Deadband filters; Deadband was disabled.'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return compatible;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function primarySubscriptionId() {
|
|
165
|
+
return subscriptionIds()[0] ?? null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function subscriptionCapacity() {
|
|
169
|
+
const diagnostics = node.connection?.client.getDiagnostics?.() || {};
|
|
170
|
+
const serverLimit = Number(
|
|
171
|
+
diagnostics.operationLimits?.maxMonitoredItemsPerSubscription
|
|
172
|
+
);
|
|
173
|
+
return Number.isFinite(serverLimit) && serverLimit > 0
|
|
174
|
+
? Math.min(configuredSubscriptionCapacity, Math.floor(serverLimit))
|
|
175
|
+
: configuredSubscriptionCapacity;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function combinedDiagnostics() {
|
|
179
|
+
const output = {
|
|
180
|
+
queueDepth: 0,
|
|
181
|
+
queueCapacity: 0,
|
|
182
|
+
maxQueueDepth: 0,
|
|
183
|
+
enqueued: 0,
|
|
184
|
+
delivered: 0,
|
|
185
|
+
dropped: 0,
|
|
186
|
+
coalesced: 0,
|
|
187
|
+
batches: 0,
|
|
188
|
+
maxLagMs: 0,
|
|
189
|
+
subscriptionCount: node.subscriptions.size,
|
|
190
|
+
mode: config.queueMode || 'all',
|
|
191
|
+
overflowPolicy: config.overflowPolicy || 'drop-oldest',
|
|
192
|
+
};
|
|
193
|
+
for (const diagnostics of node.subscriptionDiagnostics.values()) {
|
|
194
|
+
output.queueDepth += Number(diagnostics.queueDepth) || 0;
|
|
195
|
+
output.queueCapacity += Number(diagnostics.queueCapacity) || 0;
|
|
196
|
+
output.maxQueueDepth += Number(diagnostics.maxQueueDepth) || 0;
|
|
197
|
+
output.enqueued += Number(diagnostics.enqueued) || 0;
|
|
198
|
+
output.delivered += Number(diagnostics.delivered) || 0;
|
|
199
|
+
output.dropped += Number(diagnostics.dropped) || 0;
|
|
200
|
+
output.coalesced += Number(diagnostics.coalesced) || 0;
|
|
201
|
+
output.batches += Number(diagnostics.batchSequence) || 0;
|
|
202
|
+
output.maxLagMs = Math.max(output.maxLagMs, Number(diagnostics.maxLagMs) || 0);
|
|
203
|
+
}
|
|
204
|
+
return output;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function updateQueueWarning(diagnostics) {
|
|
208
|
+
const dropped = Number(diagnostics.dropped) || 0;
|
|
209
|
+
if (dropped > node.lastDropped) {
|
|
210
|
+
diagnostics.emit('warn', 'queue_overflow', 'OPC UA notification queue dropped changes', { dropped });
|
|
211
|
+
node.warn(`OPC UA notification queue dropped ${dropped - node.lastDropped} change(s); total ${dropped}`);
|
|
212
|
+
}
|
|
213
|
+
node.lastDropped = dropped;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function sendBatch(changes) {
|
|
217
|
+
let accepted = 0;
|
|
218
|
+
for (let index = 0; index < changes.length; index += 1) {
|
|
219
|
+
if (node.desired.has(changes[index].nodeId)) {
|
|
220
|
+
changes[accepted] = changes[index];
|
|
221
|
+
accepted += 1;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
changes.length = accepted;
|
|
225
|
+
if (!changes.length || node.closing) return;
|
|
226
|
+
const diagnostics = combinedDiagnostics();
|
|
227
|
+
updateQueueWarning(diagnostics);
|
|
228
|
+
const output = {
|
|
229
|
+
payload: resultDetail === 'value' ? changes.map(valueOnly) : changes,
|
|
230
|
+
opcua: {
|
|
231
|
+
operation: 'change',
|
|
232
|
+
total: changes.length,
|
|
233
|
+
subscriptionCount: node.subscriptions.size,
|
|
234
|
+
resultDetail,
|
|
235
|
+
queue: diagnostics,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
if (resultDetail === 'value') {
|
|
239
|
+
// Subscription batches are sparse and may repeat one NodeId. A parallel
|
|
240
|
+
// identifier array preserves every change without repeating objects.
|
|
241
|
+
output.opcua.identifiers = changes.map((change) => change.alias || change.nodeId);
|
|
242
|
+
}
|
|
243
|
+
node.send({
|
|
244
|
+
...output,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function clearFlushTimer() {
|
|
249
|
+
if (node.flushTimer) clearTimeout(node.flushTimer);
|
|
250
|
+
node.flushTimer = null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function scheduleFlush() {
|
|
254
|
+
if (node.flushTimer || !node.pendingChanges.length || node.closing) return;
|
|
255
|
+
node.flushTimer = setTimeout(() => {
|
|
256
|
+
node.flushTimer = null;
|
|
257
|
+
while (node.pendingChanges.length) {
|
|
258
|
+
sendBatch(node.pendingChanges.splice(0, callbackBatchSize));
|
|
259
|
+
}
|
|
260
|
+
}, callbackFlushIntervalMs);
|
|
261
|
+
node.flushTimer.unref?.();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function flushFullBatches() {
|
|
265
|
+
while (node.pendingChanges.length >= callbackBatchSize) {
|
|
266
|
+
sendBatch(node.pendingChanges.splice(0, callbackBatchSize));
|
|
267
|
+
}
|
|
268
|
+
if (!node.pendingChanges.length) clearFlushTimer();
|
|
269
|
+
else scheduleFlush();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function discardPendingChanges() {
|
|
273
|
+
clearFlushTimer();
|
|
274
|
+
node.pendingChanges.length = 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function onNativeBatch(record, batch) {
|
|
278
|
+
if (node.closing || !record.id || !node.subscriptions.has(record.id)) return;
|
|
279
|
+
const queueDiagnostics = batch.diagnostics || {};
|
|
280
|
+
node.subscriptionDiagnostics.set(record.id, queueDiagnostics);
|
|
281
|
+
const sourceChanges = batch.changes || [];
|
|
282
|
+
diagnostics.results(sourceChanges.filter((change) => node.desired.has(change.nodeId)), undefined, node.desired);
|
|
283
|
+
const changes = [];
|
|
284
|
+
for (let index = 0; index < sourceChanges.length; index += 1) {
|
|
285
|
+
const change = sourceChanges[index];
|
|
286
|
+
const point = node.desired.get(change.nodeId);
|
|
287
|
+
if (!point) continue;
|
|
288
|
+
changes.push(resultDetail === 'compact'
|
|
289
|
+
? compactChange(change, point)
|
|
290
|
+
: resultDetail === 'value'
|
|
291
|
+
? valueChange(change, point)
|
|
292
|
+
: fullChange(change, point, record.id));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if ((config.outputMode || 'batch') === 'single') {
|
|
296
|
+
const queue = combinedDiagnostics();
|
|
297
|
+
updateQueueWarning(queue);
|
|
298
|
+
changes.forEach((change, index) => {
|
|
299
|
+
const output = {
|
|
300
|
+
topic: change.nodeId,
|
|
301
|
+
payload: valueOnly(change),
|
|
302
|
+
};
|
|
303
|
+
if (resultDetail === 'value') {
|
|
304
|
+
if (index === 0) output.opcua = { operation: 'change', resultDetail, queue };
|
|
305
|
+
} else output.opcua = index === 0 ? { ...change, queue } : change;
|
|
306
|
+
node.send(output);
|
|
307
|
+
});
|
|
308
|
+
} else if (changes.length) {
|
|
309
|
+
node.pendingChanges.push(...changes);
|
|
310
|
+
flushFullBatches();
|
|
311
|
+
scheduleFlush();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const now = Date.now();
|
|
315
|
+
const queue = combinedDiagnostics();
|
|
316
|
+
if (queue.dropped > 0 || now - node.lastStatusAt >= 1000) {
|
|
317
|
+
node.lastStatusAt = now;
|
|
318
|
+
node.status({
|
|
319
|
+
fill: queue.dropped > 0 ? 'yellow' : 'green',
|
|
320
|
+
shape: queue.dropped > 0 ? 'ring' : 'dot',
|
|
321
|
+
text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub q:${queue.queueDepth}/${queue.queueCapacity}`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function createSubscription() {
|
|
327
|
+
const record = {
|
|
328
|
+
id: null,
|
|
329
|
+
monitored: new Map(),
|
|
330
|
+
limit: subscriptionCapacity(),
|
|
331
|
+
revisedPublishingInterval: null,
|
|
332
|
+
revisedLifetimeCount: null,
|
|
333
|
+
revisedMaxKeepAliveCount: null,
|
|
334
|
+
};
|
|
335
|
+
const result = await node.connection.client.createSubscription({
|
|
336
|
+
publishingInterval: Number(config.publishingInterval) || 1000,
|
|
337
|
+
lifetimeCount: Number(config.lifetimeCount) || 120,
|
|
338
|
+
maxKeepAliveCount: Number(config.maxKeepAliveCount) || 10,
|
|
339
|
+
maxNotificationsPerPublish: Number(config.maxNotificationsPerPublish) || 0,
|
|
340
|
+
callbackQueueSize: positiveInteger(config.callbackQueueSize, 10000),
|
|
341
|
+
callbackBatchSize,
|
|
342
|
+
queueMode: config.queueMode || 'all',
|
|
343
|
+
overflowPolicy: config.overflowPolicy || 'drop-oldest',
|
|
344
|
+
}, (batch) => onNativeBatch(record, batch));
|
|
345
|
+
record.id = result.subscriptionId;
|
|
346
|
+
record.revisedPublishingInterval = result.revisedPublishingInterval ?? null;
|
|
347
|
+
record.revisedLifetimeCount = result.revisedLifetimeCount ?? null;
|
|
348
|
+
record.revisedMaxKeepAliveCount = result.revisedMaxKeepAliveCount ?? null;
|
|
349
|
+
node.subscriptions.set(record.id, record);
|
|
350
|
+
return record;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function addMonitoredItems(record, points) {
|
|
354
|
+
if (!points.length) return [];
|
|
355
|
+
const results = await node.connection.client.addMonitoredItems(
|
|
356
|
+
record.id,
|
|
357
|
+
points,
|
|
358
|
+
{
|
|
359
|
+
samplingInterval: Number(config.samplingInterval) || 1000,
|
|
360
|
+
queueSize: Number(config.queueSize) || 10,
|
|
361
|
+
discardOldest: config.discardOldest !== false,
|
|
362
|
+
deadbandType: siemensWincc ? 'none' : config.deadbandType || 'none',
|
|
363
|
+
deadbandValue: siemensWincc ? 0 : Number(config.deadbandValue) || 0,
|
|
364
|
+
timestampMode: config.timestampMode || 'source',
|
|
365
|
+
},
|
|
366
|
+
batchSize
|
|
367
|
+
);
|
|
368
|
+
results.forEach((result) => {
|
|
369
|
+
result.subscriptionId = record.id;
|
|
370
|
+
if (result.statusCode !== 'Good') return;
|
|
371
|
+
const monitored = {
|
|
372
|
+
subscriptionId: record.id,
|
|
373
|
+
monitoredItemId: result.monitoredItemId,
|
|
374
|
+
clientHandle: result.clientHandle,
|
|
375
|
+
revisedSamplingInterval: result.revisedSamplingInterval ?? null,
|
|
376
|
+
revisedQueueSize: result.revisedQueueSize ?? null,
|
|
377
|
+
};
|
|
378
|
+
record.monitored.set(result.nodeId, monitored);
|
|
379
|
+
node.monitored.set(result.nodeId, monitored);
|
|
380
|
+
});
|
|
381
|
+
return results;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function findAvailableSubscription() {
|
|
385
|
+
return [...node.subscriptions.values()].find(
|
|
386
|
+
(record) => record.monitored.size < record.limit
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function addDesiredPoints(points) {
|
|
391
|
+
const remaining = [...points];
|
|
392
|
+
const failures = [];
|
|
393
|
+
while (remaining.length) {
|
|
394
|
+
const record = findAvailableSubscription() || await createSubscription();
|
|
395
|
+
const room = Math.max(0, record.limit - record.monitored.size);
|
|
396
|
+
if (!room) continue;
|
|
397
|
+
const candidates = remaining.splice(0, room);
|
|
398
|
+
const byNodeId = new Map(candidates.map((point) => [point.nodeId, point]));
|
|
399
|
+
const results = await addMonitoredItems(record, candidates);
|
|
400
|
+
const returnedNodeIds = new Set(results.map((result) => result.nodeId));
|
|
401
|
+
for (const point of candidates) {
|
|
402
|
+
if (!returnedNodeIds.has(point.nodeId)) {
|
|
403
|
+
failures.push({ nodeId: point.nodeId, statusCode: 'BadUnexpectedError' });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const capacityFailures = [];
|
|
407
|
+
results.forEach((result) => {
|
|
408
|
+
if (result.statusCode === 'Good') return;
|
|
409
|
+
const point = byNodeId.get(result.nodeId);
|
|
410
|
+
if (/TooManyMonitoredItems/i.test(result.statusCode) && point) {
|
|
411
|
+
capacityFailures.push(point);
|
|
412
|
+
} else failures.push(result);
|
|
413
|
+
});
|
|
414
|
+
if (capacityFailures.length) {
|
|
415
|
+
if (record.monitored.size === 0) {
|
|
416
|
+
failures.push(...capacityFailures.map((point) => ({
|
|
417
|
+
nodeId: point.nodeId,
|
|
418
|
+
statusCode: 'BadTooManyMonitoredItems',
|
|
419
|
+
})));
|
|
420
|
+
} else {
|
|
421
|
+
record.limit = record.monitored.size;
|
|
422
|
+
remaining.unshift(...capacityFailures);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return failures;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function removeMonitoredPoints(nodeIds) {
|
|
430
|
+
const grouped = new Map();
|
|
431
|
+
for (const [nodeId, monitored] of node.monitored) {
|
|
432
|
+
if (!nodeIds.has(nodeId)) continue;
|
|
433
|
+
if (!grouped.has(monitored.subscriptionId)) grouped.set(monitored.subscriptionId, []);
|
|
434
|
+
grouped.get(monitored.subscriptionId).push({ nodeId, ...monitored });
|
|
435
|
+
}
|
|
436
|
+
for (const [subscriptionId, removals] of grouped) {
|
|
437
|
+
const record = node.subscriptions.get(subscriptionId);
|
|
438
|
+
if (!record) continue;
|
|
439
|
+
const results = await node.connection.client.removeMonitoredItems(
|
|
440
|
+
subscriptionId,
|
|
441
|
+
removals.map((item) => item.monitoredItemId),
|
|
442
|
+
batchSize
|
|
443
|
+
);
|
|
444
|
+
results.forEach((result, index) => {
|
|
445
|
+
if (result.statusCode !== 'Good') return;
|
|
446
|
+
const nodeId = removals[index].nodeId;
|
|
447
|
+
record.monitored.delete(nodeId);
|
|
448
|
+
node.monitored.delete(nodeId);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function removeUndesiredPoints() {
|
|
454
|
+
const nodeIds = new Set(
|
|
455
|
+
[...node.monitored.keys()].filter((nodeId) => !node.desired.has(nodeId))
|
|
456
|
+
);
|
|
457
|
+
await removeMonitoredPoints(nodeIds);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function pruneEmptySubscriptions() {
|
|
461
|
+
const empty = [...node.subscriptions.values()].filter(
|
|
462
|
+
(record) => record.monitored.size === 0
|
|
463
|
+
);
|
|
464
|
+
for (const record of empty) {
|
|
465
|
+
if (node.subscriptions.size <= 1 && node.desired.size > 0) break;
|
|
466
|
+
node.subscriptions.delete(record.id);
|
|
467
|
+
node.subscriptionDiagnostics.delete(record.id);
|
|
468
|
+
await node.connection.client.deleteSubscription(record.id);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function clearSubscriptions(keepDesired = false, ignoreErrors = false) {
|
|
473
|
+
if (!keepDesired) {
|
|
474
|
+
node.desired.clear();
|
|
475
|
+
node.failed.clear();
|
|
476
|
+
}
|
|
477
|
+
discardPendingChanges();
|
|
478
|
+
const records = [...node.subscriptions.values()];
|
|
479
|
+
node.subscriptions.clear();
|
|
480
|
+
node.monitored.clear();
|
|
481
|
+
node.subscriptionDiagnostics.clear();
|
|
482
|
+
const results = await Promise.allSettled(
|
|
483
|
+
records.map((record) => node.connection.client.deleteSubscription(record.id))
|
|
484
|
+
);
|
|
485
|
+
if (!ignoreErrors) {
|
|
486
|
+
const rejected = results.find((result) => result.status === 'rejected');
|
|
487
|
+
if (rejected) throw rejected.reason;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function validateSubscriptions() {
|
|
492
|
+
for (const record of node.subscriptions.values()) {
|
|
493
|
+
const validation = await node.connection.client.validateSubscription(record.id);
|
|
494
|
+
if (!validation.valid) return false;
|
|
495
|
+
}
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function failurePayload() {
|
|
500
|
+
const failures = [...node.failed.values()];
|
|
501
|
+
return {
|
|
502
|
+
failed: failures.length,
|
|
503
|
+
failures: failures.slice(0, 100),
|
|
504
|
+
failuresTruncated: failures.length > 100,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function statusPayload(command) {
|
|
509
|
+
const ids = subscriptionIds();
|
|
510
|
+
const monitoredValues = [...node.monitored.values()];
|
|
511
|
+
const revisedSampling = numericRange(monitoredValues, 'revisedSamplingInterval');
|
|
512
|
+
const revisedQueues = numericRange(monitoredValues, 'revisedQueueSize');
|
|
513
|
+
return {
|
|
514
|
+
command,
|
|
515
|
+
desired: node.desired.size,
|
|
516
|
+
monitored: node.monitored.size,
|
|
517
|
+
...failurePayload(),
|
|
518
|
+
subscriptionCount: ids.length,
|
|
519
|
+
subscriptionId: ids[0] ?? null,
|
|
520
|
+
subscriptionIds: ids,
|
|
521
|
+
revisions: [...node.subscriptions.values()].map((record) => ({
|
|
522
|
+
subscriptionId: record.id,
|
|
523
|
+
publishingInterval: record.revisedPublishingInterval,
|
|
524
|
+
lifetimeCount: record.revisedLifetimeCount,
|
|
525
|
+
maxKeepAliveCount: record.revisedMaxKeepAliveCount,
|
|
526
|
+
})),
|
|
527
|
+
monitoredRevisions: {
|
|
528
|
+
count: Math.max(revisedSampling.count, revisedQueues.count),
|
|
529
|
+
samplingIntervalMin: revisedSampling.minimum,
|
|
530
|
+
samplingIntervalMax: revisedSampling.maximum,
|
|
531
|
+
queueSizeMin: revisedQueues.minimum,
|
|
532
|
+
queueSizeMax: revisedQueues.maximum,
|
|
533
|
+
},
|
|
534
|
+
compatibility: {
|
|
535
|
+
profile: siemensWincc ? 'siemens-wincc' : 'standard',
|
|
536
|
+
deadbandSupported: !siemensWincc,
|
|
537
|
+
monitoredItemsPerSubscription: configuredSubscriptionCapacity,
|
|
538
|
+
},
|
|
539
|
+
connectionGovernance: node.connection?.getConnectionGovernance?.(),
|
|
540
|
+
connectionUsage: node.connection?.getConnectionUsage?.(),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function reconcileSubscriptions(reason = 'state') {
|
|
545
|
+
if (node.closing) return;
|
|
546
|
+
consumer?.markUsed('subscribe-reconcile');
|
|
547
|
+
if (!node.desired.size) {
|
|
548
|
+
if (node.subscriptions.size) await clearSubscriptions(true);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
let rebuilt = false;
|
|
553
|
+
if (node.subscriptions.size) {
|
|
554
|
+
let valid = false;
|
|
555
|
+
try { valid = await validateSubscriptions(); } catch (error) {
|
|
556
|
+
diagnostics.failure(error, undefined, 'subscription_validation_failed', true);
|
|
557
|
+
}
|
|
558
|
+
if (!valid) {
|
|
559
|
+
await clearSubscriptions(true, true);
|
|
560
|
+
rebuilt = true;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
await removeUndesiredPoints();
|
|
565
|
+
const additions = [...node.desired.values()].filter(
|
|
566
|
+
(point) => !node.monitored.has(point.nodeId)
|
|
567
|
+
);
|
|
568
|
+
const failures = await addDesiredPoints(additions);
|
|
569
|
+
for (const point of additions) node.failed.delete(point.nodeId);
|
|
570
|
+
for (const failure of failures) {
|
|
571
|
+
node.failed.set(failure.nodeId, {
|
|
572
|
+
nodeId: failure.nodeId,
|
|
573
|
+
statusCode: failure.statusCode || 'BadUnexpectedError',
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
await pruneEmptySubscriptions();
|
|
577
|
+
|
|
578
|
+
diagnostics.results(failures, undefined, node.desired);
|
|
579
|
+
if (!failures.length) diagnostics.recovered('subscription');
|
|
580
|
+
node.reconcileAttempt = 0;
|
|
581
|
+
node.status({
|
|
582
|
+
fill: failures.length ? 'yellow' : 'green',
|
|
583
|
+
shape: failures.length ? 'ring' : 'dot',
|
|
584
|
+
text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub` +
|
|
585
|
+
(failures.length ? ` / ${failures.length} failed` : ''),
|
|
586
|
+
});
|
|
587
|
+
if (reason !== 'command') {
|
|
588
|
+
diagnostics.emit(failures.length ? 'warn' : 'info', failures.length ? 'subscription_recovery_partial' : 'subscription_restored',
|
|
589
|
+
'OPC UA subscription reconciliation completed; data receipt is not yet confirmed', {
|
|
590
|
+
failed: failures.length, monitored: node.monitored.size, desired: node.desired.size,
|
|
591
|
+
rebuilt, dataGapPossible: rebuilt,
|
|
592
|
+
});
|
|
593
|
+
if (config.statusOutput !== 'separate') node.send({
|
|
594
|
+
payload: { ...statusPayload('recover'), rebuilt, reason },
|
|
595
|
+
opcua: {
|
|
596
|
+
operation: 'subscription-status',
|
|
597
|
+
recovered: true,
|
|
598
|
+
rebuilt,
|
|
599
|
+
reason,
|
|
600
|
+
sessionGeneration: node.lastSessionGeneration,
|
|
601
|
+
dataGapPossible: rebuilt,
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
return { rebuilt, failures };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function replacePoints(points) {
|
|
609
|
+
const nextDesired = new Map(points.map((point) => [point.nodeId, point]));
|
|
610
|
+
const changed = new Set();
|
|
611
|
+
for (const [nodeId, monitored] of node.monitored) {
|
|
612
|
+
const previous = node.desired.get(nodeId);
|
|
613
|
+
const next = nextDesired.get(nodeId);
|
|
614
|
+
if (!next || !previous || !sameMonitoringSettings(previous, next, config, siemensWincc)) {
|
|
615
|
+
changed.add(nodeId);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
node.desired = nextDesired;
|
|
619
|
+
for (const nodeId of [...node.failed.keys()]) {
|
|
620
|
+
if (!node.desired.has(nodeId)) node.failed.delete(nodeId);
|
|
621
|
+
}
|
|
622
|
+
await removeMonitoredPoints(changed);
|
|
623
|
+
return reconcileSubscriptions('command');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function runSerialized(task) {
|
|
627
|
+
const run = node.reconcileChain.catch(() => {}).then(task);
|
|
628
|
+
node.reconcileChain = run;
|
|
629
|
+
return run;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function scheduleReconnect(reason = 'connection-state', immediate = false) {
|
|
633
|
+
if (node.closing || node.reconnectTimer) return;
|
|
634
|
+
const base = Math.max(100, Number(config.reconnectInterval) || 5000);
|
|
635
|
+
const delay = immediate ? 0 : Math.min(30000, base * (2 ** node.reconcileAttempt));
|
|
636
|
+
node.reconcileAttempt += 1;
|
|
637
|
+
node.reconnectTimer = setTimeout(async () => {
|
|
638
|
+
node.reconnectTimer = null;
|
|
639
|
+
try {
|
|
640
|
+
await runSerialized(() => reconcileSubscriptions(reason));
|
|
641
|
+
} catch (error) {
|
|
642
|
+
diagnostics.failure(error, undefined, 'subscription_recovery_failed', true);
|
|
643
|
+
diagnostics.backgroundFailure(error, 'subscription_recovery_failed');
|
|
644
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'waiting to recover' });
|
|
645
|
+
scheduleReconnect(reason);
|
|
646
|
+
}
|
|
647
|
+
}, delay);
|
|
648
|
+
node.reconnectTimer.unref?.();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const onFault = () => scheduleReconnect('service-fault');
|
|
652
|
+
const onState = (state) => {
|
|
653
|
+
if (!state || node.closing) return;
|
|
654
|
+
if (Number(state.sessionGeneration) > 0) {
|
|
655
|
+
node.lastSessionGeneration = Number(state.sessionGeneration);
|
|
656
|
+
}
|
|
657
|
+
if (state.state === 'connected') {
|
|
658
|
+
scheduleReconnect(state.reason || 'connected', true);
|
|
659
|
+
} else if (state.state === 'subscription-inactive' &&
|
|
660
|
+
(!state.subscriptionId || node.subscriptions.has(state.subscriptionId))) {
|
|
661
|
+
diagnostics.emit('warn', 'subscription_inactive', 'OPC UA subscription is inactive; recovery pending', { retrying: true, subscriptionId: state.subscriptionId });
|
|
662
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'subscription inactive' });
|
|
663
|
+
scheduleReconnect('subscription-inactive');
|
|
664
|
+
} else if (['reconnecting', 'backoff', 'failed'].includes(state.state)) {
|
|
665
|
+
node.status({ fill: 'yellow', shape: 'ring', text: state.state });
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
node.connection?.client.on('fault', onFault);
|
|
669
|
+
node.connection?.client.on('state', onState);
|
|
670
|
+
|
|
671
|
+
node.on('input', async (msg, send, done) => {
|
|
672
|
+
if (!node.connection) {
|
|
673
|
+
const error = new Error('OPCUA_CONNECTION_NOT_CONFIGURED');
|
|
674
|
+
diagnostics.failure(error, send);
|
|
675
|
+
return done(error);
|
|
676
|
+
}
|
|
677
|
+
diagnostics.beginInput();
|
|
678
|
+
let inputError;
|
|
679
|
+
try {
|
|
680
|
+
const command = msg.opcua?.command || 'replace';
|
|
681
|
+
const dynamicNodeId = msg.nodeId || msg.topic;
|
|
682
|
+
const configuredPayload = !looksLikePointList(msg.payload) && !Array.isArray(msg.items) &&
|
|
683
|
+
!dynamicNodeId ? config.pointList : '';
|
|
684
|
+
const staticNodeId = dynamicNodeId ? '' : config.nodeId;
|
|
685
|
+
const points = command === 'clear' ? [] : applyCompatibility(messagePoints(msg, staticNodeId, {
|
|
686
|
+
samplingInterval: Number(config.samplingInterval) || 1000,
|
|
687
|
+
queueSize: Number(config.queueSize) || 10,
|
|
688
|
+
discardOldest: config.discardOldest !== false,
|
|
689
|
+
}, {
|
|
690
|
+
configuredPayload,
|
|
691
|
+
format: 'auto',
|
|
692
|
+
}));
|
|
693
|
+
|
|
694
|
+
node.status({ fill: 'blue', shape: 'dot', text: `${command} ${points.length}` });
|
|
695
|
+
await runSerialized(async () => {
|
|
696
|
+
if (command === 'replace') await replacePoints(points);
|
|
697
|
+
else if (command === 'add') {
|
|
698
|
+
const changed = new Set();
|
|
699
|
+
points.forEach((point) => {
|
|
700
|
+
const previous = node.desired.get(point.nodeId);
|
|
701
|
+
if (previous && !sameMonitoringSettings(previous, point, config, siemensWincc)) {
|
|
702
|
+
changed.add(point.nodeId);
|
|
703
|
+
}
|
|
704
|
+
node.desired.set(point.nodeId, point);
|
|
705
|
+
node.failed.delete(point.nodeId);
|
|
706
|
+
});
|
|
707
|
+
await removeMonitoredPoints(changed);
|
|
708
|
+
await reconcileSubscriptions('command');
|
|
709
|
+
} else if (command === 'remove') {
|
|
710
|
+
points.forEach((point) => {
|
|
711
|
+
node.desired.delete(point.nodeId);
|
|
712
|
+
node.failed.delete(point.nodeId);
|
|
713
|
+
});
|
|
714
|
+
await reconcileSubscriptions('command');
|
|
715
|
+
} else if (command === 'clear') await clearSubscriptions();
|
|
716
|
+
else throw new Error(`Unsupported subscription command: ${command}`);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
const failed = node.failed.size;
|
|
720
|
+
node.status({
|
|
721
|
+
fill: failed ? 'yellow' : 'green', shape: failed ? 'ring' : 'dot',
|
|
722
|
+
text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub` +
|
|
723
|
+
(failed ? ` / ${failed} failed` : ''),
|
|
724
|
+
});
|
|
725
|
+
diagnostics.emit(failed ? 'warn' : 'info', 'subscription_updated', 'OPC UA subscription configuration updated', {
|
|
726
|
+
failed, monitored: node.monitored.size, desired: node.desired.size,
|
|
727
|
+
}, send);
|
|
728
|
+
if (config.statusOutput !== 'separate') send({
|
|
729
|
+
...msg,
|
|
730
|
+
payload: statusPayload(command),
|
|
731
|
+
opcua: { operation: 'subscription-status' },
|
|
732
|
+
});
|
|
733
|
+
done();
|
|
734
|
+
} catch (error) {
|
|
735
|
+
inputError = error;
|
|
736
|
+
node.status({ fill: 'red', shape: 'ring', text: error.message });
|
|
737
|
+
diagnostics.failure(error, send);
|
|
738
|
+
done(error);
|
|
739
|
+
} finally {
|
|
740
|
+
diagnostics.endInput(inputError);
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
async function cleanupSubscription() {
|
|
745
|
+
node.closing = true;
|
|
746
|
+
if (node.reconnectTimer) clearTimeout(node.reconnectTimer);
|
|
747
|
+
node.reconnectTimer = null;
|
|
748
|
+
discardPendingChanges();
|
|
749
|
+
node.connection?.client.off('fault', onFault);
|
|
750
|
+
node.connection?.client.off('state', onState);
|
|
751
|
+
node.desired.clear();
|
|
752
|
+
node.failed.clear();
|
|
753
|
+
consumer?.release();
|
|
754
|
+
await runSerialized(() => clearSubscriptions());
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const runCleanup = node.connection?.registerDependentCleanup?.(cleanupSubscription) ||
|
|
758
|
+
cleanupSubscription;
|
|
759
|
+
|
|
760
|
+
node.on('close', async (done) => {
|
|
761
|
+
try {
|
|
762
|
+
await runCleanup();
|
|
763
|
+
} catch (error) {
|
|
764
|
+
node.warn(`OPC UA subscription cleanup failed: ${error.message}`);
|
|
765
|
+
}
|
|
766
|
+
done();
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
const configuredPoints = String(config.pointList || '').trim() ||
|
|
770
|
+
String(config.nodeId || '').trim();
|
|
771
|
+
if (configuredPoints) {
|
|
772
|
+
setImmediate(() => {
|
|
773
|
+
if (node.closing) return;
|
|
774
|
+
let points;
|
|
775
|
+
try {
|
|
776
|
+
points = applyCompatibility(messagePoints({}, config.nodeId, {
|
|
777
|
+
samplingInterval: Number(config.samplingInterval) || 1000,
|
|
778
|
+
queueSize: Number(config.queueSize) || 10,
|
|
779
|
+
discardOldest: config.discardOldest !== false,
|
|
780
|
+
}, {
|
|
781
|
+
configuredPayload: config.pointList,
|
|
782
|
+
format: 'auto',
|
|
783
|
+
}));
|
|
784
|
+
} catch (error) {
|
|
785
|
+
node.status({ fill: 'red', shape: 'ring', text: error.message });
|
|
786
|
+
diagnostics.failure(error, undefined, 'subscription_configuration_failed');
|
|
787
|
+
diagnostics.backgroundFailure(error, 'subscription_configuration_failed');
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
void runSerialized(() => replacePoints(points)).then(() => {
|
|
791
|
+
node.send({
|
|
792
|
+
payload: statusPayload('startup'),
|
|
793
|
+
opcua: { operation: 'subscription-status', startup: true },
|
|
794
|
+
});
|
|
795
|
+
}).catch((error) => {
|
|
796
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'waiting to subscribe' });
|
|
797
|
+
diagnostics.failure(error, undefined, 'subscription_startup_failed', true);
|
|
798
|
+
diagnostics.backgroundFailure(error, 'subscription_startup_failed');
|
|
799
|
+
scheduleReconnect('startup');
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
RED.nodes.registerType('tier0-opcua-subscribe', OpcUaSubscribeNode);
|
|
806
|
+
};
|