@vitormnm/node-red-simple-opcua 1.7.0 → 1.8.1

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())
@@ -569,11 +584,11 @@ async function browseRecursiveNode(session, root) {
569
584
  }
570
585
 
571
586
  const methods = allItems.filter(item => item.nodeClass === "Method");
572
- for (const method of methods) {
587
+ await Promise.all(methods.map(async (method) => {
573
588
  const definition = await readMethodArguments(session, method.nodeID);
574
589
  method.inputArguments = definition.inputArguments;
575
590
  method.outputArguments = definition.outputArguments;
576
- }
591
+ }));
577
592
  }
578
593
 
579
594
  return {
@@ -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)
@@ -468,12 +591,22 @@ async function getMethodArgumentDefinition(session, methodNodeId, cache) {
468
591
  return definition;
469
592
  }
470
593
 
594
+ const PRIMITIVE_TYPES = new Set([
595
+ "Null", "Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64",
596
+ "Float", "Double", "String", "DateTime", "Guid", "ByteString", "XmlElement",
597
+ "NodeId", "ExpandedNodeId", "StatusCode", "QualifiedName", "LocalizedText"
598
+ ]);
599
+
471
600
  async function enrichItemResultWithEnumeration(result, session, cache, nodeId) {
472
601
  const type = result.type || result.dataType;
473
602
  if (!type) {
474
603
  return result;
475
604
  }
476
605
 
606
+ if (result.dataType && PRIMITIVE_TYPES.has(result.dataType)) {
607
+ return result;
608
+ }
609
+
477
610
  const isStandardEnum = type === "Int32" || type === "Enumeration";
478
611
  const isCustomNodeId = typeof type === "string" && (type.includes("i=") || type.includes("ns="));
479
612
 
@@ -487,88 +620,83 @@ async function enrichItemResultWithEnumeration(result, session, cache, nodeId) {
487
620
 
488
621
  try {
489
622
  const cacheKeyType = "dt:" + nodeId;
490
- let dtNodeId = cache ? cache.get(cacheKeyType) : undefined;
491
- if (dtNodeId === undefined) {
492
- const isNodeIdLike = result.dataType && (
493
- typeof result.dataType !== "string" ||
494
- result.dataType.includes("i=") ||
495
- result.dataType.includes("ns=")
496
- );
497
- if (isNodeIdLike) {
498
- try {
499
- dtNodeId = coerceNodeId(result.dataType);
500
- } catch (e) {
501
- dtNodeId = undefined;
623
+ let dtNodeIdPromise = cache ? cache.get(cacheKeyType) : undefined;
624
+
625
+ if (dtNodeIdPromise === undefined) {
626
+ dtNodeIdPromise = (async () => {
627
+ const isNodeIdLike = result.dataType && (
628
+ typeof result.dataType !== "string" ||
629
+ result.dataType.includes("i=") ||
630
+ result.dataType.includes("ns=")
631
+ );
632
+ if (isNodeIdLike) {
633
+ try {
634
+ return coerceNodeId(result.dataType);
635
+ } catch (e) {
636
+ return null;
637
+ }
502
638
  }
503
- }
504
- if (dtNodeId === undefined) {
505
639
  const dv = await session.read({
506
640
  nodeId: nodeId,
507
641
  attributeId: AttributeIds.DataType
508
642
  });
509
- if (dv.statusCode.isGood()) {
510
- dtNodeId = dv.value.value;
511
- if (cache) cache.set(cacheKeyType, dtNodeId);
512
- } else {
513
- if (cache) cache.set(cacheKeyType, null);
514
- }
515
- }
643
+ return dv.statusCode.isGood() ? dv.value.value : null;
644
+ })();
645
+ if (cache) cache.set(cacheKeyType, dtNodeIdPromise);
516
646
  }
517
647
 
648
+ const dtNodeId = await dtNodeIdPromise;
518
649
  if (!dtNodeId) return result;
519
650
 
520
651
  const cacheKeyStrings = "enumStrings:" + dtNodeId.toString();
521
- let enumStrings = cache ? cache.get(cacheKeyStrings) : undefined;
652
+ let enumStringsPromise = cache ? cache.get(cacheKeyStrings) : undefined;
522
653
 
523
- if (enumStrings === undefined) {
524
- const browseResult = await session.browse({
525
- nodeId: dtNodeId,
526
- referenceTypeId: "HasProperty",
527
- browseDirection: BrowseDirection.Forward,
528
- includeSubtypes: true,
529
- resultMask: 63
530
- });
531
-
532
- const enumStringsRef = browseResult.references ? browseResult.references.find(r => r.browseName.name === "EnumStrings") : null;
533
- const enumValuesRef = browseResult.references ? browseResult.references.find(r => r.browseName.name === "EnumValues") : null;
534
-
535
- if (enumStringsRef) {
536
- const dataValue = await session.read({
537
- nodeId: enumStringsRef.nodeId,
538
- attributeId: AttributeIds.Value
654
+ if (enumStringsPromise === undefined) {
655
+ enumStringsPromise = (async () => {
656
+ const browseResult = await session.browse({
657
+ nodeId: dtNodeId,
658
+ referenceTypeId: "HasProperty",
659
+ browseDirection: BrowseDirection.Forward,
660
+ includeSubtypes: true,
661
+ resultMask: 63
539
662
  });
540
- if (dataValue.statusCode.isGood() && dataValue.value.value) {
541
- enumStrings = dataValue.value.value.map(lt => lt.text);
542
- if (cache) cache.set(cacheKeyStrings, enumStrings);
543
- } else {
544
- if (cache) cache.set(cacheKeyStrings, null);
545
- }
546
- } else if (enumValuesRef) {
547
- const dataValue = await session.read({
548
- nodeId: enumValuesRef.nodeId,
549
- attributeId: AttributeIds.Value
550
- });
551
- if (dataValue.statusCode.isGood() && dataValue.value.value) {
552
- const map = {};
553
- dataValue.value.value.forEach(ev => {
554
- let val;
555
- if (Array.isArray(ev.value) && ev.value.length === 2) {
556
- val = ev.value[1]; // low part of Int64
557
- } else {
558
- val = Number(ev.value);
559
- }
560
- map[val] = ev.displayName.text;
663
+
664
+ const enumStringsRef = browseResult.references ? browseResult.references.find(r => r.browseName.name === "EnumStrings") : null;
665
+ const enumValuesRef = browseResult.references ? browseResult.references.find(r => r.browseName.name === "EnumValues") : null;
666
+
667
+ if (enumStringsRef) {
668
+ const dataValue = await session.read({
669
+ nodeId: enumStringsRef.nodeId,
670
+ attributeId: AttributeIds.Value
561
671
  });
562
- enumStrings = map;
563
- if (cache) cache.set(cacheKeyStrings, enumStrings);
564
- } else {
565
- if (cache) cache.set(cacheKeyStrings, null);
672
+ if (dataValue.statusCode.isGood() && dataValue.value.value) {
673
+ return dataValue.value.value.map(lt => lt.text);
674
+ }
675
+ } else if (enumValuesRef) {
676
+ const dataValue = await session.read({
677
+ nodeId: enumValuesRef.nodeId,
678
+ attributeId: AttributeIds.Value
679
+ });
680
+ if (dataValue.statusCode.isGood() && dataValue.value.value) {
681
+ const map = {};
682
+ dataValue.value.value.forEach(ev => {
683
+ let val;
684
+ if (Array.isArray(ev.value) && ev.value.length === 2) {
685
+ val = ev.value[1]; // low part of Int64
686
+ } else {
687
+ val = Number(ev.value);
688
+ }
689
+ map[val] = ev.displayName.text;
690
+ });
691
+ return map;
692
+ }
566
693
  }
567
- } else {
568
- if (cache) cache.set(cacheKeyStrings, null);
569
- }
694
+ return null;
695
+ })();
696
+ if (cache) cache.set(cacheKeyStrings, enumStringsPromise);
570
697
  }
571
698
 
699
+ const enumStrings = await enumStringsPromise;
572
700
  if (enumStrings && enumStrings[result.value] !== undefined) {
573
701
  result.valueEnumeration = enumStrings[result.value];
574
702
  if (result.type) result.type = "Enumeration";
@@ -601,5 +729,6 @@ module.exports = {
601
729
  resolveNodeId,
602
730
  resolveSecurityMode,
603
731
  resolveSecurityPolicy,
732
+ reshapeArray,
604
733
  statusCodeToString
605
734
  };
@@ -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