@tier0/opcua-client 0.4.1 → 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.
@@ -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
- <div class="form-row"><label for="node-input-nodeId">NodeId</label><input type="text" id="node-input-nodeId" placeholder="Optional when input supplies points"></div>
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">Standard mode keeps the latest pending value per point, shards large point sets, batches changes, and automatically restores them after reconnect.</div>
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>点表可来自数组、JSON、CSV 或 Browse 输出。通过 <code>msg.opcua.command</code> 使用 <code>replace</code>、<code>add</code>、<code>remove</code>、<code>clear</code> 动态管理点位。</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 arrays, JSON, CSV, 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.</p>
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 }, nodeId: { value: '' },
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>
@@ -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 removeUndesiredPoints() {
395
+ async function removeMonitoredPoints(nodeIds) {
347
396
  const grouped = new Map();
348
397
  for (const [nodeId, monitored] of node.monitored) {
349
- if (node.desired.has(nodeId)) continue;
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) node.desired.clear();
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 statusPayload(command, failed = 0) {
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
- failed,
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
- await pruneEmptySubscriptions();
443
- if (failures.length) {
444
- throw new Error(`${failures.length} monitored item(s) failed to reconcile`);
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: 'green', shape: 'dot',
450
- text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub`,
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
- node.desired = new Map(points.map((point) => [point.nodeId, point]));
469
- await clearSubscriptions(true);
470
- await reconcileSubscriptions('command');
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 points = command === 'clear' ? [] : messagePoints(msg, config.nodeId, {
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
- points.forEach((point) => node.desired.set(point.nodeId, point));
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) => node.desired.delete(point.nodeId));
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,
@@ -560,6 +686,8 @@ module.exports = function registerSubscribeNode(RED) {
560
686
  node.connection?.client.off('fault', onFault);
561
687
  node.connection?.client.off('state', onState);
562
688
  node.desired.clear();
689
+ node.failed.clear();
690
+ consumer?.release();
563
691
  await runSerialized(() => clearSubscriptions());
564
692
  }
565
693
 
@@ -574,6 +702,39 @@ module.exports = function registerSubscribeNode(RED) {
574
702
  }
575
703
  done();
576
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
+ }
577
738
  }
578
739
 
579
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.4.1",
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
- "prepack": "node scripts/verify-offline-package.js"
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",
@@ -17,4 +17,24 @@ if (missing.length) {
17
17
  process.exit(1);
18
18
  }
19
19
 
20
- console.log('Offline package inputs verified');
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
+ );