@tier0/opcua-client 0.1.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.
@@ -0,0 +1,285 @@
1
+ 'use strict';
2
+
3
+ const { messagePoints } = require('../lib/points');
4
+
5
+ module.exports = function registerSubscribeNode(RED) {
6
+ function OpcUaSubscribeNode(config) {
7
+ RED.nodes.createNode(this, config);
8
+ const node = this;
9
+ node.connection = RED.nodes.getNode(config.connection);
10
+ node.subscriptionId = null;
11
+ node.desired = new Map();
12
+ node.monitored = new Map();
13
+ node.reconnectTimer = null;
14
+ node.reconcileChain = Promise.resolve();
15
+ node.reconcileAttempt = 0;
16
+ node.lastSessionGeneration = 0;
17
+ node.lastRecoveryReason = '';
18
+ node.closing = false;
19
+ node.lastDiagnostics = {};
20
+ node.lastDropped = 0;
21
+ node.lastStatusAt = 0;
22
+
23
+ const batchSize = Number(config.batchSize) || 500;
24
+
25
+ function emitBatch(batch) {
26
+ const diagnostics = batch.diagnostics || {};
27
+ const results = batch.changes || [];
28
+ for (const change of results) {
29
+ const point = node.desired.get(change.nodeId);
30
+ if (!point) continue;
31
+ for (const key in point) {
32
+ if (!Object.hasOwn(change, key)) change[key] = point[key];
33
+ }
34
+ }
35
+ node.lastDiagnostics = diagnostics;
36
+ const dropped = Number(diagnostics.dropped) || 0;
37
+ if (dropped > node.lastDropped) {
38
+ node.warn(`OPC UA notification queue dropped ${dropped - node.lastDropped} change(s); total ${dropped}`);
39
+ }
40
+ node.lastDropped = dropped;
41
+
42
+ if (results.length) {
43
+ if ((config.outputMode || 'batch') === 'batch') {
44
+ node.send({
45
+ payload: results,
46
+ opcua: { operation: 'change', total: results.length, queue: diagnostics },
47
+ });
48
+ } else {
49
+ results.forEach((result, index) => node.send({
50
+ topic: result.nodeId,
51
+ payload: result.value,
52
+ opcua: index === 0 ? { ...result, queue: diagnostics } : result,
53
+ }));
54
+ }
55
+ }
56
+
57
+ const now = Date.now();
58
+ if (dropped > 0 || now - node.lastStatusAt >= 1000) {
59
+ node.lastStatusAt = now;
60
+ node.status({
61
+ fill: dropped > 0 ? 'yellow' : 'green',
62
+ shape: dropped > 0 ? 'ring' : 'dot',
63
+ text: `${node.monitored.size} monitored q:${diagnostics.queueDepth || 0}/${diagnostics.queueCapacity || 0}`,
64
+ });
65
+ }
66
+ }
67
+
68
+ async function ensureSubscription() {
69
+ if (node.subscriptionId != null) return;
70
+ const result = await node.connection.client.createSubscription({
71
+ publishingInterval: Number(config.publishingInterval) || 1000,
72
+ lifetimeCount: Number(config.lifetimeCount) || 120,
73
+ maxKeepAliveCount: Number(config.maxKeepAliveCount) || 10,
74
+ maxNotificationsPerPublish: Number(config.maxNotificationsPerPublish) || 0,
75
+ callbackQueueSize: Number(config.callbackQueueSize) || 10000,
76
+ callbackBatchSize: Number(config.callbackBatchSize) || 500,
77
+ queueMode: config.queueMode || 'all',
78
+ overflowPolicy: config.overflowPolicy || 'drop-oldest',
79
+ }, emitBatch);
80
+ node.subscriptionId = result.subscriptionId;
81
+ }
82
+
83
+ async function createMonitoredItems(points) {
84
+ if (!points.length) return [];
85
+ const results = await node.connection.client.addMonitoredItems(
86
+ node.subscriptionId,
87
+ points,
88
+ {
89
+ samplingInterval: Number(config.samplingInterval) || 1000,
90
+ queueSize: Number(config.queueSize) || 10,
91
+ discardOldest: config.discardOldest !== false,
92
+ deadbandType: config.deadbandType || 'none',
93
+ deadbandValue: Number(config.deadbandValue) || 0,
94
+ },
95
+ batchSize
96
+ );
97
+ results.forEach((result) => {
98
+ if (result.statusCode === 'Good') node.monitored.set(result.nodeId, result.monitoredItemId);
99
+ });
100
+ return results;
101
+ }
102
+
103
+ async function reconcileSubscription(reason = 'state') {
104
+ if (node.closing) return;
105
+ if (!node.desired.size) {
106
+ if (node.subscriptionId != null) await clearSubscription(true);
107
+ return;
108
+ }
109
+
110
+ let rebuilt = false;
111
+ if (node.subscriptionId != null) {
112
+ const validation = await node.connection.client.validateSubscription(node.subscriptionId);
113
+ if (!validation.valid) {
114
+ try {
115
+ await node.connection.client.deleteSubscription(node.subscriptionId);
116
+ } catch {}
117
+ node.subscriptionId = null;
118
+ node.monitored.clear();
119
+ rebuilt = true;
120
+ }
121
+ }
122
+
123
+ await ensureSubscription();
124
+ const removals = [];
125
+ for (const [nodeId, monitoredItemId] of node.monitored) {
126
+ if (!node.desired.has(nodeId)) removals.push({ nodeId, monitoredItemId });
127
+ }
128
+ if (removals.length) {
129
+ const results = await node.connection.client.removeMonitoredItems(
130
+ node.subscriptionId,
131
+ removals.map((item) => item.monitoredItemId),
132
+ batchSize
133
+ );
134
+ results.forEach((result, index) => {
135
+ if (result.statusCode === 'Good') node.monitored.delete(removals[index].nodeId);
136
+ });
137
+ }
138
+
139
+ const additions = [...node.desired.values()].filter(
140
+ (point) => !node.monitored.has(point.nodeId)
141
+ );
142
+ const results = await createMonitoredItems(additions);
143
+ const failed = results.filter((result) => result.statusCode !== 'Good');
144
+ if (failed.length) throw new Error(`${failed.length} monitored item(s) failed to recover`);
145
+
146
+ node.reconcileAttempt = 0;
147
+ node.status({ fill: 'green', shape: 'dot', text: `${node.monitored.size} monitored` });
148
+ if (reason !== 'command') {
149
+ node.send({
150
+ payload: {
151
+ command: 'recover',
152
+ desired: node.desired.size,
153
+ monitored: node.monitored.size,
154
+ subscriptionId: node.subscriptionId,
155
+ rebuilt,
156
+ reason,
157
+ },
158
+ opcua: {
159
+ operation: 'subscription-status',
160
+ recovered: true,
161
+ rebuilt,
162
+ reason,
163
+ sessionGeneration: node.lastSessionGeneration,
164
+ dataGapPossible: rebuilt,
165
+ },
166
+ });
167
+ }
168
+ }
169
+
170
+ async function clearSubscription(keepDesired = false) {
171
+ if (!keepDesired) node.desired.clear();
172
+ if (node.subscriptionId != null) {
173
+ await node.connection.client.deleteSubscription(node.subscriptionId);
174
+ }
175
+ node.subscriptionId = null;
176
+ node.monitored.clear();
177
+ }
178
+
179
+ async function replacePoints(points) {
180
+ node.desired = new Map(points.map((point) => [point.nodeId, point]));
181
+ await clearSubscription(true);
182
+ await reconcileSubscription('command');
183
+ }
184
+
185
+ function runSerialized(task) {
186
+ const run = node.reconcileChain.catch(() => {}).then(task);
187
+ node.reconcileChain = run;
188
+ return run;
189
+ }
190
+
191
+ function scheduleReconnect(reason = 'connection-state', immediate = false) {
192
+ if (node.closing || node.reconnectTimer) return;
193
+ const base = Math.max(100, Number(config.reconnectInterval) || 5000);
194
+ const delay = immediate ? 0 : Math.min(30000, base * (2 ** node.reconcileAttempt));
195
+ node.reconcileAttempt += 1;
196
+ node.lastRecoveryReason = reason;
197
+ node.reconnectTimer = setTimeout(async () => {
198
+ node.reconnectTimer = null;
199
+ try {
200
+ await runSerialized(() => reconcileSubscription(reason));
201
+ } catch (error) {
202
+ node.warn(`OPC UA resubscribe failed: ${error.message}`);
203
+ node.status({ fill: 'yellow', shape: 'ring', text: 'waiting to recover' });
204
+ scheduleReconnect(reason);
205
+ }
206
+ }, delay);
207
+ }
208
+
209
+ const onFault = () => scheduleReconnect('service-fault');
210
+ const onState = (state) => {
211
+ if (!state || node.closing) return;
212
+ if (Number(state.sessionGeneration) > 0) {
213
+ node.lastSessionGeneration = Number(state.sessionGeneration);
214
+ }
215
+ if (state.state === 'connected') {
216
+ scheduleReconnect(state.reason || 'connected', true);
217
+ } else if (state.state === 'subscription-inactive' &&
218
+ (!state.subscriptionId || state.subscriptionId === node.subscriptionId)) {
219
+ node.status({ fill: 'yellow', shape: 'ring', text: 'subscription inactive' });
220
+ scheduleReconnect('subscription-inactive');
221
+ } else if (['reconnecting', 'backoff', 'failed'].includes(state.state)) {
222
+ node.status({ fill: 'yellow', shape: 'ring', text: state.state });
223
+ }
224
+ };
225
+ node.connection?.client.on('fault', onFault);
226
+ node.connection?.client.on('state', onState);
227
+
228
+ node.on('input', async (msg, send, done) => {
229
+ if (!node.connection) return done(new Error('OPC UA connection not configured'));
230
+ try {
231
+ const command = msg.opcua?.command || 'replace';
232
+ const points = command === 'clear' ? [] : messagePoints(msg, config.nodeId, {
233
+ samplingInterval: Number(config.samplingInterval) || 1000,
234
+ queueSize: Number(config.queueSize) || 10,
235
+ discardOldest: config.discardOldest !== false,
236
+ });
237
+
238
+ node.status({ fill: 'blue', shape: 'dot', text: `${command} ${points.length}` });
239
+ await runSerialized(async () => {
240
+ if (command === 'replace') await replacePoints(points);
241
+ else if (command === 'add') {
242
+ points.forEach((point) => node.desired.set(point.nodeId, point));
243
+ await reconcileSubscription('command');
244
+ } else if (command === 'remove') {
245
+ points.forEach((point) => node.desired.delete(point.nodeId));
246
+ await reconcileSubscription('command');
247
+ } else if (command === 'clear') await clearSubscription();
248
+ else throw new Error(`Unsupported subscription command: ${command}`);
249
+ });
250
+
251
+ node.status({ fill: 'green', shape: 'dot', text: `${node.monitored.size} monitored` });
252
+ send({
253
+ ...msg,
254
+ payload: {
255
+ command,
256
+ desired: node.desired.size,
257
+ monitored: node.monitored.size,
258
+ subscriptionId: node.subscriptionId,
259
+ },
260
+ opcua: { operation: 'subscription-status' },
261
+ });
262
+ done();
263
+ } catch (error) {
264
+ node.status({ fill: 'red', shape: 'ring', text: error.message });
265
+ done(error);
266
+ }
267
+ });
268
+
269
+ node.on('close', async (done) => {
270
+ node.closing = true;
271
+ if (node.reconnectTimer) clearTimeout(node.reconnectTimer);
272
+ node.connection?.client.off('fault', onFault);
273
+ node.connection?.client.off('state', onState);
274
+ node.desired.clear();
275
+ try {
276
+ await clearSubscription();
277
+ } catch (error) {
278
+ node.warn(`OPC UA subscription cleanup failed: ${error.message}`);
279
+ }
280
+ done();
281
+ });
282
+ }
283
+
284
+ RED.nodes.registerType('tier0-opcua-subscribe', OpcUaSubscribeNode);
285
+ };
@@ -0,0 +1,209 @@
1
+ <style>
2
+ .opcua-advanced { margin: 8px 0 12px; }
3
+ .opcua-advanced summary { cursor: pointer; font-weight: 600; padding: 6px 0; }
4
+ .opcua-point-editor { box-sizing: border-box; display: inline-block; vertical-align: top; width: calc(100% - 110px); }
5
+ .opcua-point-toolbar { display: flex; gap: 6px; margin-bottom: 8px; }
6
+ .opcua-write-point-add { display: grid; gap: 6px; grid-template-columns: minmax(180px, 2fr) minmax(90px, 1fr) 110px auto; margin-bottom: 8px; }
7
+ .opcua-write-point-add input, .opcua-write-point-add select { min-width: 0; width: 100%; }
8
+ .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%; }
9
+ .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; }
10
+ .opcua-point-list li:last-child { border-bottom: 0; }
11
+ .opcua-point-list .opcua-point-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
12
+ .opcua-point-list .opcua-point-empty { color: var(--red-ui-form-placeholder-color, #888); justify-content: center; }
13
+ .opcua-point-list .opcua-point-remove { border: 0; color: var(--red-ui-text-color, #555); flex: 0 0 auto; }
14
+ .opcua-point-status { color: var(--red-ui-form-text-color, #666); font-size: 0.9em; margin-top: 5px; }
15
+ </style>
16
+ <script type="text/html" data-template-name="tier0-opcua-write">
17
+ <div class="form-row"><label for="node-input-connection">Connection</label><input type="text" id="node-input-connection"></div>
18
+ <input type="hidden" id="node-input-nodeId">
19
+ <input type="hidden" id="node-input-pointList">
20
+ <input type="hidden" id="node-input-pointListFileName">
21
+ <input type="file" id="node-input-pointFile" accept=".json,.csv,application/json,text/csv" style="display:none">
22
+ <div class="form-row">
23
+ <label>Points</label>
24
+ <div class="opcua-point-editor">
25
+ <div class="opcua-point-toolbar">
26
+ <button type="button" class="red-ui-button" id="node-input-importPointList"><i class="fa fa-upload"></i> Import</button>
27
+ <button type="button" class="red-ui-button" id="node-input-clearPointList"><i class="fa fa-trash"></i> Clear all</button>
28
+ </div>
29
+ <div class="opcua-write-point-add">
30
+ <input type="text" id="node-input-newPointNodeId" placeholder="NodeId">
31
+ <input type="text" id="node-input-newPointValue" placeholder="Value">
32
+ <select id="node-input-newPointDataType"><option>String</option><option>Boolean</option><option>SByte</option><option>Byte</option><option>Int16</option><option>UInt16</option><option>Int32</option><option>UInt32</option><option>Int64</option><option>UInt64</option><option>Float</option><option>Double</option><option>DateTime</option><option>ByteString</option></select>
33
+ <button type="button" class="red-ui-button" id="node-input-addPoint"><i class="fa fa-plus"></i> Add</button>
34
+ </div>
35
+ <ol class="opcua-point-list" id="node-input-pointListItems"></ol>
36
+ <div class="opcua-point-status" id="node-input-pointListStatus">0 points</div>
37
+ </div>
38
+ </div>
39
+ <details class="opcua-advanced">
40
+ <summary>Advanced settings</summary>
41
+ <div class="form-row"><label for="node-input-dataType">Default type</label><select id="node-input-dataType"><option>String</option><option>Boolean</option><option>SByte</option><option>Byte</option><option>Int16</option><option>UInt16</option><option>Int32</option><option>UInt32</option><option>Int64</option><option>UInt64</option><option>Float</option><option>Double</option><option>DateTime</option><option>ByteString</option></select></div>
42
+ <div class="form-row"><label for="node-input-batchSize">Batch size</label><input type="number" id="node-input-batchSize"></div>
43
+ <div class="form-row"><label for="node-input-outputMode">Output</label><select id="node-input-outputMode"><option value="batch">Batch</option><option value="split">One message per point</option></select></div>
44
+ </details>
45
+ <div class="form-row"><label for="node-input-name">Name</label><input type="text" id="node-input-name"></div>
46
+ </script>
47
+ <script type="text/html" data-help-name="tier0-opcua-write">
48
+ <p>Add NodeId, value, and data type rows or import JSON/CSV. Imported points are parsed and shown in the same list.</p>
49
+ <p>CSV requires <code>nodeId</code> and <code>value</code>; <code>dataType</code> is optional. A JSON/CSV point list may also arrive dynamically in <code>msg.payload</code>.</p>
50
+ </script>
51
+ <script type="text/javascript">
52
+ RED.nodes.registerType('tier0-opcua-write', {
53
+ category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 1, icon: 'bridge.png',
54
+ defaults: {
55
+ connection: { value: '', type: 'tier0-opcua-connection', required: true },
56
+ nodeId: { value: '' }, dataType: { value: 'String' },
57
+ pointList: { value: '' }, pointListFileName: { value: '' },
58
+ batchSize: { value: 500, validate: RED.validators.number() },
59
+ outputMode: { value: 'batch' }, name: { value: '' }
60
+ },
61
+ oneditprepare: function () {
62
+ const points = new Map();
63
+
64
+ const csvLine = (line) => {
65
+ const fields = [];
66
+ let value = '';
67
+ let quoted = false;
68
+ for (let index = 0; index < line.length; index += 1) {
69
+ const character = line[index];
70
+ if (character === '"') {
71
+ if (quoted && line[index + 1] === '"') { value += '"'; index += 1; }
72
+ else quoted = !quoted;
73
+ } else if (character === ',' && !quoted) {
74
+ fields.push(value.trim()); value = '';
75
+ } else value += character;
76
+ }
77
+ fields.push(value.trim());
78
+ return fields;
79
+ };
80
+
81
+ const parse = (content) => {
82
+ const text = String(content || '').replace(/^\uFEFF/, '').trim();
83
+ if (!text) return [];
84
+ if (text.startsWith('[') || text.startsWith('{')) {
85
+ const parsed = JSON.parse(text);
86
+ return Array.isArray(parsed) ? parsed : (Array.isArray(parsed.items) ? parsed.items : [parsed]);
87
+ }
88
+ const lines = text.split(/\r?\n/).filter((line) => line.trim() && !line.trimStart().startsWith('#'));
89
+ if (!lines.length) return [];
90
+ const headers = csvLine(lines.shift());
91
+ if (!headers.includes('nodeId')) throw new Error('CSV header must contain nodeId');
92
+ if (!headers.includes('value')) throw new Error('CSV header must contain value');
93
+ return lines.map((line) => {
94
+ const values = csvLine(line);
95
+ return Object.fromEntries(headers.map((header, index) => [header, values[index] || '']));
96
+ });
97
+ };
98
+
99
+ const normalize = (item) => {
100
+ const source = typeof item === 'string' ? { nodeId: item } : { ...(item || {}) };
101
+ source.nodeId = String(source.nodeId || source.topic || '').trim();
102
+ if (!source.nodeId) throw new Error('Point is missing nodeId');
103
+ if (!Object.prototype.hasOwnProperty.call(source, 'value')) source.useInput = true;
104
+ source.dataType = String(source.dataType || $('#node-input-dataType').val() || 'String');
105
+ return source;
106
+ };
107
+
108
+ const valueText = (value) => {
109
+ if (typeof value === 'string') return value;
110
+ try { return JSON.stringify(value); } catch (_) { return String(value); }
111
+ };
112
+
113
+ const persist = () => {
114
+ const items = [...points.values()];
115
+ $('#node-input-pointList').val(items.length ? JSON.stringify(items) : '');
116
+ if (items.length) $('#node-input-nodeId').val('');
117
+ $('#node-input-pointListStatus').text(`${items.length} point${items.length === 1 ? '' : 's'}`);
118
+ };
119
+
120
+ const render = () => {
121
+ const list = document.getElementById('node-input-pointListItems');
122
+ list.textContent = '';
123
+ if (!points.size) {
124
+ const empty = document.createElement('li');
125
+ empty.className = 'opcua-point-empty';
126
+ empty.textContent = 'No points. Add a write item or import a file.';
127
+ list.appendChild(empty);
128
+ } else {
129
+ const fragment = document.createDocumentFragment();
130
+ points.forEach((point, nodeId) => {
131
+ const row = document.createElement('li');
132
+ const text = document.createElement('span');
133
+ text.className = 'opcua-point-text';
134
+ const shownValue = point.useInput ? '<from msg.payload>' : valueText(point.value);
135
+ text.textContent = `${nodeId} = ${shownValue} (${point.dataType})`;
136
+ text.title = text.textContent;
137
+ const remove = document.createElement('button');
138
+ remove.type = 'button';
139
+ remove.className = 'red-ui-button opcua-point-remove';
140
+ remove.title = `Remove ${nodeId}`;
141
+ remove.innerHTML = '<i class="fa fa-times"></i>';
142
+ remove.addEventListener('click', () => { points.delete(nodeId); render(); });
143
+ row.append(text, remove);
144
+ fragment.appendChild(row);
145
+ });
146
+ list.appendChild(fragment);
147
+ }
148
+ persist();
149
+ };
150
+
151
+ const merge = (items) => {
152
+ items.forEach((item) => { const point = normalize(item); points.set(point.nodeId, point); });
153
+ render();
154
+ };
155
+
156
+ try {
157
+ const stored = $('#node-input-pointList').val();
158
+ if (stored) merge(parse(stored));
159
+ else if ($('#node-input-nodeId').val()) merge([{
160
+ nodeId: $('#node-input-nodeId').val(),
161
+ dataType: $('#node-input-dataType').val(),
162
+ useInput: true
163
+ }]);
164
+ else render();
165
+ } catch (error) {
166
+ RED.notify(`Cannot load point list: ${error.message}`, 'error');
167
+ render();
168
+ }
169
+
170
+ $('#node-input-addPoint').on('click', () => {
171
+ try {
172
+ merge([{
173
+ nodeId: $('#node-input-newPointNodeId').val(),
174
+ value: $('#node-input-newPointValue').val(),
175
+ dataType: $('#node-input-newPointDataType').val()
176
+ }]);
177
+ $('#node-input-newPointNodeId, #node-input-newPointValue').val('');
178
+ $('#node-input-newPointNodeId').focus();
179
+ } catch (error) { RED.notify(error.message, 'error'); }
180
+ });
181
+ $('#node-input-newPointNodeId, #node-input-newPointValue').on('keydown', function (event) {
182
+ if (event.key === 'Enter') { event.preventDefault(); $('#node-input-addPoint').click(); }
183
+ });
184
+ $('#node-input-importPointList').on('click', () => $('#node-input-pointFile').click());
185
+ $('#node-input-pointFile').on('change', function () {
186
+ const file = this.files && this.files[0];
187
+ if (!file) return;
188
+ const reader = new FileReader();
189
+ reader.onload = () => {
190
+ try {
191
+ const imported = parse(reader.result);
192
+ merge(imported);
193
+ $('#node-input-pointListFileName').val(file.name);
194
+ RED.notify(`${imported.length} point(s) imported`, 'success');
195
+ } catch (error) { RED.notify(`Import failed: ${error.message}`, 'error'); }
196
+ $('#node-input-pointFile').val('');
197
+ };
198
+ reader.readAsText(file);
199
+ });
200
+ $('#node-input-clearPointList').on('click', () => {
201
+ points.clear();
202
+ $('#node-input-nodeId').val('');
203
+ $('#node-input-pointListFileName').val('');
204
+ render();
205
+ });
206
+ },
207
+ label: function () { return this.name || 'tier0-opcua-write'; }
208
+ });
209
+ </script>
package/nodes/write.js ADDED
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ const { messageWritePoints } = require('../lib/points');
4
+
5
+ module.exports = function registerWriteNode(RED) {
6
+ function OpcUaWriteNode(config) {
7
+ RED.nodes.createNode(this, config);
8
+ const node = this;
9
+ node.connection = RED.nodes.getNode(config.connection);
10
+
11
+ node.on('input', async (msg, send, done) => {
12
+ if (!node.connection) return done(new Error('OPC UA connection not configured'));
13
+
14
+ try {
15
+ const points = messageWritePoints(msg, config);
16
+ node.status({ fill: 'blue', shape: 'dot', text: `writing ${points.length}` });
17
+ const results = await node.connection.client.writeMany(
18
+ points,
19
+ Number(config.batchSize) || 500
20
+ );
21
+
22
+ if ((config.outputMode || 'batch') === 'split') {
23
+ results.forEach((result) => send({ ...msg, topic: result.nodeId, opcua: result }));
24
+ } else {
25
+ msg.payload = results;
26
+ msg.opcua = {
27
+ ...(msg.opcua || {}),
28
+ operation: 'write',
29
+ total: results.length,
30
+ good: results.filter((item) => item.statusCode === 'Good').length,
31
+ clientDiagnostics: node.connection.client.getDiagnostics(),
32
+ };
33
+ send(msg);
34
+ }
35
+
36
+ node.status({ fill: 'green', shape: 'dot', text: `${results.length} written` });
37
+ done();
38
+ } catch (error) {
39
+ node.status({ fill: 'red', shape: 'ring', text: error.message });
40
+ done(error);
41
+ }
42
+ });
43
+ }
44
+
45
+ RED.nodes.registerType('tier0-opcua-write', OpcUaWriteNode);
46
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@tier0/opcua-client",
3
+ "version": "0.1.0",
4
+ "description": "High-scale OPC UA client nodes for Node-RED powered by open62541",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/loliuy/opcua-client.git"
9
+ },
10
+ "main": "index.js",
11
+ "gypfile": true,
12
+ "files": [
13
+ "binding.gyp",
14
+ "index.js",
15
+ "lib",
16
+ "nodes",
17
+ "src",
18
+ "scripts",
19
+ "README.md",
20
+ "BENCHMARK.md",
21
+ "LICENSE"
22
+ ],
23
+ "scripts": {
24
+ "install": "npm run build:open62541 && node-gyp rebuild",
25
+ "build:open62541": "bash scripts/build-open62541.sh",
26
+ "build": "node-gyp rebuild",
27
+ "clean": "node-gyp clean",
28
+ "test": "node --test test/*.test.js",
29
+ "test:native": "node test/native.integration.js && node test/native.queue.integration.js && node test/reconnect.integration.js",
30
+ "test:reconnect": "node test/reconnect.integration.js",
31
+ "test:stress": "node test/stress.js",
32
+ "test:nodered": "node test/nodered.integration.js",
33
+ "test:nodered:reconnect": "OPCUA_NODERED_RECONNECT=1 node test/nodered.integration.js"
34
+ },
35
+ "keywords": [
36
+ "node-red",
37
+ "opcua",
38
+ "opc-ua",
39
+ "open62541",
40
+ "industrial-iot"
41
+ ],
42
+ "engines": {
43
+ "node": ">=20"
44
+ },
45
+ "node-red": {
46
+ "version": ">=3.1",
47
+ "nodes": {
48
+ "tier0-opcua-connection": "nodes/connection.js",
49
+ "tier0-opcua-browse": "nodes/browse.js",
50
+ "tier0-opcua-read": "nodes/read.js",
51
+ "tier0-opcua-write": "nodes/write.js",
52
+ "tier0-opcua-subscribe": "nodes/subscribe.js"
53
+ }
54
+ },
55
+ "dependencies": {
56
+ "node-addon-api": "^8.3.1"
57
+ },
58
+ "devDependencies": {
59
+ "node-gyp": "^11.4.2"
60
+ }
61
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
5
+ ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd -P)"
6
+ VERSION="${OPEN62541_VERSION:-1.4.16}"
7
+ BUILD_ROOT="${ROOT_DIR}/.build/open62541-src"
8
+ INSTALL_ROOT="${OPEN62541_PREFIX:-${ROOT_DIR}/.build/open62541}"
9
+
10
+ if [[ ! -d "${BUILD_ROOT}/.git" ]]; then
11
+ rm -rf "${BUILD_ROOT}"
12
+ git clone --depth 1 --branch "v${VERSION}" \
13
+ https://github.com/open62541/open62541.git "${BUILD_ROOT}"
14
+ fi
15
+
16
+ cmake -S "${BUILD_ROOT}" -B "${BUILD_ROOT}/build" \
17
+ -DCMAKE_BUILD_TYPE=Release \
18
+ -DCMAKE_POSITION_INDEPENDENT_CODE=ON \
19
+ -DBUILD_SHARED_LIBS=OFF \
20
+ -DUA_ENABLE_ENCRYPTION=OFF \
21
+ -DUA_BUILD_EXAMPLES=OFF \
22
+ -DUA_BUILD_UNIT_TESTS=OFF \
23
+ -DCMAKE_INSTALL_PREFIX="${INSTALL_ROOT}"
24
+
25
+ cmake --build "${BUILD_ROOT}/build" --parallel "${BUILD_JOBS:-2}"
26
+ cmake --install "${BUILD_ROOT}/build"