@tier0/opcua-client 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BENCHMARK.md +27 -0
- package/README.md +28 -1
- package/lib/client.js +23 -1
- package/lib/points.js +40 -5
- package/nodes/browse.html +13 -2
- package/nodes/browse.js +29 -2
- package/nodes/connection.js +95 -0
- package/nodes/read.html +2 -0
- package/nodes/read.js +10 -0
- package/nodes/subscribe.html +163 -5
- package/nodes/subscribe.js +190 -21
- package/nodes/write.html +2 -0
- package/nodes/write.js +10 -0
- package/package.json +3 -2
- package/prebuilds/linux-x64-musl/opcua_open62541.node +0 -0
- package/scripts/verify-offline-package.js +21 -1
- package/src/addon.cc +335 -49
package/nodes/subscribe.html
CHANGED
|
@@ -2,12 +2,41 @@
|
|
|
2
2
|
.opcua-advanced { margin: 8px 0 12px; }
|
|
3
3
|
.opcua-advanced summary { cursor: pointer; font-weight: 600; padding: 6px 0; }
|
|
4
4
|
.opcua-standard-note { margin: 0 0 10px 105px; color: var(--red-ui-form-text-color, #666); font-size: 0.9em; }
|
|
5
|
+
.opcua-point-editor { box-sizing: border-box; display: inline-block; vertical-align: top; width: calc(100% - 110px); }
|
|
6
|
+
.opcua-point-toolbar { display: flex; gap: 6px; margin-bottom: 8px; }
|
|
7
|
+
.opcua-point-add { display: flex; gap: 6px; margin-bottom: 8px; }
|
|
8
|
+
.opcua-point-add input { flex: 1; min-width: 0; }
|
|
9
|
+
.opcua-point-list { border: 1px solid var(--red-ui-form-input-border-color, #ccc); border-radius: 2px; box-sizing: border-box; height: 180px; list-style: none; margin: 0 !important; overflow: auto; padding: 0 !important; width: 100%; }
|
|
10
|
+
.opcua-point-list li { align-items: center; border-bottom: 1px solid var(--red-ui-secondary-border-color, #eee); display: flex; gap: 8px; min-height: 34px; padding: 4px 8px; }
|
|
11
|
+
.opcua-point-list li:last-child { border-bottom: 0; }
|
|
12
|
+
.opcua-point-list .opcua-point-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
13
|
+
.opcua-point-list .opcua-point-empty { color: var(--red-ui-form-placeholder-color, #888); justify-content: center; }
|
|
14
|
+
.opcua-point-list .opcua-point-remove { border: 0; color: var(--red-ui-text-color, #555); flex: 0 0 auto; }
|
|
15
|
+
.opcua-point-status { color: var(--red-ui-form-text-color, #666); font-size: 0.9em; margin-top: 5px; }
|
|
5
16
|
</style>
|
|
6
17
|
<script type="text/html" data-template-name="tier0-opcua-subscribe">
|
|
7
18
|
<div class="form-row"><label for="node-input-connection">Connection</label><input type="text" id="node-input-connection"></div>
|
|
8
|
-
<
|
|
19
|
+
<input type="hidden" id="node-input-nodeId">
|
|
20
|
+
<input type="hidden" id="node-input-pointList">
|
|
21
|
+
<input type="hidden" id="node-input-pointListFileName">
|
|
22
|
+
<input type="file" id="node-input-pointFile" accept=".json,.csv,application/json,text/csv" style="display:none">
|
|
23
|
+
<div class="form-row">
|
|
24
|
+
<label>Points</label>
|
|
25
|
+
<div class="opcua-point-editor">
|
|
26
|
+
<div class="opcua-point-toolbar">
|
|
27
|
+
<button type="button" class="red-ui-button" id="node-input-importPointList"><i class="fa fa-upload"></i> Import</button>
|
|
28
|
+
<button type="button" class="red-ui-button" id="node-input-clearPointList"><i class="fa fa-trash"></i> Clear all</button>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="opcua-point-add">
|
|
31
|
+
<input type="text" id="node-input-newPointNodeId" placeholder="NodeId, for example ns=2;s=Machine.Speed">
|
|
32
|
+
<button type="button" class="red-ui-button" id="node-input-addPoint"><i class="fa fa-plus"></i> Add</button>
|
|
33
|
+
</div>
|
|
34
|
+
<ol class="opcua-point-list" id="node-input-pointListItems"></ol>
|
|
35
|
+
<div class="opcua-point-status" id="node-input-pointListStatus">0 points</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
9
38
|
<div class="form-row"><label for="node-input-samplingInterval">Sampling (ms)</label><input type="number" id="node-input-samplingInterval"></div>
|
|
10
|
-
<div class="opcua-standard-note">
|
|
39
|
+
<div class="opcua-standard-note">Saved points start automatically after deploy. Repeated identical point lists are reconciled without rebuilding server subscriptions.</div>
|
|
11
40
|
<details class="opcua-advanced">
|
|
12
41
|
<summary>Advanced subscription and queue settings</summary>
|
|
13
42
|
<div class="form-row"><label for="node-input-publishingInterval">Publishing interval</label><input type="number" id="node-input-publishingInterval"></div>
|
|
@@ -33,7 +62,7 @@
|
|
|
33
62
|
<h3>订阅(Subscribe)</h3>
|
|
34
63
|
<p>创建 OPC UA Subscription 和 MonitoredItems,由服务器在值变化时推送通知;它不是客户端定时 Read。大量点位会按照服务器限制自动拆分到多个 Subscription。</p>
|
|
35
64
|
<h4>输入与命令</h4>
|
|
36
|
-
<p
|
|
65
|
+
<p>可直接保存或导入 JSON/CSV 点表;部署后自动建立订阅。点表也可来自消息或 Browse 输出,通过 <code>msg.opcua.command</code> 使用 <code>replace</code>、<code>add</code>、<code>remove</code>、<code>clear</code> 动态管理点位。重复的相同点表不会重建 Subscription。</p>
|
|
37
66
|
<h4>时间与队列</h4>
|
|
38
67
|
<ul>
|
|
39
68
|
<li><b>Sampling</b>:服务器检查 MonitoredItem 的请求间隔。</li>
|
|
@@ -55,7 +84,7 @@
|
|
|
55
84
|
<h3>Subscribe</h3>
|
|
56
85
|
<p>Creates one or more OPC UA subscriptions and MonitoredItems so the server sends data-change notifications. It is not a periodic client Read. Large point sets are automatically sharded across Subscriptions according to server limits.</p>
|
|
57
86
|
<h4>Input and commands</h4>
|
|
58
|
-
<p>Point lists may come from
|
|
87
|
+
<p>Save or import a JSON/CSV point list to start automatically after deploy. Point lists may also come from messages or Browse output. Use <code>replace</code>, <code>add</code>, <code>remove</code>, or <code>clear</code> in <code>msg.opcua.command</code> to manage points dynamically. Repeating an unchanged list does not rebuild the server Subscription.</p>
|
|
59
88
|
<h4>Timing and queues</h4>
|
|
60
89
|
<ul>
|
|
61
90
|
<li><b>Sampling</b>: Requested interval at which the server checks a MonitoredItem.</li>
|
|
@@ -77,7 +106,8 @@
|
|
|
77
106
|
RED.nodes.registerType('tier0-opcua-subscribe', {
|
|
78
107
|
category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 1, icon: 'bridge.png',
|
|
79
108
|
defaults: {
|
|
80
|
-
connection: { value: '', type: 'tier0-opcua-connection', required: true },
|
|
109
|
+
connection: { value: '', type: 'tier0-opcua-connection', required: true },
|
|
110
|
+
nodeId: { value: '' }, pointList: { value: '' }, pointListFileName: { value: '' },
|
|
81
111
|
publishingInterval: { value: 1000, validate: RED.validators.number() }, samplingInterval: { value: 1000, validate: RED.validators.number() },
|
|
82
112
|
queueSize: { value: 1, validate: RED.validators.number() }, discardOldest: { value: true },
|
|
83
113
|
deadbandType: { value: 'none' }, deadbandValue: { value: 0, validate: RED.validators.number() },
|
|
@@ -91,6 +121,134 @@
|
|
|
91
121
|
lifetimeCount: { value: 120 }, maxKeepAliveCount: { value: 10 }, maxNotificationsPerPublish: { value: 0 },
|
|
92
122
|
reconnectInterval: { value: 5000 }, name: { value: '' }
|
|
93
123
|
},
|
|
124
|
+
oneditprepare: function () {
|
|
125
|
+
const points = new Map();
|
|
126
|
+
|
|
127
|
+
const csvLine = (line) => {
|
|
128
|
+
const fields = [];
|
|
129
|
+
let value = '';
|
|
130
|
+
let quoted = false;
|
|
131
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
132
|
+
const character = line[index];
|
|
133
|
+
if (character === '"') {
|
|
134
|
+
if (quoted && line[index + 1] === '"') { value += '"'; index += 1; }
|
|
135
|
+
else quoted = !quoted;
|
|
136
|
+
} else if (character === ',' && !quoted) {
|
|
137
|
+
fields.push(value.trim()); value = '';
|
|
138
|
+
} else value += character;
|
|
139
|
+
}
|
|
140
|
+
fields.push(value.trim());
|
|
141
|
+
return fields;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const parse = (content) => {
|
|
145
|
+
const text = String(content || '').replace(/^\uFEFF/, '').trim();
|
|
146
|
+
if (!text) return [];
|
|
147
|
+
if (text.startsWith('[') || text.startsWith('{')) {
|
|
148
|
+
const parsed = JSON.parse(text);
|
|
149
|
+
return Array.isArray(parsed) ? parsed : (Array.isArray(parsed.items) ? parsed.items : [parsed]);
|
|
150
|
+
}
|
|
151
|
+
const lines = text.split(/\r?\n/).filter((line) => line.trim() && !line.trimStart().startsWith('#'));
|
|
152
|
+
if (!lines.length) return [];
|
|
153
|
+
const headers = csvLine(lines.shift());
|
|
154
|
+
if (!headers.includes('nodeId')) throw new Error('CSV header must contain nodeId');
|
|
155
|
+
return lines.map((line) => {
|
|
156
|
+
const values = csvLine(line);
|
|
157
|
+
return Object.fromEntries(headers.map((header, index) => [header, values[index] || '']));
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const normalize = (item) => {
|
|
162
|
+
const source = typeof item === 'string' ? { nodeId: item } : { ...(item || {}) };
|
|
163
|
+
source.nodeId = String(source.nodeId || source.topic || '').trim();
|
|
164
|
+
if (!source.nodeId) throw new Error('Point is missing nodeId');
|
|
165
|
+
return source;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const persist = () => {
|
|
169
|
+
const items = [...points.values()];
|
|
170
|
+
$('#node-input-pointList').val(items.length ? JSON.stringify(items) : '');
|
|
171
|
+
if (items.length) $('#node-input-nodeId').val('');
|
|
172
|
+
$('#node-input-pointListStatus').text(`${items.length} point${items.length === 1 ? '' : 's'}`);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const render = () => {
|
|
176
|
+
const list = document.getElementById('node-input-pointListItems');
|
|
177
|
+
list.textContent = '';
|
|
178
|
+
if (!points.size) {
|
|
179
|
+
const empty = document.createElement('li');
|
|
180
|
+
empty.className = 'opcua-point-empty';
|
|
181
|
+
empty.textContent = 'No saved points. Add a NodeId, import a file, or supply points by message.';
|
|
182
|
+
list.appendChild(empty);
|
|
183
|
+
} else {
|
|
184
|
+
const fragment = document.createDocumentFragment();
|
|
185
|
+
points.forEach((point, nodeId) => {
|
|
186
|
+
const row = document.createElement('li');
|
|
187
|
+
const text = document.createElement('span');
|
|
188
|
+
text.className = 'opcua-point-text';
|
|
189
|
+
text.textContent = point.alias ? `${point.alias} — ${nodeId}` : nodeId;
|
|
190
|
+
text.title = text.textContent;
|
|
191
|
+
const remove = document.createElement('button');
|
|
192
|
+
remove.type = 'button';
|
|
193
|
+
remove.className = 'red-ui-button opcua-point-remove';
|
|
194
|
+
remove.title = `Remove ${nodeId}`;
|
|
195
|
+
remove.innerHTML = '<i class="fa fa-times"></i>';
|
|
196
|
+
remove.addEventListener('click', () => { points.delete(nodeId); render(); });
|
|
197
|
+
row.append(text, remove);
|
|
198
|
+
fragment.appendChild(row);
|
|
199
|
+
});
|
|
200
|
+
list.appendChild(fragment);
|
|
201
|
+
}
|
|
202
|
+
persist();
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const merge = (items) => {
|
|
206
|
+
items.forEach((item) => { const point = normalize(item); points.set(point.nodeId, point); });
|
|
207
|
+
render();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const stored = $('#node-input-pointList').val();
|
|
212
|
+
if (stored) merge(parse(stored));
|
|
213
|
+
else if ($('#node-input-nodeId').val()) merge([{ nodeId: $('#node-input-nodeId').val() }]);
|
|
214
|
+
else render();
|
|
215
|
+
} catch (error) {
|
|
216
|
+
RED.notify(`Cannot load point list: ${error.message}`, 'error');
|
|
217
|
+
render();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
$('#node-input-addPoint').on('click', () => {
|
|
221
|
+
try {
|
|
222
|
+
merge([{ nodeId: $('#node-input-newPointNodeId').val() }]);
|
|
223
|
+
$('#node-input-newPointNodeId').val('').focus();
|
|
224
|
+
} catch (error) { RED.notify(error.message, 'error'); }
|
|
225
|
+
});
|
|
226
|
+
$('#node-input-newPointNodeId').on('keydown', function (event) {
|
|
227
|
+
if (event.key === 'Enter') { event.preventDefault(); $('#node-input-addPoint').click(); }
|
|
228
|
+
});
|
|
229
|
+
$('#node-input-importPointList').on('click', () => $('#node-input-pointFile').click());
|
|
230
|
+
$('#node-input-pointFile').on('change', function () {
|
|
231
|
+
const file = this.files && this.files[0];
|
|
232
|
+
if (!file) return;
|
|
233
|
+
const reader = new FileReader();
|
|
234
|
+
reader.onload = () => {
|
|
235
|
+
try {
|
|
236
|
+
const imported = parse(reader.result);
|
|
237
|
+
merge(imported);
|
|
238
|
+
$('#node-input-pointListFileName').val(file.name);
|
|
239
|
+
RED.notify(`${imported.length} point(s) imported`, 'success');
|
|
240
|
+
} catch (error) { RED.notify(`Import failed: ${error.message}`, 'error'); }
|
|
241
|
+
$('#node-input-pointFile').val('');
|
|
242
|
+
};
|
|
243
|
+
reader.readAsText(file);
|
|
244
|
+
});
|
|
245
|
+
$('#node-input-clearPointList').on('click', () => {
|
|
246
|
+
points.clear();
|
|
247
|
+
$('#node-input-nodeId').val('');
|
|
248
|
+
$('#node-input-pointListFileName').val('');
|
|
249
|
+
render();
|
|
250
|
+
});
|
|
251
|
+
},
|
|
94
252
|
label: function () { return this.name || 'tier0-opcua-subscribe'; }
|
|
95
253
|
});
|
|
96
254
|
</script>
|
package/nodes/subscribe.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { messagePoints } = require('../lib/points');
|
|
3
|
+
const { looksLikePointList, messagePoints } = require('../lib/points');
|
|
4
4
|
|
|
5
5
|
const NOTIFICATION_CONFIG_FIELDS = new Set([
|
|
6
6
|
'nodeId',
|
|
@@ -55,6 +55,43 @@ function positiveInteger(value, fallback) {
|
|
|
55
55
|
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
function monitoringSettings(point, config) {
|
|
59
|
+
return {
|
|
60
|
+
samplingInterval: Number(point.samplingInterval) || Number(config.samplingInterval) || 1000,
|
|
61
|
+
queueSize: positiveInteger(point.queueSize, positiveInteger(config.queueSize, 1)),
|
|
62
|
+
discardOldest: point.discardOldest !== false,
|
|
63
|
+
deadbandType: point.deadbandType || config.deadbandType || 'none',
|
|
64
|
+
deadbandValue: Number(point.deadbandValue ?? config.deadbandValue) || 0,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sameMonitoringSettings(left, right, config) {
|
|
69
|
+
const leftSettings = monitoringSettings(left, config);
|
|
70
|
+
const rightSettings = monitoringSettings(right, config);
|
|
71
|
+
return Object.keys(leftSettings).every(
|
|
72
|
+
(key) => leftSettings[key] === rightSettings[key]
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function numericRange(items, field) {
|
|
77
|
+
let count = 0;
|
|
78
|
+
let minimum = Infinity;
|
|
79
|
+
let maximum = -Infinity;
|
|
80
|
+
for (const item of items) {
|
|
81
|
+
if (item[field] == null) continue;
|
|
82
|
+
const value = Number(item[field]);
|
|
83
|
+
if (!Number.isFinite(value)) continue;
|
|
84
|
+
count += 1;
|
|
85
|
+
minimum = Math.min(minimum, value);
|
|
86
|
+
maximum = Math.max(maximum, value);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
count,
|
|
90
|
+
minimum: count ? minimum : null,
|
|
91
|
+
maximum: count ? maximum : null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
module.exports = function registerSubscribeNode(RED) {
|
|
59
96
|
function OpcUaSubscribeNode(config) {
|
|
60
97
|
RED.nodes.createNode(this, config);
|
|
@@ -63,6 +100,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
63
100
|
node.subscriptions = new Map();
|
|
64
101
|
node.desired = new Map();
|
|
65
102
|
node.monitored = new Map();
|
|
103
|
+
node.failed = new Map();
|
|
66
104
|
node.subscriptionDiagnostics = new Map();
|
|
67
105
|
node.pendingChanges = [];
|
|
68
106
|
node.flushTimer = null;
|
|
@@ -73,6 +111,9 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
73
111
|
node.closing = false;
|
|
74
112
|
node.lastDropped = 0;
|
|
75
113
|
node.lastStatusAt = 0;
|
|
114
|
+
const consumer = node.connection?.registerConsumer?.('subscribe', {
|
|
115
|
+
nodeId: config.id, name: config.name,
|
|
116
|
+
});
|
|
76
117
|
|
|
77
118
|
const batchSize = positiveInteger(config.batchSize, 500);
|
|
78
119
|
const callbackBatchSize = positiveInteger(config.callbackBatchSize, 500);
|
|
@@ -253,6 +294,9 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
253
294
|
id: null,
|
|
254
295
|
monitored: new Map(),
|
|
255
296
|
limit: subscriptionCapacity(),
|
|
297
|
+
revisedPublishingInterval: null,
|
|
298
|
+
revisedLifetimeCount: null,
|
|
299
|
+
revisedMaxKeepAliveCount: null,
|
|
256
300
|
};
|
|
257
301
|
const result = await node.connection.client.createSubscription({
|
|
258
302
|
publishingInterval: Number(config.publishingInterval) || 1000,
|
|
@@ -265,6 +309,9 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
265
309
|
overflowPolicy: config.overflowPolicy || 'drop-oldest',
|
|
266
310
|
}, (batch) => onNativeBatch(record, batch));
|
|
267
311
|
record.id = result.subscriptionId;
|
|
312
|
+
record.revisedPublishingInterval = result.revisedPublishingInterval ?? null;
|
|
313
|
+
record.revisedLifetimeCount = result.revisedLifetimeCount ?? null;
|
|
314
|
+
record.revisedMaxKeepAliveCount = result.revisedMaxKeepAliveCount ?? null;
|
|
268
315
|
node.subscriptions.set(record.id, record);
|
|
269
316
|
return record;
|
|
270
317
|
}
|
|
@@ -291,6 +338,8 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
291
338
|
subscriptionId: record.id,
|
|
292
339
|
monitoredItemId: result.monitoredItemId,
|
|
293
340
|
clientHandle: result.clientHandle,
|
|
341
|
+
revisedSamplingInterval: result.revisedSamplingInterval ?? null,
|
|
342
|
+
revisedQueueSize: result.revisedQueueSize ?? null,
|
|
294
343
|
};
|
|
295
344
|
record.monitored.set(result.nodeId, monitored);
|
|
296
345
|
node.monitored.set(result.nodeId, monitored);
|
|
@@ -343,10 +392,10 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
343
392
|
return failures;
|
|
344
393
|
}
|
|
345
394
|
|
|
346
|
-
async function
|
|
395
|
+
async function removeMonitoredPoints(nodeIds) {
|
|
347
396
|
const grouped = new Map();
|
|
348
397
|
for (const [nodeId, monitored] of node.monitored) {
|
|
349
|
-
if (
|
|
398
|
+
if (!nodeIds.has(nodeId)) continue;
|
|
350
399
|
if (!grouped.has(monitored.subscriptionId)) grouped.set(monitored.subscriptionId, []);
|
|
351
400
|
grouped.get(monitored.subscriptionId).push({ nodeId, ...monitored });
|
|
352
401
|
}
|
|
@@ -367,6 +416,13 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
367
416
|
}
|
|
368
417
|
}
|
|
369
418
|
|
|
419
|
+
async function removeUndesiredPoints() {
|
|
420
|
+
const nodeIds = new Set(
|
|
421
|
+
[...node.monitored.keys()].filter((nodeId) => !node.desired.has(nodeId))
|
|
422
|
+
);
|
|
423
|
+
await removeMonitoredPoints(nodeIds);
|
|
424
|
+
}
|
|
425
|
+
|
|
370
426
|
async function pruneEmptySubscriptions() {
|
|
371
427
|
const empty = [...node.subscriptions.values()].filter(
|
|
372
428
|
(record) => record.monitored.size === 0
|
|
@@ -380,7 +436,10 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
380
436
|
}
|
|
381
437
|
|
|
382
438
|
async function clearSubscriptions(keepDesired = false, ignoreErrors = false) {
|
|
383
|
-
if (!keepDesired)
|
|
439
|
+
if (!keepDesired) {
|
|
440
|
+
node.desired.clear();
|
|
441
|
+
node.failed.clear();
|
|
442
|
+
}
|
|
384
443
|
discardPendingChanges();
|
|
385
444
|
const records = [...node.subscriptions.values()];
|
|
386
445
|
node.subscriptions.clear();
|
|
@@ -403,22 +462,49 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
403
462
|
return true;
|
|
404
463
|
}
|
|
405
464
|
|
|
406
|
-
function
|
|
465
|
+
function failurePayload() {
|
|
466
|
+
const failures = [...node.failed.values()];
|
|
467
|
+
return {
|
|
468
|
+
failed: failures.length,
|
|
469
|
+
failures: failures.slice(0, 100),
|
|
470
|
+
failuresTruncated: failures.length > 100,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function statusPayload(command) {
|
|
407
475
|
const ids = subscriptionIds();
|
|
476
|
+
const monitoredValues = [...node.monitored.values()];
|
|
477
|
+
const revisedSampling = numericRange(monitoredValues, 'revisedSamplingInterval');
|
|
478
|
+
const revisedQueues = numericRange(monitoredValues, 'revisedQueueSize');
|
|
408
479
|
return {
|
|
409
480
|
command,
|
|
410
481
|
desired: node.desired.size,
|
|
411
482
|
monitored: node.monitored.size,
|
|
412
|
-
|
|
483
|
+
...failurePayload(),
|
|
413
484
|
subscriptionCount: ids.length,
|
|
414
485
|
subscriptionId: ids[0] ?? null,
|
|
415
486
|
subscriptionIds: ids,
|
|
487
|
+
revisions: [...node.subscriptions.values()].map((record) => ({
|
|
488
|
+
subscriptionId: record.id,
|
|
489
|
+
publishingInterval: record.revisedPublishingInterval,
|
|
490
|
+
lifetimeCount: record.revisedLifetimeCount,
|
|
491
|
+
maxKeepAliveCount: record.revisedMaxKeepAliveCount,
|
|
492
|
+
})),
|
|
493
|
+
monitoredRevisions: {
|
|
494
|
+
count: Math.max(revisedSampling.count, revisedQueues.count),
|
|
495
|
+
samplingIntervalMin: revisedSampling.minimum,
|
|
496
|
+
samplingIntervalMax: revisedSampling.maximum,
|
|
497
|
+
queueSizeMin: revisedQueues.minimum,
|
|
498
|
+
queueSizeMax: revisedQueues.maximum,
|
|
499
|
+
},
|
|
416
500
|
connectionGovernance: node.connection?.getConnectionGovernance?.(),
|
|
501
|
+
connectionUsage: node.connection?.getConnectionUsage?.(),
|
|
417
502
|
};
|
|
418
503
|
}
|
|
419
504
|
|
|
420
505
|
async function reconcileSubscriptions(reason = 'state') {
|
|
421
506
|
if (node.closing) return;
|
|
507
|
+
consumer?.markUsed('subscribe-reconcile');
|
|
422
508
|
if (!node.desired.size) {
|
|
423
509
|
if (node.subscriptions.size) await clearSubscriptions(true);
|
|
424
510
|
return;
|
|
@@ -439,15 +525,21 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
439
525
|
(point) => !node.monitored.has(point.nodeId)
|
|
440
526
|
);
|
|
441
527
|
const failures = await addDesiredPoints(additions);
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
528
|
+
for (const point of additions) node.failed.delete(point.nodeId);
|
|
529
|
+
for (const failure of failures) {
|
|
530
|
+
node.failed.set(failure.nodeId, {
|
|
531
|
+
nodeId: failure.nodeId,
|
|
532
|
+
statusCode: failure.statusCode || 'BadUnexpectedError',
|
|
533
|
+
});
|
|
445
534
|
}
|
|
535
|
+
await pruneEmptySubscriptions();
|
|
446
536
|
|
|
447
537
|
node.reconcileAttempt = 0;
|
|
448
538
|
node.status({
|
|
449
|
-
fill: '
|
|
450
|
-
|
|
539
|
+
fill: failures.length ? 'yellow' : 'green',
|
|
540
|
+
shape: failures.length ? 'ring' : 'dot',
|
|
541
|
+
text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub` +
|
|
542
|
+
(failures.length ? ` / ${failures.length} failed` : ''),
|
|
451
543
|
});
|
|
452
544
|
if (reason !== 'command') {
|
|
453
545
|
node.send({
|
|
@@ -462,12 +554,25 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
462
554
|
},
|
|
463
555
|
});
|
|
464
556
|
}
|
|
557
|
+
return { rebuilt, failures };
|
|
465
558
|
}
|
|
466
559
|
|
|
467
560
|
async function replacePoints(points) {
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
561
|
+
const nextDesired = new Map(points.map((point) => [point.nodeId, point]));
|
|
562
|
+
const changed = new Set();
|
|
563
|
+
for (const [nodeId, monitored] of node.monitored) {
|
|
564
|
+
const previous = node.desired.get(nodeId);
|
|
565
|
+
const next = nextDesired.get(nodeId);
|
|
566
|
+
if (!next || !previous || !sameMonitoringSettings(previous, next, config)) {
|
|
567
|
+
changed.add(nodeId);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
node.desired = nextDesired;
|
|
571
|
+
for (const nodeId of [...node.failed.keys()]) {
|
|
572
|
+
if (!node.desired.has(nodeId)) node.failed.delete(nodeId);
|
|
573
|
+
}
|
|
574
|
+
await removeMonitoredPoints(changed);
|
|
575
|
+
return reconcileSubscriptions('command');
|
|
471
576
|
}
|
|
472
577
|
|
|
473
578
|
function runSerialized(task) {
|
|
@@ -517,28 +622,49 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
517
622
|
if (!node.connection) return done(new Error('OPC UA connection not configured'));
|
|
518
623
|
try {
|
|
519
624
|
const command = msg.opcua?.command || 'replace';
|
|
520
|
-
const
|
|
625
|
+
const dynamicNodeId = msg.nodeId || msg.topic;
|
|
626
|
+
const configuredPayload = !looksLikePointList(msg.payload) && !Array.isArray(msg.items) &&
|
|
627
|
+
!dynamicNodeId ? config.pointList : '';
|
|
628
|
+
const staticNodeId = dynamicNodeId ? '' : config.nodeId;
|
|
629
|
+
const points = command === 'clear' ? [] : messagePoints(msg, staticNodeId, {
|
|
521
630
|
samplingInterval: Number(config.samplingInterval) || 1000,
|
|
522
631
|
queueSize: Number(config.queueSize) || 10,
|
|
523
632
|
discardOldest: config.discardOldest !== false,
|
|
633
|
+
}, {
|
|
634
|
+
configuredPayload,
|
|
635
|
+
format: 'auto',
|
|
524
636
|
});
|
|
525
637
|
|
|
526
638
|
node.status({ fill: 'blue', shape: 'dot', text: `${command} ${points.length}` });
|
|
527
639
|
await runSerialized(async () => {
|
|
528
640
|
if (command === 'replace') await replacePoints(points);
|
|
529
641
|
else if (command === 'add') {
|
|
530
|
-
|
|
642
|
+
const changed = new Set();
|
|
643
|
+
points.forEach((point) => {
|
|
644
|
+
const previous = node.desired.get(point.nodeId);
|
|
645
|
+
if (previous && !sameMonitoringSettings(previous, point, config)) {
|
|
646
|
+
changed.add(point.nodeId);
|
|
647
|
+
}
|
|
648
|
+
node.desired.set(point.nodeId, point);
|
|
649
|
+
node.failed.delete(point.nodeId);
|
|
650
|
+
});
|
|
651
|
+
await removeMonitoredPoints(changed);
|
|
531
652
|
await reconcileSubscriptions('command');
|
|
532
653
|
} else if (command === 'remove') {
|
|
533
|
-
points.forEach((point) =>
|
|
654
|
+
points.forEach((point) => {
|
|
655
|
+
node.desired.delete(point.nodeId);
|
|
656
|
+
node.failed.delete(point.nodeId);
|
|
657
|
+
});
|
|
534
658
|
await reconcileSubscriptions('command');
|
|
535
659
|
} else if (command === 'clear') await clearSubscriptions();
|
|
536
660
|
else throw new Error(`Unsupported subscription command: ${command}`);
|
|
537
661
|
});
|
|
538
662
|
|
|
663
|
+
const failed = node.failed.size;
|
|
539
664
|
node.status({
|
|
540
|
-
fill: 'green', shape: 'dot',
|
|
541
|
-
text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub
|
|
665
|
+
fill: failed ? 'yellow' : 'green', shape: failed ? 'ring' : 'dot',
|
|
666
|
+
text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub` +
|
|
667
|
+
(failed ? ` / ${failed} failed` : ''),
|
|
542
668
|
});
|
|
543
669
|
send({
|
|
544
670
|
...msg,
|
|
@@ -552,20 +678,63 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
552
678
|
}
|
|
553
679
|
});
|
|
554
680
|
|
|
555
|
-
|
|
681
|
+
async function cleanupSubscription() {
|
|
556
682
|
node.closing = true;
|
|
557
683
|
if (node.reconnectTimer) clearTimeout(node.reconnectTimer);
|
|
684
|
+
node.reconnectTimer = null;
|
|
558
685
|
discardPendingChanges();
|
|
559
686
|
node.connection?.client.off('fault', onFault);
|
|
560
687
|
node.connection?.client.off('state', onState);
|
|
561
688
|
node.desired.clear();
|
|
689
|
+
node.failed.clear();
|
|
690
|
+
consumer?.release();
|
|
691
|
+
await runSerialized(() => clearSubscriptions());
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const runCleanup = node.connection?.registerDependentCleanup?.(cleanupSubscription) ||
|
|
695
|
+
cleanupSubscription;
|
|
696
|
+
|
|
697
|
+
node.on('close', async (done) => {
|
|
562
698
|
try {
|
|
563
|
-
await
|
|
699
|
+
await runCleanup();
|
|
564
700
|
} catch (error) {
|
|
565
701
|
node.warn(`OPC UA subscription cleanup failed: ${error.message}`);
|
|
566
702
|
}
|
|
567
703
|
done();
|
|
568
704
|
});
|
|
705
|
+
|
|
706
|
+
const configuredPoints = String(config.pointList || '').trim() ||
|
|
707
|
+
String(config.nodeId || '').trim();
|
|
708
|
+
if (configuredPoints) {
|
|
709
|
+
setImmediate(() => {
|
|
710
|
+
if (node.closing) return;
|
|
711
|
+
let points;
|
|
712
|
+
try {
|
|
713
|
+
points = messagePoints({}, config.nodeId, {
|
|
714
|
+
samplingInterval: Number(config.samplingInterval) || 1000,
|
|
715
|
+
queueSize: Number(config.queueSize) || 10,
|
|
716
|
+
discardOldest: config.discardOldest !== false,
|
|
717
|
+
}, {
|
|
718
|
+
configuredPayload: config.pointList,
|
|
719
|
+
format: 'auto',
|
|
720
|
+
});
|
|
721
|
+
} catch (error) {
|
|
722
|
+
node.status({ fill: 'red', shape: 'ring', text: error.message });
|
|
723
|
+
node.warn(`OPC UA saved subscription point list is invalid: ${error.message}`);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
void runSerialized(() => replacePoints(points)).then(() => {
|
|
727
|
+
node.send({
|
|
728
|
+
payload: statusPayload('startup'),
|
|
729
|
+
opcua: { operation: 'subscription-status', startup: true },
|
|
730
|
+
});
|
|
731
|
+
}).catch((error) => {
|
|
732
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'waiting to subscribe' });
|
|
733
|
+
node.warn(`OPC UA startup subscription failed: ${error.message}`);
|
|
734
|
+
scheduleReconnect('startup');
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
}
|
|
569
738
|
}
|
|
570
739
|
|
|
571
740
|
RED.nodes.registerType('tier0-opcua-subscribe', OpcUaSubscribeNode);
|
package/nodes/write.html
CHANGED
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
<li>单点:NodeId 来自节点配置、<code>msg.nodeId</code> 或 <code>msg.topic</code>,写入值来自 <code>msg.payload</code>。</li>
|
|
55
55
|
<li>批量:通过编辑器、JSON 或 CSV 提供 <code>nodeId</code>、<code>value</code> 和可选 <code>dataType</code>。</li>
|
|
56
56
|
<li>CSV 必须包含 <code>nodeId</code> 和 <code>value</code>;未指定类型时使用节点默认类型。</li>
|
|
57
|
+
<li>Int64/UInt64 建议使用十进制字符串;超范围、小数和不安全的 Number 会在发送前被拒绝。</li>
|
|
57
58
|
<li><code>indexRange</code> 可部分写入数组或矩阵。</li>
|
|
58
59
|
</ul>
|
|
59
60
|
<h4>输出</h4>
|
|
@@ -68,6 +69,7 @@
|
|
|
68
69
|
<li>Single point: NodeId comes from the node setting, <code>msg.nodeId</code>, or <code>msg.topic</code>; the value comes from <code>msg.payload</code>.</li>
|
|
69
70
|
<li>Batch: Supply <code>nodeId</code>, <code>value</code>, and optional <code>dataType</code> through the editor, JSON, or CSV.</li>
|
|
70
71
|
<li>CSV requires <code>nodeId</code> and <code>value</code>. The node default type is used when <code>dataType</code> is omitted.</li>
|
|
72
|
+
<li>Use decimal strings for Int64/UInt64. Out-of-range, fractional, and unsafe Number values are rejected before transmission.</li>
|
|
71
73
|
<li>Use <code>indexRange</code> for partial array or matrix writes.</li>
|
|
72
74
|
</ul>
|
|
73
75
|
<h4>Output</h4>
|
package/nodes/write.js
CHANGED
|
@@ -7,11 +7,15 @@ module.exports = function registerWriteNode(RED) {
|
|
|
7
7
|
RED.nodes.createNode(this, config);
|
|
8
8
|
const node = this;
|
|
9
9
|
node.connection = RED.nodes.getNode(config.connection);
|
|
10
|
+
const consumer = node.connection?.registerConsumer?.('write', {
|
|
11
|
+
nodeId: config.id, name: config.name,
|
|
12
|
+
});
|
|
10
13
|
|
|
11
14
|
node.on('input', async (msg, send, done) => {
|
|
12
15
|
if (!node.connection) return done(new Error('OPC UA connection not configured'));
|
|
13
16
|
|
|
14
17
|
try {
|
|
18
|
+
consumer?.markUsed('write');
|
|
15
19
|
const points = messageWritePoints(msg, config);
|
|
16
20
|
node.status({ fill: 'blue', shape: 'dot', text: `writing ${points.length}` });
|
|
17
21
|
const results = await node.connection.client.writeMany(
|
|
@@ -41,6 +45,12 @@ module.exports = function registerWriteNode(RED) {
|
|
|
41
45
|
done(error);
|
|
42
46
|
}
|
|
43
47
|
});
|
|
48
|
+
|
|
49
|
+
node.on('close', (removed, done) => {
|
|
50
|
+
if (typeof removed === 'function') done = removed;
|
|
51
|
+
consumer?.release();
|
|
52
|
+
done?.();
|
|
53
|
+
});
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
RED.nodes.registerType('tier0-opcua-write', OpcUaWriteNode);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tier0/opcua-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "High-scale OPC UA client nodes for Node-RED powered by open62541",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"test:stress": "node test/stress.js",
|
|
39
39
|
"test:nodered": "node test/nodered.integration.js",
|
|
40
40
|
"test:nodered:reconnect": "OPCUA_NODERED_RECONNECT=1 node test/nodered.integration.js",
|
|
41
|
-
"
|
|
41
|
+
"test:release": "npm test && npm run test:native && npm run test:offline-runtime && npm run test:nodered",
|
|
42
|
+
"prepack": "npm test && node scripts/verify-offline-package.js"
|
|
42
43
|
},
|
|
43
44
|
"keywords": [
|
|
44
45
|
"node-red",
|
|
Binary file
|
|
@@ -17,4 +17,24 @@ if (missing.length) {
|
|
|
17
17
|
process.exit(1);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
21
|
+
const prebuildSize = fs.statSync(
|
|
22
|
+
path.join(root, 'prebuilds/linux-x64-musl/opcua_open62541.node')
|
|
23
|
+
).size;
|
|
24
|
+
const sourceSize = fs.statSync(
|
|
25
|
+
path.join(root, 'vendor/open62541-v1.4.16.tar.gz')
|
|
26
|
+
).size;
|
|
27
|
+
if (!packageJson.files?.includes('prebuilds') || !packageJson.files?.includes('vendor')) {
|
|
28
|
+
throw new Error('package.json files must include prebuilds and vendor for offline installation');
|
|
29
|
+
}
|
|
30
|
+
if (!packageJson.bundledDependencies?.includes('node-addon-api')) {
|
|
31
|
+
throw new Error('node-addon-api must remain a bundled dependency');
|
|
32
|
+
}
|
|
33
|
+
if (prebuildSize < 100000 || sourceSize < 1000000) {
|
|
34
|
+
throw new Error('Bundled native addon or open62541 source archive is unexpectedly small');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(
|
|
38
|
+
`Offline package inputs verified for ${packageJson.name}@${packageJson.version}: ` +
|
|
39
|
+
`prebuild=${prebuildSize} bytes, open62541=${sourceSize} bytes`
|
|
40
|
+
);
|