@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.
package/lib/native.js ADDED
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ let injectedAddon;
4
+ let loadedAddon;
5
+
6
+ function loadAddon() {
7
+ if (injectedAddon) return injectedAddon;
8
+ if (loadedAddon) return loadedAddon;
9
+
10
+ try {
11
+ loadedAddon = require('../build/Release/opcua_open62541.node');
12
+ return loadedAddon;
13
+ } catch (error) {
14
+ const wrapped = new Error(
15
+ `Native open62541 addon is unavailable: ${error.message}. ` +
16
+ 'Run npm run build:open62541 && npm run build.'
17
+ );
18
+ wrapped.cause = error;
19
+ throw wrapped;
20
+ }
21
+ }
22
+
23
+ function setAddonForTests(addon) {
24
+ injectedAddon = addon;
25
+ }
26
+
27
+ function resetAddonForTests() {
28
+ injectedAddon = undefined;
29
+ loadedAddon = undefined;
30
+ }
31
+
32
+ module.exports = { loadAddon, setAddonForTests, resetAddonForTests };
package/lib/points.js ADDED
@@ -0,0 +1,231 @@
1
+ 'use strict';
2
+
3
+ const DEFAULTS = Object.freeze({
4
+ samplingInterval: 1000,
5
+ queueSize: 10,
6
+ discardOldest: true,
7
+ });
8
+
9
+ function positiveNumber(value, fallback) {
10
+ const number = Number(value);
11
+ return Number.isFinite(number) && number > 0 ? number : fallback;
12
+ }
13
+
14
+ function normalizePoint(input, defaults = {}) {
15
+ const source = typeof input === 'string' ? { nodeId: input } : { ...(input || {}) };
16
+ const nodeId = String(source.nodeId || source.topic || '').trim();
17
+ if (!nodeId) throw new Error('Point is missing nodeId');
18
+
19
+ const point = {
20
+ ...source,
21
+ nodeId,
22
+ samplingInterval: positiveNumber(
23
+ source.samplingInterval,
24
+ positiveNumber(defaults.samplingInterval, DEFAULTS.samplingInterval)
25
+ ),
26
+ queueSize: Math.floor(positiveNumber(
27
+ source.queueSize,
28
+ positiveNumber(defaults.queueSize, DEFAULTS.queueSize)
29
+ )),
30
+ discardOldest: source.discardOldest == null
31
+ ? defaults.discardOldest !== false
32
+ : source.discardOldest !== false,
33
+ };
34
+
35
+ if (point.alias != null) point.alias = String(point.alias);
36
+ if (point.dataType != null) point.dataType = String(point.dataType);
37
+ if (point.enabled == null) point.enabled = true;
38
+ return point;
39
+ }
40
+
41
+ function normalizePoints(inputs, defaults = {}, options = {}) {
42
+ if (!Array.isArray(inputs)) throw new Error('Point list must be an array');
43
+
44
+ const duplicateMode = options.duplicateMode || 'last';
45
+ const points = new Map();
46
+ const errors = [];
47
+
48
+ inputs.forEach((input, index) => {
49
+ try {
50
+ const point = normalizePoint(input, defaults);
51
+ if (point.enabled === false) return;
52
+ if (points.has(point.nodeId) && duplicateMode === 'error') {
53
+ throw new Error(`Duplicate nodeId: ${point.nodeId}`);
54
+ }
55
+ if (!points.has(point.nodeId) || duplicateMode !== 'first') {
56
+ points.set(point.nodeId, point);
57
+ }
58
+ } catch (error) {
59
+ errors.push({ index, error: error.message, input });
60
+ }
61
+ });
62
+
63
+ if (errors.length && options.allowInvalid !== true) {
64
+ const error = new Error(`Invalid point list: ${errors.length} item(s) failed validation`);
65
+ error.items = errors;
66
+ throw error;
67
+ }
68
+
69
+ return { points: [...points.values()], errors };
70
+ }
71
+
72
+ function parseCsvLine(line) {
73
+ const fields = [];
74
+ let value = '';
75
+ let quoted = false;
76
+
77
+ for (let index = 0; index < line.length; index += 1) {
78
+ const char = line[index];
79
+ if (char === '"') {
80
+ if (quoted && line[index + 1] === '"') {
81
+ value += '"';
82
+ index += 1;
83
+ } else {
84
+ quoted = !quoted;
85
+ }
86
+ } else if (char === ',' && !quoted) {
87
+ fields.push(value.trim());
88
+ value = '';
89
+ } else {
90
+ value += char;
91
+ }
92
+ }
93
+ fields.push(value.trim());
94
+ return fields;
95
+ }
96
+
97
+ function parseCsv(text) {
98
+ const lines = String(text)
99
+ .replace(/^\uFEFF/, '')
100
+ .split(/\r?\n/)
101
+ .filter((line) => line.trim() && !line.trimStart().startsWith('#'));
102
+ if (!lines.length) return [];
103
+
104
+ const headers = parseCsvLine(lines.shift()).map((header) => header.trim());
105
+ if (!headers.includes('nodeId')) throw new Error('CSV header must contain nodeId');
106
+
107
+ return lines.map((line) => {
108
+ const values = parseCsvLine(line);
109
+ return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? '']));
110
+ });
111
+ }
112
+
113
+ function parsePointPayload(payload, format = 'auto') {
114
+ if (Array.isArray(payload)) return payload;
115
+ const text = Buffer.isBuffer(payload) ? payload.toString('utf8') : String(payload ?? '').trim();
116
+ if (!text) return [];
117
+
118
+ const selected = format === 'auto'
119
+ ? (text.startsWith('[') || text.startsWith('{') ? 'json' : 'csv')
120
+ : format;
121
+
122
+ if (selected === 'json') {
123
+ const parsed = JSON.parse(text);
124
+ if (Array.isArray(parsed)) return parsed;
125
+ if (Array.isArray(parsed.items)) return parsed.items;
126
+ return [parsed];
127
+ }
128
+ if (selected === 'csv') return parseCsv(text);
129
+ throw new Error(`Unsupported point-list format: ${selected}`);
130
+ }
131
+
132
+ function looksLikePointList(payload) {
133
+ if (Array.isArray(payload)) return true;
134
+ if (payload && typeof payload === 'object' && Array.isArray(payload.items)) return true;
135
+ if (payload == null || (typeof payload !== 'string' && !Buffer.isBuffer(payload))) return false;
136
+ const text = (Buffer.isBuffer(payload) ? payload.toString('utf8') : payload)
137
+ .replace(/^\uFEFF/, '')
138
+ .trimStart();
139
+ return text.startsWith('[') || text.startsWith('{') ||
140
+ /^nodeId(?:\s*,|\s*\r?\n)/i.test(text);
141
+ }
142
+
143
+ function normalizePointPayload(payload, defaults = {}, options = {}) {
144
+ const parsed = parsePointPayload(payload, options.format || 'auto');
145
+ return normalizePoints(parsed, defaults, options);
146
+ }
147
+
148
+ function messagePoints(msg, staticNodeId, defaults = {}, options = {}) {
149
+ let payload;
150
+ if (looksLikePointList(msg.payload)) payload = msg.payload;
151
+ else if (Array.isArray(msg.items)) payload = msg.items;
152
+ else if (options.configuredPayload) payload = options.configuredPayload;
153
+
154
+ if (payload != null) {
155
+ return normalizePointPayload(payload, defaults, options).points;
156
+ }
157
+
158
+ const nodeId = staticNodeId || msg.nodeId || msg.topic;
159
+ if (!nodeId) throw new Error('Missing OPC UA nodeId or point list');
160
+ return [normalizePoint({ nodeId, alias: msg.alias, dataType: msg.dataType }, defaults)];
161
+ }
162
+
163
+ function coerceWriteValue(value, dataType) {
164
+ if (value == null || typeof value !== 'string') return value;
165
+ const type = String(dataType || 'String');
166
+ if (type === 'Boolean') {
167
+ if (/^(true|1)$/i.test(value)) return true;
168
+ if (/^(false|0)$/i.test(value)) return false;
169
+ throw new Error(`Invalid Boolean value: ${value}`);
170
+ }
171
+ if (/^(S?Byte|U?Int(16|32|64)|Float|Double)$/.test(type)) {
172
+ const number = Number(value);
173
+ if (!Number.isFinite(number)) throw new Error(`Invalid ${type} value: ${value}`);
174
+ return number;
175
+ }
176
+ return value;
177
+ }
178
+
179
+ function messageWritePoints(msg, config = {}) {
180
+ let points;
181
+ let configuredPoints = false;
182
+ if (looksLikePointList(msg.payload)) {
183
+ points = normalizePointPayload(msg.payload, {}, { format: config.inputFormat || 'auto' }).points;
184
+ } else if (msg.payload && typeof msg.payload === 'object' && msg.payload.nodeId) {
185
+ points = normalizePoints([msg.payload]).points;
186
+ } else if (config.pointList) {
187
+ points = normalizePointPayload(config.pointList, {}, { format: 'auto' }).points;
188
+ configuredPoints = true;
189
+ } else {
190
+ const nodeId = config.nodeId || msg.nodeId || msg.topic;
191
+ if (!nodeId) throw new Error('Missing OPC UA nodeId or point list');
192
+ points = normalizePoints([{ nodeId, value: msg.payload }]).points;
193
+ }
194
+
195
+ return points.map((point) => {
196
+ const dataType = point.dataType || msg.dataType || config.dataType || 'String';
197
+ let value = point.value;
198
+ if (configuredPoints && point.useInput && !Object.prototype.hasOwnProperty.call(point, 'value')) {
199
+ if (points.length === 1) value = msg.payload;
200
+ else if (msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload)) {
201
+ if (Object.prototype.hasOwnProperty.call(msg.payload, point.nodeId)) value = msg.payload[point.nodeId];
202
+ else if (point.alias && Object.prototype.hasOwnProperty.call(msg.payload, point.alias)) value = msg.payload[point.alias];
203
+ else throw new Error(`Missing input value for ${point.nodeId}`);
204
+ } else throw new Error(`Missing input value for ${point.nodeId}`);
205
+ }
206
+ return { ...point, dataType, value: coerceWriteValue(value, dataType) };
207
+ });
208
+ }
209
+
210
+ function chunks(items, size) {
211
+ const chunkSize = Math.max(1, Math.floor(positiveNumber(size, 500)));
212
+ const result = [];
213
+ for (let index = 0; index < items.length; index += chunkSize) {
214
+ result.push(items.slice(index, index + chunkSize));
215
+ }
216
+ return result;
217
+ }
218
+
219
+ module.exports = {
220
+ DEFAULTS,
221
+ chunks,
222
+ coerceWriteValue,
223
+ looksLikePointList,
224
+ messagePoints,
225
+ messageWritePoints,
226
+ normalizePoint,
227
+ normalizePointPayload,
228
+ normalizePoints,
229
+ parseCsv,
230
+ parsePointPayload,
231
+ };
@@ -0,0 +1,258 @@
1
+ <style>
2
+ .opcua-advanced { margin: 8px 0 12px; }
3
+ .opcua-advanced summary { cursor: pointer; font-weight: 600; padding: 6px 0; }
4
+ .opcua-tree-tools { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
5
+ .opcua-tree-search { width: 100% !important; margin-bottom: 6px !important; }
6
+ .opcua-tree-status { color: var(--red-ui-form-text-color, #666); font-size: 0.9em; min-height: 20px; }
7
+ .opcua-tree { height: 360px; overflow: auto; border: 1px solid var(--red-ui-form-input-border-color, #ccc); border-radius: 3px; padding: 5px; background: var(--red-ui-form-input-background, #fff); }
8
+ .opcua-tree ul { list-style: none; margin: 0; padding-left: 20px; }
9
+ .opcua-tree > ul { padding-left: 0; }
10
+ .opcua-tree-row { display: flex; align-items: center; min-height: 25px; white-space: nowrap; }
11
+ .opcua-tree-toggle { width: 20px; min-width: 20px; padding: 0; border: 0; background: transparent; color: inherit; }
12
+ .opcua-tree-toggle.is-leaf { visibility: hidden; }
13
+ .opcua-tree-check { width: auto !important; margin: 0 6px 0 0 !important; }
14
+ .opcua-tree-label { overflow: hidden; text-overflow: ellipsis; }
15
+ .opcua-tree-class { margin-left: 6px; color: #888; font-size: 0.85em; }
16
+ </style>
17
+ <script type="text/html" data-template-name="tier0-opcua-browse">
18
+ <div class="form-row"><label for="node-input-connection">Connection</label><input type="text" id="node-input-connection"></div>
19
+ <div class="form-row"><label for="node-input-rootNodeId">Root NodeId</label><input type="text" id="node-input-rootNodeId" placeholder="ns=0;i=85"></div>
20
+ <div class="form-row">
21
+ <label style="vertical-align:top">Node tree</label>
22
+ <div style="display:inline-block;width:70%">
23
+ <div class="opcua-tree-tools">
24
+ <button type="button" class="red-ui-button" id="node-input-loadTree"><i class="fa fa-search"></i> Load tree</button>
25
+ <button type="button" class="red-ui-button" id="node-input-selectAll"><i class="fa fa-check-square-o"></i> Select all</button>
26
+ <button type="button" class="red-ui-button" id="node-input-clearSelection"><i class="fa fa-square-o"></i> Clear</button>
27
+ </div>
28
+ <input type="text" class="opcua-tree-search" id="node-input-treeSearch" placeholder="Filter by name, path, or NodeId">
29
+ <div class="opcua-tree-status" id="node-input-treeStatus"></div>
30
+ <div class="opcua-tree" id="node-input-tree"><div class="opcua-tree-status">Load the deployed connection to browse and select variables.</div></div>
31
+ </div>
32
+ </div>
33
+ <details class="opcua-advanced">
34
+ <summary>Advanced browse settings</summary>
35
+ <div class="form-row"><label for="node-input-recursive">Recursive</label><input type="checkbox" id="node-input-recursive" style="width:auto"></div>
36
+ <div class="form-row"><label for="node-input-variablesOnly">Variables only</label><input type="checkbox" id="node-input-variablesOnly" style="width:auto"></div>
37
+ <div class="form-row"><label for="node-input-maxDepth">Maximum depth</label><input type="number" id="node-input-maxDepth"></div>
38
+ <div class="form-row"><label for="node-input-maxNodes">Maximum nodes</label><input type="number" id="node-input-maxNodes"></div>
39
+ <div class="form-row"><label for="node-input-attributeBatchSize">Attribute batch</label><input type="number" id="node-input-attributeBatchSize"></div>
40
+ <div class="form-row"><label for="node-input-outputMode">Output</label><select id="node-input-outputMode"><option value="all">All selected points</option><option value="paged">Paged selected points</option></select></div>
41
+ <div class="form-row"><label for="node-input-pageSize">Page size</label><input type="number" id="node-input-pageSize"></div>
42
+ </details>
43
+ <div class="form-row"><label for="node-input-name">Name</label><input type="text" id="node-input-name"></div>
44
+ </script>
45
+ <script type="text/html" data-help-name="tier0-opcua-browse">
46
+ <p>Loads the deployed OPC UA connection as a checkbox tree. Checking a parent selects every variable below it. Clearing a child makes its ancestors partially selected.</p>
47
+ <p>On input the node outputs every saved checked variable, ready for Read or Subscribe. Send <code>msg.opcua.liveBrowse=true</code> to bypass the saved selection and browse live.</p>
48
+ </script>
49
+ <script type="text/javascript">
50
+ RED.nodes.registerType('tier0-opcua-browse', {
51
+ category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 1, icon: 'search.png',
52
+ defaults: {
53
+ connection: { value: '', type: 'tier0-opcua-connection', required: true }, rootNodeId: { value: 'ns=0;i=85' },
54
+ selectedNodes: { value: [] }, recursive: { value: true }, variablesOnly: { value: true },
55
+ maxDepth: { value: 10, validate: RED.validators.number() }, maxNodes: { value: 10000, validate: RED.validators.number() },
56
+ attributeBatchSize: { value: 200, validate: RED.validators.number() }, outputMode: { value: 'all' },
57
+ pageSize: { value: 500, validate: RED.validators.number() }, name: { value: '' }
58
+ },
59
+ oneditprepare: function () {
60
+ const self = this;
61
+ let treeRoot = null;
62
+ let treeLoaded = false;
63
+ let selectedIds = new Set((self.selectedNodes || []).map((item) => item.nodeId));
64
+ let selectedDescriptors = new Map((self.selectedNodes || []).map((item) => [item.nodeId, item]));
65
+ const treeArea = $('#node-input-tree');
66
+ const statusArea = $('#node-input-treeStatus');
67
+ const loadButton = $('#node-input-loadTree');
68
+ const loadIcon = loadButton.children('i');
69
+
70
+ function adminUrl() {
71
+ const api = RED.settings && RED.settings.apiRootUrl;
72
+ if (api) return String(api).replace(/\/?$/, '/') + 'opcua-client/browse';
73
+ const root = RED.settings && RED.settings.httpAdminRoot;
74
+ if (root && String(root) !== '/') return String(root).replace(/\/+$/, '') + '/opcua-client/browse';
75
+ const path = String(window.location.pathname || '').replace(/\/+$/, '');
76
+ return (path && path !== '/' ? path : '') + '/opcua-client/browse';
77
+ }
78
+
79
+ function descriptor(node) {
80
+ const item = { ...node };
81
+ delete item.children;
82
+ delete item._checkbox;
83
+ delete item._childrenElement;
84
+ delete item._listItem;
85
+ return item;
86
+ }
87
+
88
+ function walk(node, action) {
89
+ action(node);
90
+ (node.children || []).forEach((child) => walk(child, action));
91
+ }
92
+
93
+ function setSubtree(node, checked) {
94
+ walk(node, (current) => {
95
+ if (current.nodeClass !== 'Variable') return;
96
+ if (checked) {
97
+ selectedIds.add(current.nodeId);
98
+ selectedDescriptors.set(current.nodeId, descriptor(current));
99
+ } else {
100
+ selectedIds.delete(current.nodeId);
101
+ selectedDescriptors.delete(current.nodeId);
102
+ }
103
+ });
104
+ }
105
+
106
+ function updateChecks(node) {
107
+ let total = node.nodeClass === 'Variable' ? 1 : 0;
108
+ let selected = node.nodeClass === 'Variable' && selectedIds.has(node.nodeId) ? 1 : 0;
109
+ (node.children || []).forEach((child) => {
110
+ const childState = updateChecks(child);
111
+ total += childState.total;
112
+ selected += childState.selected;
113
+ });
114
+ if (node._checkbox) {
115
+ const checked = total > 0 && selected === total;
116
+ const indeterminate = selected > 0 && selected < total;
117
+ node._checkbox.prop('disabled', total === 0);
118
+ node._checkbox.prop('checked', checked);
119
+ node._checkbox.prop('indeterminate', indeterminate);
120
+ node._checkbox.attr('aria-checked', indeterminate ? 'mixed' : checked ? 'true' : 'false');
121
+ }
122
+ return { total, selected };
123
+ }
124
+
125
+ function updateSelectionStatus() {
126
+ if (!treeRoot) {
127
+ statusArea.text(`${selectedIds.size} saved variable(s); load the tree to edit the selection.`);
128
+ return;
129
+ }
130
+ const state = updateChecks(treeRoot);
131
+ statusArea.text(`${state.selected} of ${state.total} variable(s) selected`);
132
+ }
133
+
134
+ function renderNode(node, expanded) {
135
+ const item = $('<li/>');
136
+ const row = $('<div class="opcua-tree-row"/>');
137
+ const hasChildren = Array.isArray(node.children) && node.children.length > 0;
138
+ const toggle = $('<button type="button" class="opcua-tree-toggle" aria-label="Toggle branch"><i class="fa"/></button>');
139
+ if (!hasChildren) toggle.addClass('is-leaf');
140
+ const checkbox = $('<input type="checkbox" class="opcua-tree-check">');
141
+ const label = $('<span class="opcua-tree-label"/>').text(node.displayName || node.browseName || node.nodeId);
142
+ const nodeClass = $('<span class="opcua-tree-class"/>').text(node.nodeClass === 'Variable' ? 'variable' : 'folder');
143
+ row.append(toggle, checkbox, label, nodeClass);
144
+ item.append(row);
145
+ node._checkbox = checkbox;
146
+ node._listItem = item;
147
+
148
+ let childrenElement = null;
149
+ if (hasChildren) {
150
+ childrenElement = $('<ul/>');
151
+ node.children.forEach((child) => childrenElement.append(renderNode(child, false)));
152
+ item.append(childrenElement);
153
+ node._childrenElement = childrenElement;
154
+ const setExpanded = (open) => {
155
+ childrenElement.toggle(open);
156
+ toggle.children('i').attr('class', open ? 'fa fa-caret-down' : 'fa fa-caret-right');
157
+ };
158
+ setExpanded(expanded);
159
+ toggle.on('click', () => setExpanded(!childrenElement.is(':visible')));
160
+ }
161
+
162
+ checkbox.on('change', function () {
163
+ setSubtree(node, this.checked);
164
+ updateSelectionStatus();
165
+ });
166
+ return item;
167
+ }
168
+
169
+ function showSubtree(node, visible) {
170
+ if (node._listItem) node._listItem.toggle(visible);
171
+ (node.children || []).forEach((child) => showSubtree(child, visible));
172
+ }
173
+
174
+ function applyFilter(node, term) {
175
+ const text = `${node.displayName || ''} ${node.browseName || ''} ${node.browsePath || ''} ${node.nodeId || ''}`.toLowerCase();
176
+ if (term && text.includes(term)) {
177
+ showSubtree(node, true);
178
+ if (node._childrenElement) node._childrenElement.show();
179
+ return true;
180
+ }
181
+ let childMatch = false;
182
+ (node.children || []).forEach((child) => { if (applyFilter(child, term)) childMatch = true; });
183
+ const visible = !term || childMatch;
184
+ if (node._listItem) node._listItem.toggle(visible);
185
+ if (term && childMatch && node._childrenElement) node._childrenElement.show();
186
+ return visible;
187
+ }
188
+
189
+ function renderTree(root) {
190
+ treeRoot = root;
191
+ treeLoaded = true;
192
+ const available = new Map();
193
+ walk(root, (node) => {
194
+ if (node.nodeClass === 'Variable') available.set(node.nodeId, descriptor(node));
195
+ });
196
+ selectedIds = new Set([...selectedIds].filter((nodeId) => available.has(nodeId)));
197
+ selectedDescriptors = new Map([...selectedIds].map((nodeId) => [nodeId, available.get(nodeId)]));
198
+ treeArea.empty().append($('<ul/>').append(renderNode(root, true)));
199
+ updateSelectionStatus();
200
+ }
201
+
202
+ loadButton.on('click', function () {
203
+ const connectionId = $('#node-input-connection').val();
204
+ if (!connectionId || connectionId === '_ADD_') {
205
+ statusArea.text('Select, save, and deploy a connection before loading the tree.');
206
+ return;
207
+ }
208
+ loadButton.addClass('disabled').attr('disabled', 'disabled');
209
+ loadIcon.attr('class', 'fa fa-spinner fa-spin');
210
+ statusArea.text('Browsing the OPC UA address space...');
211
+ $.ajax({
212
+ url: adminUrl(), method: 'POST', contentType: 'application/json', dataType: 'json',
213
+ data: JSON.stringify({
214
+ connectionId,
215
+ rootNodeId: $('#node-input-rootNodeId').val() || 'ns=0;i=85',
216
+ maxDepth: $('#node-input-maxDepth').val(),
217
+ maxNodes: $('#node-input-maxNodes').val(),
218
+ attributeBatchSize: $('#node-input-attributeBatchSize').val()
219
+ })
220
+ }).done((data) => {
221
+ if (!data || !data.tree) {
222
+ statusArea.text('Browse returned no node tree.');
223
+ return;
224
+ }
225
+ renderTree(data.tree);
226
+ }).fail((request) => {
227
+ const message = request.responseJSON && request.responseJSON.error || request.responseText || 'Browse failed';
228
+ statusArea.text(message);
229
+ }).always(() => {
230
+ loadButton.removeClass('disabled').removeAttr('disabled');
231
+ loadIcon.attr('class', 'fa fa-search');
232
+ });
233
+ });
234
+
235
+ $('#node-input-selectAll').on('click', () => {
236
+ if (!treeRoot) return;
237
+ setSubtree(treeRoot, true);
238
+ updateSelectionStatus();
239
+ });
240
+ $('#node-input-clearSelection').on('click', () => {
241
+ if (treeRoot) setSubtree(treeRoot, false);
242
+ else { selectedIds.clear(); selectedDescriptors.clear(); }
243
+ updateSelectionStatus();
244
+ });
245
+ $('#node-input-treeSearch').on('input', function () {
246
+ if (treeRoot) applyFilter(treeRoot, String(this.value || '').trim().toLowerCase());
247
+ });
248
+ updateSelectionStatus();
249
+ self._opcuaBrowseEditorSelection = () => treeLoaded ? [...selectedDescriptors.values()] : self.selectedNodes || [];
250
+ },
251
+ oneditsave: function () {
252
+ if (this._opcuaBrowseEditorSelection) this.selectedNodes = this._opcuaBrowseEditorSelection();
253
+ delete this._opcuaBrowseEditorSelection;
254
+ },
255
+ oneditcancel: function () { delete this._opcuaBrowseEditorSelection; },
256
+ label: function () { return this.name || 'tier0-opcua-browse'; }
257
+ });
258
+ </script>
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ const { chunks } = require('../lib/points');
4
+ const { buildBrowseTree, selectedVariables } = require('../lib/browse-tree');
5
+
6
+ const activeEditorBrowses = new Set();
7
+
8
+ function positiveInteger(value, fallback, maximum) {
9
+ const number = Number(value);
10
+ if (!Number.isFinite(number) || number <= 0) return fallback;
11
+ return Math.min(Math.floor(number), maximum);
12
+ }
13
+
14
+ module.exports = function registerBrowseNode(RED) {
15
+ RED.httpAdmin.post(
16
+ '/opcua-client/browse',
17
+ RED.auth.needsPermission('opcua-client.browse'),
18
+ async (req, res) => {
19
+ const body = req.body || {};
20
+ const connectionId = String(body.connectionId || '');
21
+ const connection = RED.nodes.getNode(connectionId);
22
+ if (!connection?.client) {
23
+ res.status(404).send({ error: 'Connection is not deployed. Save and deploy it before loading the node tree.' });
24
+ return;
25
+ }
26
+ if (activeEditorBrowses.has(connectionId)) {
27
+ res.status(409).send({ error: 'A node-tree Browse is already running for this connection.' });
28
+ return;
29
+ }
30
+
31
+ const rootNodeId = String(body.rootNodeId || 'ns=0;i=85').trim();
32
+ if (!rootNodeId || rootNodeId.length > 1024) {
33
+ res.status(400).send({ error: 'Invalid root NodeId.' });
34
+ return;
35
+ }
36
+
37
+ activeEditorBrowses.add(connectionId);
38
+ try {
39
+ const items = await connection.client.browse({
40
+ rootNodeId,
41
+ recursive: true,
42
+ variablesOnly: false,
43
+ maxDepth: positiveInteger(body.maxDepth, 10, 50),
44
+ maxNodes: positiveInteger(body.maxNodes, 10000, 100000),
45
+ attributeBatchSize: positiveInteger(body.attributeBatchSize, 200, 5000),
46
+ });
47
+ const tree = buildBrowseTree(items, rootNodeId);
48
+ res.send({ tree, total: items.length, rootNodeId });
49
+ } catch (error) {
50
+ const status = error?.code === 'OPCUA_RECONNECTING' ? 503 : 500;
51
+ res.status(status).send({ error: error.message || String(error) });
52
+ } finally {
53
+ activeEditorBrowses.delete(connectionId);
54
+ }
55
+ }
56
+ );
57
+
58
+ function OpcUaBrowseNode(config) {
59
+ RED.nodes.createNode(this, config);
60
+ const node = this;
61
+ node.connection = RED.nodes.getNode(config.connection);
62
+
63
+ node.on('input', async (msg, send, done) => {
64
+ if (!node.connection) return done(new Error('OPC UA connection not configured'));
65
+
66
+ try {
67
+ const rootNodeId = msg.nodeId || msg.topic || config.rootNodeId || 'ns=0;i=85';
68
+ node.status({ fill: 'blue', shape: 'dot', text: 'browsing' });
69
+ const configuredSelection = selectedVariables(config.selectedNodes);
70
+ const useSelection = configuredSelection.length > 0 && msg.opcua?.liveBrowse !== true;
71
+ const points = useSelection ? configuredSelection : await node.connection.client.browse({
72
+ rootNodeId,
73
+ recursive: config.recursive !== false,
74
+ maxDepth: Number(config.maxDepth) || 10,
75
+ maxNodes: Number(config.maxNodes) || 10000,
76
+ variablesOnly: config.variablesOnly !== false,
77
+ namespaceIndexes: msg.opcua?.namespaceIndexes || [],
78
+ attributeBatchSize: Number(config.attributeBatchSize) || 200,
79
+ });
80
+
81
+ const outputMode = config.outputMode || 'all';
82
+ if (outputMode === 'paged') {
83
+ const pages = chunks(points, Number(config.pageSize) || 500);
84
+ pages.forEach((page, index) => send({
85
+ ...msg,
86
+ payload: page,
87
+ opcua: {
88
+ ...(msg.opcua || {}),
89
+ command: index === 0 ? 'replace' : 'add',
90
+ source: useSelection ? 'browse-selection' : 'browse',
91
+ rootNodeId,
92
+ page: index + 1,
93
+ pages: pages.length,
94
+ done: index === pages.length - 1,
95
+ total: points.length,
96
+ },
97
+ }));
98
+ } else {
99
+ msg.payload = points;
100
+ msg.opcua = {
101
+ ...(msg.opcua || {}),
102
+ command: 'replace',
103
+ source: useSelection ? 'browse-selection' : 'browse',
104
+ rootNodeId,
105
+ done: true,
106
+ total: points.length,
107
+ };
108
+ send(msg);
109
+ }
110
+
111
+ node.status({ fill: 'green', shape: 'dot', text: `${points.length} points` });
112
+ done();
113
+ } catch (error) {
114
+ node.status({ fill: 'red', shape: 'ring', text: error.message });
115
+ done(error);
116
+ }
117
+ });
118
+ }
119
+
120
+ RED.nodes.registerType('tier0-opcua-browse', OpcUaBrowseNode);
121
+ };