@vitormnm/node-red-simple-opcua 1.6.3 → 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.
Files changed (45) hide show
  1. package/README.md +104 -136
  2. package/client/lib/opcua-client-browser.js +254 -11
  3. package/client/lib/opcua-client-method-service.js +1 -1
  4. package/client/lib/opcua-client-subscription-service.js +0 -2
  5. package/client/lib/opcua-client-write-service.js +14 -4
  6. package/client/opcua-client-config.html +118 -1
  7. package/client/opcua-client-config.js +112 -9
  8. package/client/opcua-client-help.html +6 -0
  9. package/client/opcua-client-utils.js +158 -10
  10. package/client/opcua-client.html +8 -0
  11. package/client/opcua-client.js +97 -1
  12. package/client/view/opcua-client.js +106 -14
  13. package/examples/flows_simple_opc.json +1 -1
  14. package/package.json +2 -2
  15. package/server/lib/opcua-address-space-alarm.js +95 -32
  16. package/server/lib/opcua-address-space-builder.js +717 -59
  17. package/server/lib/opcua-config.js +110 -35
  18. package/server/lib/opcua-server-events-child.js +31 -5
  19. package/server/lib/opcua-server-runtime-child.js +424 -27
  20. package/server/lib/opcua-server-runtime.js +52 -5
  21. package/server/lib/opcua-server-status-child.js +46 -15
  22. package/server/nodered/simple_opcua/server/certificates/mutex +0 -0
  23. package/server/nodered/simple_opcua/server/certificates/own/certs/server_selfsigned_cert_2048.pem +25 -0
  24. package/server/nodered/simple_opcua/server/certificates/own/certs/server_selfsigned_cert_2048.pem.mutex +0 -0
  25. package/server/nodered/simple_opcua/server/certificates/own/openssl.cnf +72 -0
  26. package/server/nodered/simple_opcua/server/certificates/own/private/private_key.pem +28 -0
  27. package/server/nodered/simple_opcua/server/certificates/trusted/certs/NodeOPCUA-Client@tuf[c5a9e20a8b680cdff76aaf0165bb3c9318da37a5].pem +25 -0
  28. package/server/nodered/simple_opcua/server/myServer1/mutex +0 -0
  29. package/server/nodered/simple_opcua/server/myServer1/own/certs/server_selfsigned_cert_2048.pem +25 -0
  30. package/server/nodered/simple_opcua/server/myServer1/own/certs/server_selfsigned_cert_2048.pem.mutex +0 -0
  31. package/server/nodered/simple_opcua/server/myServer1/own/openssl.cnf +72 -0
  32. package/server/nodered/simple_opcua/server/myServer1/own/private/private_key.pem +28 -0
  33. package/server/nodered/simple_opcua/server/myServer1/trusted/certs/NodeOPCUA-Client@tuf[91e520c64ff891c67168f08a46dd194071e15dae].pem +25 -0
  34. package/server/nodered/simple_opcua/server/myServer1/trusted/certs/NodeOPCUA-Client@tuf[98ae95da627cea4c500753c319161a3554ee38d7].pem +25 -0
  35. package/server/nodered/simple_opcua/server/myServer1/trusted/certs/NodeOPCUA-Client@tuf[aef8d7a1cfba13d84189a0bcf1694208fc51a7f9].pem +25 -0
  36. package/server/nodered/simple_opcua/server/myServer1/trusted/certs/NodeOPCUA-Client@tuf[c5a9e20a8b680cdff76aaf0165bb3c9318da37a5].pem +25 -0
  37. package/server/nodered/simple_opcua/server/myServer1/trusted/certs/NodeOPCUA-Client@tuf[ebdf9acf1d02e347917a14108d3144799c638ea3].pem +25 -0
  38. package/server/opcua-server-io.html +93 -1
  39. package/server/opcua-server-io.js +153 -29
  40. package/server/opcua-server-registry.js +8 -2
  41. package/server/opcua-server.css +64 -0
  42. package/server/opcua-server.html +168 -44
  43. package/server/opcua-server.js +115 -5
  44. package/server/view/opcua-server.css +100 -6
  45. package/server/view/opcua-server.js +746 -48
@@ -3,16 +3,28 @@
3
3
  function toggleCredentials() {
4
4
  var authType = $("#node-config-input-authType").val();
5
5
  $(".opcua-client-auth-row").toggle(authType === "username");
6
+ if (authType === "anonymous") {
7
+ $("#node-config-input-username").val("");
8
+ $("#node-config-input-password").val("");
9
+ }
6
10
  }
7
11
 
8
12
  RED.nodes.registerType("opcua-client-config", {
9
13
  category: "config",
10
14
  defaults: {
11
15
  name: { value: "" },
16
+ sessionName: { value: "ClientSession1" },
12
17
  endpoint: { value: "opc.tcp://localhost:4840", required: true },
13
18
  securityPolicy: { value: "None", required: true },
14
19
  securityMode: { value: "None", required: true },
15
- authType: { value: "anonymous", required: true }
20
+ authType: { value: "anonymous", required: true },
21
+ initialDelay: { value: 1000, validate: RED.validators.number() },
22
+ maxDelay: { value: 10000, validate: RED.validators.number() },
23
+ maxRetry: { value: -1, validate: RED.validators.number() },
24
+ requestedSessionTimeout: { value: 300000, validate: RED.validators.number() },
25
+ keepSessionAlive: { value: true },
26
+ autoReconnect: { value: true },
27
+ endpointMustExist: { value: false }
16
28
  },
17
29
  credentials: {
18
30
  username: { type: "text" },
@@ -22,22 +34,87 @@
22
34
  return this.name || this.endpoint || "opcua-client-config";
23
35
  },
24
36
  oneditprepare: function () {
37
+ if (this.keepSessionAlive !== false) {
38
+ $("#node-config-input-keepSessionAlive").prop("checked", true);
39
+ }
40
+ if (this.autoReconnect !== false) {
41
+ $("#node-config-input-autoReconnect").prop("checked", true);
42
+ }
43
+ if (this.endpointMustExist === true) {
44
+ $("#node-config-input-endpointMustExist").prop("checked", true);
45
+ }
46
+ if (this.maxRetry === undefined || Number(this.maxRetry) === 10) {
47
+ $("#node-config-input-maxRetry").val(-1);
48
+ }
49
+
25
50
  $("#node-config-input-authType").on("change", toggleCredentials);
26
51
  toggleCredentials();
52
+
53
+ $("#node-config-input-securityPolicy").on("change", function () {
54
+ var policy = $(this).val();
55
+ var modeSelect = $("#node-config-input-securityMode");
56
+ var mode = modeSelect.val();
57
+ if (policy === "None") {
58
+ if (mode !== "None") {
59
+ modeSelect.val("None");
60
+ }
61
+ } else {
62
+ if (mode === "None") {
63
+ modeSelect.val("SignAndEncrypt");
64
+ }
65
+ }
66
+ });
67
+
68
+ $("#node-config-input-securityMode").on("change", function () {
69
+ var mode = $(this).val();
70
+ var policySelect = $("#node-config-input-securityPolicy");
71
+ var policy = policySelect.val();
72
+ if (mode === "None") {
73
+ if (policy !== "None") {
74
+ policySelect.val("None");
75
+ }
76
+ } else {
77
+ if (policy === "None") {
78
+ policySelect.val("Basic256Sha256");
79
+ }
80
+ }
81
+ });
82
+
83
+ function toggleReconnect() {
84
+ var autoReconnect = $("#node-config-input-autoReconnect").prop("checked");
85
+ $(".opcua-client-reconnect-row").toggle(autoReconnect);
86
+ }
87
+ $("#node-config-input-autoReconnect").on("change", toggleReconnect);
88
+ toggleReconnect();
27
89
  }
28
90
  });
29
91
  })();
30
92
  </script>
31
93
 
32
94
  <script type="text/html" data-template-name="opcua-client-config">
95
+ <!-- Connection Section -->
96
+ <div class="form-row" style="margin-top: 10px; margin-bottom: 10px;">
97
+ <span style="font-weight: bold;"><i class="fa fa-plug"></i> Connection Settings</span>
98
+ <hr style="margin-top: 5px; margin-bottom: 10px; border: 0; border-top: 1px solid #ccc;"/>
99
+ </div>
33
100
  <div class="form-row">
34
101
  <label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
35
102
  <input type="text" id="node-config-input-name" placeholder="OPC UA Client">
36
103
  </div>
104
+ <div class="form-row">
105
+ <label for="node-config-input-sessionName"><i class="fa fa-info-circle"></i> Session Name</label>
106
+ <input type="text" id="node-config-input-sessionName" placeholder="ClientSession1">
107
+ </div>
37
108
  <div class="form-row">
38
109
  <label for="node-config-input-endpoint"><i class="fa fa-plug"></i> Endpoint</label>
39
110
  <input type="text" id="node-config-input-endpoint" placeholder="opc.tcp://localhost:4840">
40
111
  </div>
112
+
113
+ <!-- Security Section -->
114
+ <div class="form-row" style="margin-top: 20px; margin-bottom: 10px;">
115
+ <span style="font-weight: bold;"><i class="fa fa-shield"></i> Security & Authentication</span>
116
+ <hr style="margin-top: 5px; margin-bottom: 10px; border: 0; border-top: 1px solid #ccc;"/>
117
+ </div>
41
118
  <div class="form-row">
42
119
  <label for="node-config-input-securityPolicy"><i class="fa fa-lock"></i> Security Policy</label>
43
120
  <select id="node-config-input-securityPolicy">
@@ -72,6 +149,46 @@
72
149
  <label for="node-config-input-password"><i class="fa fa-key"></i> Password</label>
73
150
  <input type="password" id="node-config-input-password">
74
151
  </div>
152
+
153
+ <!-- Client Settings Section -->
154
+ <div class="form-row" style="margin-top: 20px; margin-bottom: 10px;">
155
+ <span style="font-weight: bold;"><i class="fa fa-cog"></i> Client Settings</span>
156
+ <hr style="margin-top: 5px; margin-bottom: 10px; border: 0; border-top: 1px solid #ccc;"/>
157
+ </div>
158
+ <div class="form-row">
159
+ <label for="node-config-input-requestedSessionTimeout"><i class="fa fa-clock-o"></i> Session Timeout (ms)</label>
160
+ <input type="number" id="node-config-input-requestedSessionTimeout" placeholder="300000">
161
+ </div>
162
+ <div class="form-row" style="display: flex; align-items: center;">
163
+ <label for="node-config-input-keepSessionAlive" style="width: auto; margin-right: 10px;"><i class="fa fa-heartbeat"></i> Keep Session Alive</label>
164
+ <input type="checkbox" id="node-config-input-keepSessionAlive" style="width: auto; margin: 0;">
165
+ </div>
166
+ <div class="form-row" style="display: flex; align-items: center; margin-bottom: 5px;">
167
+ <label for="node-config-input-endpointMustExist" style="width: auto; margin-right: 10px;"><i class="fa fa-exclamation-circle"></i> Endpoint Must Exist</label>
168
+ <input type="checkbox" id="node-config-input-endpointMustExist" style="width: auto; margin: 0;">
169
+ </div>
170
+ <div style="font-size: 0.85em; color: #666; margin-left: 20px; margin-top: -5px; margin-bottom: 10px;">
171
+ Verify that the server endpoint matches the requested security policy and mode before connecting.
172
+ </div>
173
+ <div class="form-row" style="display: flex; align-items: center;">
174
+ <label for="node-config-input-autoReconnect" style="width: auto; margin-right: 10px;"><i class="fa fa-refresh"></i> Automatic Reconnect</label>
175
+ <input type="checkbox" id="node-config-input-autoReconnect" style="width: auto; margin: 0;">
176
+ </div>
177
+ <div class="form-row opcua-client-reconnect-row">
178
+ <label for="node-config-input-initialDelay"><i class="fa fa-clock-o"></i> Initial Delay (ms)</label>
179
+ <input type="number" id="node-config-input-initialDelay" placeholder="1000">
180
+ </div>
181
+ <div class="form-row opcua-client-reconnect-row">
182
+ <label for="node-config-input-maxDelay"><i class="fa fa-clock-o"></i> Max Delay (ms)</label>
183
+ <input type="number" id="node-config-input-maxDelay" placeholder="10000">
184
+ </div>
185
+ <div class="form-row opcua-client-reconnect-row">
186
+ <label for="node-config-input-maxRetry"><i class="fa fa-repeat"></i> Max Retries</label>
187
+ <input type="number" id="node-config-input-maxRetry" placeholder="-1">
188
+ <div style="font-size: 0.85em; color: #666; margin-left: 110px; margin-top: 2px;">
189
+ -1: Retry indefinitely. 0: Do not attempt to reconnect.
190
+ </div>
191
+ </div>
75
192
  </script>
76
193
 
77
194
  <script type="text/html" data-help-name="opcua-client-config">
@@ -1,11 +1,17 @@
1
1
  "use strict";
2
2
 
3
+ const path = require("path");
4
+ const { OPCUACertificateManager, UserTokenType } = require("node-opcua");
5
+
3
6
  const {
4
7
  OPCUAClient,
5
8
  getMethodArgumentDefinition,
6
9
  resolveMethodObjectId,
7
10
  resolveSecurityMode,
8
- resolveSecurityPolicy
11
+ resolveSecurityPolicy,
12
+ AttributeIds,
13
+ dataValueToItemResult,
14
+ enrichItemResultWithEnumeration
9
15
  } = require("./opcua-client-utils");
10
16
 
11
17
  const {
@@ -23,10 +29,18 @@ module.exports = function (RED) {
23
29
 
24
30
 
25
31
  node.name = (config.name || "").trim();
32
+ node.sessionName = (config.sessionName || "ClientSession1").trim();
26
33
  node.endpoint = (config.endpoint || "").trim();
27
34
  node.securityPolicy = config.securityPolicy || "None";
28
35
  node.securityMode = config.securityMode || "None";
29
36
  node.authType = config.authType || "anonymous";
37
+ node.initialDelay = config.initialDelay !== undefined ? Number(config.initialDelay) : 1000;
38
+ node.maxDelay = config.maxDelay !== undefined ? Number(config.maxDelay) : 10000;
39
+ node.maxRetry = (config.maxRetry !== undefined && Number(config.maxRetry) !== 10) ? Number(config.maxRetry) : -1;
40
+ node.requestedSessionTimeout = config.requestedSessionTimeout !== undefined ? Number(config.requestedSessionTimeout) : 300000;
41
+ node.keepSessionAlive = config.keepSessionAlive !== false;
42
+ node.autoReconnect = config.autoReconnect !== false;
43
+ node.endpointMustExist = config.endpointMustExist === true;
30
44
  node.client = null;
31
45
  node.session = null;
32
46
  node.connectPromise = null;
@@ -52,23 +66,72 @@ module.exports = function (RED) {
52
66
  }
53
67
 
54
68
  this.connectPromise = (async () => {
69
+ const userDir = (RED.settings && RED.settings.userDir) || path.join(require('os').homedir(), ".node-red");
70
+ let flowFile = (RED.settings && RED.settings.flowFile) || "flows.json";
71
+ if (typeof flowFile !== "string") {
72
+ flowFile = "flows.json";
73
+ }
74
+ const flowFileFolder = path.isAbsolute(flowFile) ? path.dirname(flowFile) : path.join(userDir, path.dirname(flowFile));
75
+ const clientName = (this.name || "").trim() || this.sessionName || "default";
76
+ const safeClientName = clientName
77
+ .replace(/[\\/:\*\?"<>|]/g, "_")
78
+ .replace(/^\.+$/, "");
79
+ const clientCertificateFolder = path.join(flowFileFolder, "simple_opcua", "client", safeClientName);
80
+
81
+ // Ensure directories exist immediately
82
+ const fs = require("fs");
83
+ try {
84
+ const trustedDir = path.join(clientCertificateFolder, "trusted", "certs");
85
+ const rejectedDir = path.join(clientCertificateFolder, "rejected");
86
+ if (!fs.existsSync(trustedDir)) {
87
+ fs.mkdirSync(trustedDir, { recursive: true });
88
+ }
89
+ if (!fs.existsSync(rejectedDir)) {
90
+ fs.mkdirSync(rejectedDir, { recursive: true });
91
+ }
92
+ } catch (e) {
93
+ // Ignore directory creation errors
94
+ }
95
+
96
+ const clientCertificateManager = new OPCUACertificateManager({
97
+ rootFolder: clientCertificateFolder,
98
+ automaticallyAcceptUnknownCertificate: true
99
+ });
100
+ await clientCertificateManager.initialize();
101
+
55
102
  const client = OPCUAClient.create({
56
- endpointMustExist: false,
57
- keepSessionAlive: true,
103
+ endpointMustExist: this.endpointMustExist,
104
+ keepSessionAlive: this.keepSessionAlive,
58
105
  securityMode: resolveSecurityMode(this.securityMode),
59
- securityPolicy: resolveSecurityPolicy(this.securityPolicy)
106
+ securityPolicy: resolveSecurityPolicy(this.securityPolicy),
107
+ clientName: this.sessionName || "ClientSession",
108
+ clientCertificateManager: clientCertificateManager,
109
+ requestedSessionTimeout: this.requestedSessionTimeout,
110
+ connectionStrategy: {
111
+ maxRetry: this.autoReconnect ? this.maxRetry : 0,
112
+ initialDelay: this.initialDelay,
113
+ maxDelay: this.maxDelay
114
+ }
60
115
  });
61
116
 
117
+ client._nextSessionName = () => {
118
+ return this.sessionName || "ClientSession1";
119
+ };
120
+
62
121
  try {
63
122
  await client.connect(this.endpoint);
64
123
 
65
124
  const credentials = this.credentials || {};
66
- const session = this.authType === "username"
67
- ? await client.createSession({
125
+ let userIdentity = { type: UserTokenType.Anonymous };
126
+
127
+ if (this.authType === "username") {
128
+ userIdentity = {
129
+ type: UserTokenType.UserName,
68
130
  userName: credentials.username || "",
69
131
  password: credentials.password || ""
70
- })
71
- : await client.createSession();
132
+ };
133
+ }
134
+ const session = await client.createSession(userIdentity);
72
135
 
73
136
  session.on("session_closed", () => {
74
137
  this.session = null;
@@ -154,11 +217,51 @@ module.exports = function (RED) {
154
217
  res.status(500).json({ error: error.message });
155
218
  }
156
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
+ });
157
254
  };
158
255
 
159
256
  async function browseForEditor(configNode, nodeId) {
160
257
  const session = await configNode.getSession();
161
- return browseNode(session, {
258
+ const result = await browseNode(session, {
162
259
  nodeID: nodeId || ROOT_NODE_ID
163
260
  });
261
+ if (result && result.children) {
262
+ result.browse = result.children;
263
+ delete result.children;
264
+ }
265
+ return result;
164
266
  }
267
+
@@ -5,6 +5,7 @@
5
5
  <p><b>Read</b>: reads one or more variable values.</p>
6
6
  <p><b>Write</b>: writes one or more variable values.</p>
7
7
  <p><b>Browse</b>: browses one or more OPC UA nodes and returns their children.</p>
8
+ <p><b>Browse Recursive</b>: perform a recursive search across all nodes starting from the specified node.</p>
8
9
  <p><b>Method</b>: calls one or more OPC UA methods.</p>
9
10
  <p><b>Subscription</b>: subscribes to one or more variable values and emits one message per change.</p>
10
11
 
@@ -33,6 +34,11 @@
33
34
  <p>If <code>msg.payload</code> is not an array and <code>NodeId</code> is configured, the node browses that configured node.</p>
34
35
  <p>If <code>msg.payload = []</code>, the node browses the OPC UA <code>RootFolder</code>.</p>
35
36
 
37
+ <h3>Browse Recursive</h3>
38
+ <p>Perform a recursive search across all nodes starting from the specified <code>nodeId</code>. It will return the value (if it is a variable), display name, description, and all other item information.</p>
39
+ <p>If <code>msg.payload</code> is not an array and <code>NodeId</code> is configured, the node browses recursively starting from that configured node.</p>
40
+ <p>If <code>msg.payload = []</code>, the node browses recursively starting from the OPC UA <code>RootFolder</code>.</p>
41
+
36
42
  <h3>Method</h3>
37
43
  <p><code>nodeID</code> and <code>objectId</code> are accepted explicitly. For backward compatibility, <code>nodeID</code> is also accepted as the method id.</p>
38
44
  <p><b>Input</b> <code>msg.payload</code>:</p>
@@ -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)
@@ -469,7 +592,15 @@ async function getMethodArgumentDefinition(session, methodNodeId, cache) {
469
592
  }
470
593
 
471
594
  async function enrichItemResultWithEnumeration(result, session, cache, nodeId) {
472
- if (result.type !== "Int32" && result.type !== "Enumeration") {
595
+ const type = result.type || result.dataType;
596
+ if (!type) {
597
+ return result;
598
+ }
599
+
600
+ const isStandardEnum = type === "Int32" || type === "Enumeration";
601
+ const isCustomNodeId = typeof type === "string" && (type.includes("i=") || type.includes("ns="));
602
+
603
+ if (!isStandardEnum && !isCustomNodeId) {
473
604
  return result;
474
605
  }
475
606
 
@@ -481,15 +612,29 @@ async function enrichItemResultWithEnumeration(result, session, cache, nodeId) {
481
612
  const cacheKeyType = "dt:" + nodeId;
482
613
  let dtNodeId = cache ? cache.get(cacheKeyType) : undefined;
483
614
  if (dtNodeId === undefined) {
484
- const dv = await session.read({
485
- nodeId: nodeId,
486
- attributeId: AttributeIds.DataType
487
- });
488
- if (dv.statusCode.isGood()) {
489
- dtNodeId = dv.value.value;
490
- if (cache) cache.set(cacheKeyType, dtNodeId);
491
- } else {
492
- if (cache) cache.set(cacheKeyType, null);
615
+ const isNodeIdLike = result.dataType && (
616
+ typeof result.dataType !== "string" ||
617
+ result.dataType.includes("i=") ||
618
+ result.dataType.includes("ns=")
619
+ );
620
+ if (isNodeIdLike) {
621
+ try {
622
+ dtNodeId = coerceNodeId(result.dataType);
623
+ } catch (e) {
624
+ dtNodeId = undefined;
625
+ }
626
+ }
627
+ if (dtNodeId === undefined) {
628
+ const dv = await session.read({
629
+ nodeId: nodeId,
630
+ attributeId: AttributeIds.DataType
631
+ });
632
+ if (dv.statusCode.isGood()) {
633
+ dtNodeId = dv.value.value;
634
+ if (cache) cache.set(cacheKeyType, dtNodeId);
635
+ } else {
636
+ if (cache) cache.set(cacheKeyType, null);
637
+ }
493
638
  }
494
639
  }
495
640
 
@@ -549,6 +694,8 @@ async function enrichItemResultWithEnumeration(result, session, cache, nodeId) {
549
694
 
550
695
  if (enumStrings && enumStrings[result.value] !== undefined) {
551
696
  result.valueEnumeration = enumStrings[result.value];
697
+ if (result.type) result.type = "Enumeration";
698
+ if (result.dataType) result.dataType = "Enumeration";
552
699
  }
553
700
  } catch (e) {
554
701
  console.error("Error in enrichItemResultWithEnumeration:", e);
@@ -577,5 +724,6 @@ module.exports = {
577
724
  resolveNodeId,
578
725
  resolveSecurityMode,
579
726
  resolveSecurityPolicy,
727
+ reshapeArray,
580
728
  statusCodeToString
581
729
  };
@@ -17,6 +17,7 @@
17
17
  <option value="read">Read</option>
18
18
  <option value="write">Write</option>
19
19
  <option value="browse">Browse</option>
20
+ <option value="browseRecursive">Browse Recursive</option>
20
21
  <option value="method">Method</option>
21
22
  <option value="getSubscriptionId">getSubscriptionId</option>
22
23
  <option value="subscription">Subscription</option>
@@ -49,6 +50,7 @@
49
50
  <div id="node-input-browse-context-menu" class="opcua-tree-context-menu" style="display:none;">
50
51
  <a href="#" id="node-input-browse-context-refresh"><i class="fa fa-refresh"></i> Refresh node</a>
51
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>
52
54
  </div>
53
55
  </div>
54
56
 
@@ -85,6 +87,7 @@
85
87
  <p><b>Read</b>: reads one or more variable values.</p>
86
88
  <p><b>Write</b>: writes one or more variable values.</p>
87
89
  <p><b>Browse</b>: browses one or more OPC UA nodes and returns their children.</p>
90
+ <p><b>Browse Recursive</b>: perform a recursive search across all nodes starting from the specified node.</p>
88
91
  <p><b>Method</b>: calls one or more OPC UA methods.</p>
89
92
  <p><b>Subscription</b>: subscribes to one or more variable values and emits one message per change.</p>
90
93
 
@@ -113,6 +116,11 @@
113
116
  <p>If <code>msg.payload</code> is not an array and <code>NodeId</code> is configured, the node browses that configured node.</p>
114
117
  <p>If <code>msg.payload = []</code>, the node browses the OPC UA <code>RootFolder</code>.</p>
115
118
 
119
+ <h3>Browse Recursive</h3>
120
+ <p>Perform a recursive search across all nodes starting from the specified <code>nodeId</code>. It will return the value (if it is a variable), display name, description, and all other item information.</p>
121
+ <p>If <code>msg.payload</code> is not an array and <code>NodeId</code> is configured, the node browses recursively starting from that configured node.</p>
122
+ <p>If <code>msg.payload = []</code>, the node browses recursively starting from the OPC UA <code>RootFolder</code>.</p>
123
+
116
124
  <h3>Method</h3>
117
125
  <p><code>nodeID</code> and <code>objectId</code> are accepted explicitly. For backward compatibility, <code>nodeID</code> is also accepted as the method id.</p>
118
126
  <p><b>Input</b> <code>msg.payload</code>:</p>