@vitormnm/node-red-simple-opcua 1.7.0 → 1.8.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/README.md CHANGED
@@ -1,5 +1,3 @@
1
- # @vitormnm/node-red-simple-opcua
2
-
3
1
  OPC UA client and server with a simple graphical interface for Node-RED.
4
2
 
5
3
  This package provides a simplified, highly parameterized set of nodes to establish OPC UA Servers and Clients in Node-RED without complex coding.
@@ -40,6 +38,23 @@ Interacts with an active local server instance. It supports the following modes:
40
38
 
41
39
  ---
42
40
 
41
+ ### Supported Variable Data Types, Arrays & Matrices
42
+
43
+ The server supports the following standard OPC UA data types, including arrays and matrices:
44
+
45
+ - **Integers**: `Int16`, `UInt16`, `Int32`, `UInt32`, `Int64`
46
+ - **Floats**: `Float`
47
+ - **Others**: `Boolean`, `String`, `ByteString`
48
+ - **Arrays (1D)**: Pass standard JavaScript arrays or JSON arrays as values (e.g., `[1, 2, 3, 4]` or `"[1, 2, 3, 4]"`).
49
+ - **Multi-Dimensional Arrays (Matrices)**: Pass nested arrays. The dimensions are automatically detected based on the shape (e.g., `[[1, 2], [3, 4]]` is registered as a 2x2 matrix with dimensions `[2, 2]`, and `[[[1, 2], [3, 4]], [[5, 6], [7, 8]]]` as a 2x2x2 matrix).
50
+
51
+ *Note on 64-bit Integers (`Int64`/`UInt64`):*
52
+ - *Scalars are represented as `[high, low]` arrays (e.g., `[0, 100]` for the value 100).*
53
+ - *Arrays of 64-bit integers are represented as arrays of doublets (e.g., `[[0, 100], [0, 200]]`).*
54
+ - *They are handled internally using JavaScript `BigInt`.*
55
+
56
+ ---
57
+
43
58
  ## Client Functionalities
44
59
 
45
60
  The client implementation consists of `opcua-client-config` (shared connection configuration) and `opcua-client` (the action node).
@@ -6,10 +6,11 @@ const {
6
6
  DataType,
7
7
  NodeClass,
8
8
  coerceNodeId,
9
- makeNodeId
9
+ makeNodeId,
10
+ VariantArrayType
10
11
  } = require("node-opcua");
11
12
 
12
- const { enrichItemResultWithEnumeration } = require("../opcua-client-utils");
13
+ const { enrichItemResultWithEnumeration, reshapeArray } = require("../opcua-client-utils");
13
14
 
14
15
  async function browseNode(session, root) {
15
16
  const nodeID = normalizeNodeId(root.nodeID || root.nodeId || ROOT_NODE_ID);
@@ -123,7 +124,14 @@ async function browseNode(session, root) {
123
124
  if (nodeClass === "Variable") {
124
125
  const dataTypeValue = dataValues[i * 3 + 1]?.value?.value;
125
126
  const rawValueVariant = dataValues[i * 3 + 2]?.value;
126
- const rawValue = rawValueVariant?.value;
127
+ let rawValue = rawValueVariant?.value;
128
+
129
+ if (rawValueVariant && rawValueVariant.arrayType === VariantArrayType.Matrix && rawValueVariant.dimensions && (Array.isArray(rawValue) || ArrayBuffer.isView(rawValue))) {
130
+ if (ArrayBuffer.isView(rawValue)) {
131
+ rawValue = Array.from(rawValue);
132
+ }
133
+ rawValue = reshapeArray(rawValue, rawValueVariant.dimensions);
134
+ }
127
135
 
128
136
  item.dataType = dataTypeValue?.namespace === 0 && typeof dataTypeValue?.value === "number"
129
137
  ? (DataType[dataTypeValue.value] || dataTypeValue.toString())
@@ -525,7 +533,14 @@ async function browseRecursiveNode(session, root) {
525
533
  if (item.nodeClass === "Variable") {
526
534
  const dataTypeValue = dataValues[index * 3 + 1]?.value?.value;
527
535
  const rawValueVariant = dataValues[index * 3 + 2]?.value;
528
- const rawValue = rawValueVariant?.value;
536
+ let rawValue = rawValueVariant?.value;
537
+
538
+ if (rawValueVariant && rawValueVariant.arrayType === VariantArrayType.Matrix && rawValueVariant.dimensions && (Array.isArray(rawValue) || ArrayBuffer.isView(rawValue))) {
539
+ if (ArrayBuffer.isView(rawValue)) {
540
+ rawValue = Array.from(rawValue);
541
+ }
542
+ rawValue = reshapeArray(rawValue, rawValueVariant.dimensions);
543
+ }
529
544
 
530
545
  item.dataType = dataTypeValue?.namespace === 0 && typeof dataTypeValue?.value === "number"
531
546
  ? (DataType[dataTypeValue.value] || dataTypeValue.toString())
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
 
3
- const { AttributeIds, DataType, coerceNodeId } = require("node-opcua");
3
+ const { AttributeIds, DataType, coerceNodeId, VariantArrayType } = require("node-opcua");
4
4
  const {
5
5
  buildVariantFromItem,
6
6
  normalizeTypeName,
7
7
  resolveNodeId,
8
- statusCodeToString
8
+ statusCodeToString,
9
+ reshapeArray
9
10
  } = require("../opcua-client-utils");
10
11
 
11
12
  // Máximo de tags por chamada session.write (ajuste conforme limite do servidor)
@@ -84,7 +85,7 @@ async function writeBatches(session, items, variants) {
84
85
  allStatusCodes[start + i] = sc;
85
86
  });
86
87
  } catch (batchError) {
87
- // Se o batch falhar por completo, marca todos com erro
88
+
88
89
  for (let i = start; i < end; i++) {
89
90
  allStatusCodes[i] = { name: batchError.message, value: -1 };
90
91
  }
@@ -109,10 +110,19 @@ function buildResults(items, variants, statusCodes) {
109
110
  const scName = sc && sc.name ? sc.name : "Good";
110
111
  const typeName = DataType[variants[index].dataType] || null;
111
112
 
113
+ let val = variants[index].value;
114
+ const variant = variants[index];
115
+ if (variant && variant.arrayType === VariantArrayType.Matrix && variant.dimensions && (Array.isArray(val) || ArrayBuffer.isView(val))) {
116
+ if (ArrayBuffer.isView(val)) {
117
+ val = Array.from(val);
118
+ }
119
+ val = reshapeArray(val, variant.dimensions);
120
+ }
121
+
112
122
  return {
113
123
  name: item.name || nodeId,
114
124
  nodeID: nodeId,
115
- value: variants[index].value,
125
+ value: val,
116
126
  type: typeName,
117
127
  status: scName,
118
128
  sourceTimestamp: null,
@@ -8,7 +8,10 @@ const {
8
8
  getMethodArgumentDefinition,
9
9
  resolveMethodObjectId,
10
10
  resolveSecurityMode,
11
- resolveSecurityPolicy
11
+ resolveSecurityPolicy,
12
+ AttributeIds,
13
+ dataValueToItemResult,
14
+ enrichItemResultWithEnumeration
12
15
  } = require("./opcua-client-utils");
13
16
 
14
17
  const {
@@ -214,6 +217,40 @@ module.exports = function (RED) {
214
217
  res.status(500).json({ error: error.message });
215
218
  }
216
219
  });
220
+
221
+ RED.httpAdmin.get("/opcua-client-config/:id/read", RED.auth.needsPermission("flows.read"), async function (req, res) {
222
+ try {
223
+ const configNode = RED.nodes.getNode(req.params.id);
224
+
225
+ if (!configNode) {
226
+ res.status(404).json({ error: "OPC UA client configuration not found" });
227
+ return;
228
+ }
229
+
230
+ const nodeId = req.query.nodeId;
231
+ if (!nodeId) {
232
+ throw new Error("Missing nodeId parameter");
233
+ }
234
+
235
+ const session = await configNode.getSession();
236
+ const dataValue = await session.read({
237
+ nodeId: nodeId,
238
+ attributeId: AttributeIds.Value
239
+ });
240
+
241
+ if (dataValue.statusCode && !dataValue.statusCode.isGood()) {
242
+ throw new Error("Read failed: " + dataValue.statusCode.toString());
243
+ }
244
+
245
+ const cache = new Map();
246
+ let result = dataValueToItemResult({ nodeID: nodeId }, dataValue);
247
+ result = await enrichItemResultWithEnumeration(result, session, cache, nodeId);
248
+
249
+ res.json({ value: result.value, valueEnumeration: result.valueEnumeration });
250
+ } catch (error) {
251
+ res.status(500).json({ error: error.message });
252
+ }
253
+ });
217
254
  };
218
255
 
219
256
  async function browseForEditor(configNode, nodeId) {
@@ -204,6 +204,70 @@ function coerceValue(value, typeName) {
204
204
  }
205
205
  }
206
206
 
207
+ function getArrayDimensions(value, typeName) {
208
+ if (!Array.isArray(value)) {
209
+ return null;
210
+ }
211
+ // Check if it is a 64-bit scalar represented as [high, low]
212
+ if ((typeName === "Int64" || typeName === "UInt64") && value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number") {
213
+ return null;
214
+ }
215
+
216
+ const hasNestedArray = value.some(item => {
217
+ if (Array.isArray(item)) {
218
+ if ((typeName === "Int64" || typeName === "UInt64") && item.length === 2 && typeof item[0] === "number" && typeof item[1] === "number") {
219
+ return false;
220
+ }
221
+ return true;
222
+ }
223
+ return false;
224
+ });
225
+
226
+ if (!hasNestedArray) {
227
+ return null;
228
+ }
229
+
230
+ const dimensions = [];
231
+ let current = value;
232
+ while (Array.isArray(current)) {
233
+ if ((typeName === "Int64" || typeName === "UInt64") && current.length === 2 && typeof current[0] === "number" && typeof current[1] === "number") {
234
+ break;
235
+ }
236
+ dimensions.push(current.length);
237
+ if (current.length === 0) {
238
+ break;
239
+ }
240
+ current = current[0];
241
+ }
242
+ return dimensions;
243
+ }
244
+
245
+ function flattenMatrix(value, typeName) {
246
+ if (!Array.isArray(value)) {
247
+ return value;
248
+ }
249
+ if ((typeName === "Int64" || typeName === "UInt64") && value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number") {
250
+ return [value];
251
+ }
252
+
253
+ const flat = [];
254
+ const recurse = (a) => {
255
+ for (const item of a) {
256
+ if (Array.isArray(item)) {
257
+ if ((typeName === "Int64" || typeName === "UInt64") && item.length === 2 && typeof item[0] === "number" && typeof item[1] === "number") {
258
+ flat.push(item);
259
+ } else {
260
+ recurse(item);
261
+ }
262
+ } else {
263
+ flat.push(item);
264
+ }
265
+ }
266
+ };
267
+ recurse(value);
268
+ return flat;
269
+ }
270
+
207
271
  function buildVariantFromItem(item, fallbackTypeName) {
208
272
  const typeName = normalizeTypeName(item.type || fallbackTypeName || inferTypeName(item.value));
209
273
  const dataType = DataType[typeName];
@@ -212,6 +276,23 @@ function buildVariantFromItem(item, fallbackTypeName) {
212
276
  throw new Error("Unsupported OPC UA data type: " + typeName);
213
277
  }
214
278
 
279
+ const dimensions = getArrayDimensions(item.value, typeName);
280
+ if (dimensions) {
281
+ // Multi-dimensional array (Matrix)
282
+ const flatVal = flattenMatrix(item.value, typeName);
283
+ const coercedArray = coerceValue(flatVal, typeName);
284
+ const TypedArrayCtor = TYPED_ARRAY_MAP[dataType];
285
+
286
+ return new Variant({
287
+ dataType,
288
+ arrayType: VariantArrayType.Matrix,
289
+ dimensions,
290
+ value: TypedArrayCtor
291
+ ? TypedArrayCtor.from(coercedArray)
292
+ : coercedArray
293
+ });
294
+ }
295
+
215
296
  const isArray = Array.isArray(item.value);
216
297
 
217
298
  if (isArray) {
@@ -311,12 +392,48 @@ function resolve64BitValue(value, isUnsigned) {
311
392
  return value;
312
393
  }
313
394
 
395
+ function reshapeArray(flatArray, dimensions) {
396
+ if (!dimensions || dimensions.length <= 1) {
397
+ return flatArray;
398
+ }
399
+
400
+ const reshape = (arr, dims, offset) => {
401
+ const size = dims[0];
402
+ if (dims.length === 1) {
403
+ return {
404
+ result: arr.slice(offset, offset + size),
405
+ nextOffset: offset + size
406
+ };
407
+ }
408
+
409
+ const result = [];
410
+ let currentOffset = offset;
411
+ for (let i = 0; i < size; i++) {
412
+ const step = reshape(arr, dims.slice(1), currentOffset);
413
+ result.push(step.result);
414
+ currentOffset = step.nextOffset;
415
+ }
416
+ return {
417
+ result: result,
418
+ nextOffset: currentOffset
419
+ };
420
+ };
421
+
422
+ return reshape(flatArray, dimensions, 0).result;
423
+ }
424
+
314
425
  function dataValueToItemResult(item, dataValue) {
315
426
  const variant = dataValue && dataValue.value ? dataValue.value : null;
316
427
  let val = variant ? variant.value : null;
317
428
  if (variant && (variant.dataType === DataType.Int64 || variant.dataType === DataType.UInt64)) {
318
429
  val = resolve64BitValue(val, variant.dataType === DataType.UInt64);
319
430
  }
431
+ if (variant && variant.arrayType === VariantArrayType.Matrix && variant.dimensions && (Array.isArray(val) || ArrayBuffer.isView(val))) {
432
+ if (ArrayBuffer.isView(val)) {
433
+ val = Array.from(val);
434
+ }
435
+ val = reshapeArray(val, variant.dimensions);
436
+ }
320
437
  return {
321
438
  name: resolveName(item, resolveNodeId(item)),
322
439
  nodeID: resolveNodeId(item),
@@ -374,6 +491,12 @@ function callResultToItemResult(item, callResult, argumentDefinition) {
374
491
  if (variant && (variant.dataType === DataType.Int64 || variant.dataType === DataType.UInt64)) {
375
492
  val = resolve64BitValue(val, variant.dataType === DataType.UInt64);
376
493
  }
494
+ if (variant && variant.arrayType === VariantArrayType.Matrix && variant.dimensions && (Array.isArray(val) || ArrayBuffer.isView(val))) {
495
+ if (ArrayBuffer.isView(val)) {
496
+ val = Array.from(val);
497
+ }
498
+ val = reshapeArray(val, variant.dimensions);
499
+ }
377
500
  return {
378
501
  name: outputDefinitions[index] && outputDefinitions[index].name
379
502
  ? String(outputDefinitions[index].name)
@@ -601,5 +724,6 @@ module.exports = {
601
724
  resolveNodeId,
602
725
  resolveSecurityMode,
603
726
  resolveSecurityPolicy,
727
+ reshapeArray,
604
728
  statusCodeToString
605
729
  };
@@ -50,6 +50,7 @@
50
50
  <div id="node-input-browse-context-menu" class="opcua-tree-context-menu" style="display:none;">
51
51
  <a href="#" id="node-input-browse-context-refresh"><i class="fa fa-refresh"></i> Refresh node</a>
52
52
  <a href="#" id="node-input-browse-context-copy-nodeid"><i class="fa fa-copy"></i> Copy NodeID</a>
53
+ <a href="#" id="node-input-browse-context-read-value"><i class="fa fa-eye"></i> Read value</a>
53
54
  </div>
54
55
  </div>
55
56
 
@@ -657,7 +657,7 @@
657
657
  }).fail(function (xhr) {
658
658
  var message = xhr && xhr.responseJSON && xhr.responseJSON.error
659
659
  ? xhr.responseJSON.error
660
- : "Falha ao navegar no servidor OPC UA.";
660
+ : "Failed to browse the OPC UA server.";
661
661
  browseState = null;
662
662
  container.html('<div class="opcua-tree-empty">' + escapeHtml(message) + '</div>');
663
663
  RED.notify(message, "error");
@@ -675,6 +675,7 @@
675
675
  contextMenuPath = path || "";
676
676
  $("#node-input-browse-context-refresh").toggle(!!item && !isVariable(item));
677
677
  $("#node-input-browse-context-copy-nodeid").toggle(!!nodeIdOf(item));
678
+ $("#node-input-browse-context-read-value").toggle(!!item && isVariable(item) && !!nodeIdOf(item));
678
679
  menu.css({ left: x + "px", top: y + "px" }).show();
679
680
  }
680
681
 
@@ -682,15 +683,15 @@
682
683
  var item = getItemAtPath(path);
683
684
  var nodeId = nodeIdOf(item);
684
685
  if (!nodeId) {
685
- RED.notify("NodeID nao encontrado para o item selecionado.", "warning");
686
+ RED.notify("NodeID not found for the selected item.", "warning");
686
687
  return;
687
688
  }
688
689
 
689
690
  if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
690
691
  navigator.clipboard.writeText(nodeId).then(function () {
691
- RED.notify("NodeID copiado.", "success");
692
+ RED.notify("NodeID copied.", "success");
692
693
  }).catch(function () {
693
- RED.notify("Falha ao copiar NodeID.", "error");
694
+ RED.notify("Failed to copy NodeID.", "error");
694
695
  });
695
696
  return;
696
697
  }
@@ -704,13 +705,48 @@
704
705
  input[0].select();
705
706
  try {
706
707
  document.execCommand("copy");
707
- RED.notify("NodeID copiado.", "success");
708
+ RED.notify("NodeID copied.", "success");
708
709
  } catch (error) {
709
- RED.notify("Falha ao copiar NodeID.", "error");
710
+ RED.notify("Failed to copy NodeID.", "error");
710
711
  }
711
712
  input.remove();
712
713
  }
713
714
 
715
+ function readValueFromPath(path) {
716
+ var item = getItemAtPath(path);
717
+ var nodeId = nodeIdOf(item);
718
+ if (!nodeId) {
719
+ RED.notify("NodeID not found for the selected item.", "warning");
720
+ return;
721
+ }
722
+
723
+ var connectionId = $("#node-input-connection").val();
724
+ if (!connectionId) {
725
+ RED.notify("Select an OPC UA connection before reading.", "warning");
726
+ return;
727
+ }
728
+
729
+ $.getJSON("opcua-client-config/" + encodeURIComponent(connectionId) + "/read", {
730
+ nodeId: nodeId
731
+ }).done(function (payload) {
732
+ if (payload && payload.error) {
733
+ RED.notify("Read failed: " + payload.error, "error");
734
+ } else if (payload) {
735
+ var valueText = (payload.valueEnumeration !== undefined && payload.valueEnumeration !== null)
736
+ ? payload.valueEnumeration + " (" + payload.value + ")"
737
+ : (payload.value !== undefined ? String(payload.value) : "undefined");
738
+ RED.notify("Variable value: " + valueText, "success");
739
+ } else {
740
+ RED.notify("No value returned from the server.", "warning");
741
+ }
742
+ }).fail(function (xhr) {
743
+ var message = xhr && xhr.responseJSON && xhr.responseJSON.error
744
+ ? xhr.responseJSON.error
745
+ : "Failed to read variable value.";
746
+ RED.notify(message, "error");
747
+ });
748
+ }
749
+
714
750
  function setBrowseSelectedPath(path) {
715
751
  browseSelectedPath = path || "";
716
752
  $(".opcua-tree-row").removeClass("is-selected");
@@ -759,20 +795,51 @@
759
795
  renderBrowseTree();
760
796
  var browseNodeId = item.nodeID || item.nodeId;
761
797
  loadBrowse(browseNodeId).done(function (payload) {
762
-
763
-
764
-
765
- item.browse = Array.isArray(payload.browse) ? payload.browse : [];
766
- saveBrowseSession();
767
- renderBrowseTree();
798
+ try {
799
+ if (!payload) {
800
+ console.error("Browse returned empty payload for node " + browseNodeId);
801
+ RED.notify("Browse returned empty payload.", "error");
802
+ item.browse = [];
803
+ } else if (payload.error) {
804
+ console.error("Browse returned error for node " + browseNodeId + ":", payload.error);
805
+ RED.notify(payload.error, "error");
806
+ item.browse = [];
807
+ } else {
808
+ item.browse = Array.isArray(payload.browse) ? payload.browse : [];
809
+ }
810
+ saveBrowseSession();
811
+ renderBrowseTree();
812
+ triggerChildrenExpansion(item.browse, path);
813
+ } catch (err) {
814
+ console.error("Error handling browse payload for node " + browseNodeId + ":", err);
815
+ RED.notify("Error handling browse response: " + err.message, "error");
816
+ expansionState[path] = false;
817
+ saveBrowseSession();
818
+ renderBrowseTree();
819
+ }
768
820
  }).fail(function (xhr) {
769
821
  expansionState[path] = false;
770
822
  saveBrowseSession();
771
823
  renderBrowseTree();
772
824
  var message = xhr && xhr.responseJSON && xhr.responseJSON.error
773
825
  ? xhr.responseJSON.error
774
- : "Falha ao expandir o node.";
826
+ : "Failed to expand the node.";
775
827
  RED.notify(message, "error");
828
+ console.error("Browse request failed for node " + browseNodeId + ":", xhr);
829
+ });
830
+ }
831
+
832
+ function triggerChildrenExpansion(children, parentPath) {
833
+ if (!Array.isArray(children)) return;
834
+ children.forEach(function (child, index) {
835
+ var childPath = parentPath + ".browse." + index;
836
+ if (isExpanded(childPath, false)) {
837
+ if (!Array.isArray(child.browse)) {
838
+ expandNode(childPath);
839
+ } else {
840
+ triggerChildrenExpansion(child.browse, childPath);
841
+ }
842
+ }
776
843
  });
777
844
  }
778
845
 
@@ -1057,7 +1124,24 @@
1057
1124
  if ($(event.target).closest(".opcua-client-toggle-tree, .opcua-client-toggle-tag, .opcua-tree-actions, #node-input-browse-context-menu").length) {
1058
1125
  return;
1059
1126
  }
1060
- setBrowseSelectedPath($(this).attr("data-path"));
1127
+ var path = $(this).attr("data-path");
1128
+ setBrowseSelectedPath(path);
1129
+
1130
+ if (event.ctrlKey || event.metaKey) {
1131
+ event.preventDefault();
1132
+ if ($("#node-input-mode").val() === "method") {
1133
+ var item = getItemAtPath(path);
1134
+ if (item && item.nodeClass === "Method") {
1135
+ var parentPath = path.split(".");
1136
+ parentPath.splice(parentPath.length - 2, 2);
1137
+ parentPath = parentPath.join(".");
1138
+ var parentItem = getItemAtPath(parentPath);
1139
+ addMethodFromTree(item, parentItem);
1140
+ return;
1141
+ }
1142
+ }
1143
+ toggleSelectedNode(path);
1144
+ }
1061
1145
  });
1062
1146
 
1063
1147
  $(document).on("contextmenu", ".opcua-tree-row", function (event) {
@@ -1091,6 +1175,14 @@
1091
1175
  copyNodeIdFromPath(path);
1092
1176
  });
1093
1177
 
1178
+ $(document).on("click", "#node-input-browse-context-read-value", function (event) {
1179
+ event.preventDefault();
1180
+ var highlightedPath = $(".opcua-tree-row.is-selected").first().attr("data-path") || "";
1181
+ var path = contextMenuPath || browseSelectedPath || highlightedPath || "";
1182
+ hideTreeContextMenu();
1183
+ readValueFromPath(path);
1184
+ });
1185
+
1094
1186
  $(document).on("click", function (event) {
1095
1187
  if (!$(event.target).closest("#node-input-browse-context-menu").length) {
1096
1188
  hideTreeContextMenu();
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@vitormnm/node-red-simple-opcua",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "description": "Custom Node-RED OPC UA server and client node powered by node-opcua",
7
+ "description": "OPC UA server and client nodes with a graphical interface, powered by node-opcua",
8
8
  "main": "server/opcua-server.js",
9
9
  "node-red": {
10
10
  "version": ">=3.0.0",