@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.
@@ -737,22 +737,39 @@ class OpcUaServerConfigParser {
737
737
  }
738
738
 
739
739
  coerceValue(value, type) {
740
- if (Array.isArray(value)) {
741
- return value.map((item) => this.coerceScalarValue(item, type));
742
- }
740
+ const recursiveMap = (val, fn) => {
741
+ if (Array.isArray(val)) {
742
+ if ((type === "Int64" || type === "UInt64") && val.length === 2 && typeof val[0] === "number" && typeof val[1] === "number") {
743
+ return fn(val);
744
+ }
745
+ return val.map(item => recursiveMap(item, fn));
746
+ }
747
+ return fn(val);
748
+ };
743
749
 
744
- if (typeof value === "string") {
745
- const trimmed = value.trim();
746
- if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
747
- try {
748
- const parsed = JSON.parse(trimmed);
749
- if (Array.isArray(parsed)) {
750
- return parsed.map((item) => this.coerceScalarValue(item, type));
750
+ const extractArray = (val) => {
751
+ if (Array.isArray(val)) {
752
+ return val;
753
+ }
754
+ if (typeof val === "string") {
755
+ const trimmed = val.trim();
756
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
757
+ try {
758
+ const parsed = JSON.parse(trimmed);
759
+ if (Array.isArray(parsed)) {
760
+ return parsed;
761
+ }
762
+ } catch (error) {
763
+ throw new Error("Invalid array value for type " + type + ": " + error.message);
751
764
  }
752
- } catch (error) {
753
- throw new Error("Invalid array value for type " + type + ": " + error.message);
754
765
  }
755
766
  }
767
+ return null;
768
+ };
769
+
770
+ const items = extractArray(value);
771
+ if (items) {
772
+ return recursiveMap(items, (item) => this.coerceScalarValue(item, type));
756
773
  }
757
774
 
758
775
  return this.coerceScalarValue(value, type);
@@ -110,10 +110,36 @@ function flushQueue(node, nodeId) {
110
110
 
111
111
 
112
112
  function upsertEvent(map, event) {
113
- const key = String(event.nodeID || "").trim();
114
- if (!key) return;
115
-
116
- map.set(key, event); // sobrescreve automaticamente
113
+ const nodeKey = String(event.nodeID || "").trim();
114
+ if (!nodeKey) return;
115
+
116
+ // Key by nodeID only so the same variable is merged into one entry
117
+ const key = nodeKey;
118
+ const existing = map.get(key);
119
+
120
+ if (!existing) {
121
+ // First access for this variable in this interval — store a copy
122
+ map.set(key, Object.assign({}, event, { users: Array.isArray(event.users) ? [...event.users] : [] }));
123
+ } else {
124
+ // Variable already seen — update value and merge any new users
125
+ existing.value = event.value;
126
+ if (event.message !== undefined) existing.message = event.message;
127
+ if (event.severity !== undefined) existing.severity = event.severity;
128
+ if (event.retain !== undefined) existing.retain = event.retain;
129
+ if (event.activeState !== undefined) existing.activeState = event.activeState;
130
+ if (event.sourceName !== undefined) existing.sourceName = event.sourceName;
131
+ if (event.conditionName !== undefined) existing.conditionName = event.conditionName;
132
+ if (event.ConfirmedState !== undefined) existing.ConfirmedState = event.ConfirmedState;
133
+ if (event.ackedState !== undefined) existing.ackedState = event.ackedState;
134
+ if (event.alarmNode !== undefined) existing.alarmNode = event.alarmNode;
135
+ const existingNames = new Set((existing.users || []).map(u => u.name));
136
+ for (const user of (event.users || [])) {
137
+ if (!existingNames.has(user.name)) {
138
+ existing.users.push(user);
139
+ existingNames.add(user.name);
140
+ }
141
+ }
142
+ }
117
143
  }
118
144
 
119
145
  function matchesServer(serverRef, event) {
@@ -398,6 +398,14 @@ class OpcUaServerProcess {
398
398
  const target = msg && msg.opcuaServerIo ? msg.opcuaServerIo : {};
399
399
  const identifierType = this.resolveIdentifierType(target);
400
400
 
401
+ let isSingleTag = false;
402
+ try {
403
+ this.resolveIdentifier(target);
404
+ isSingleTag = true;
405
+ } catch (e) {
406
+ // not a single tag
407
+ }
408
+
401
409
  // Buffer serializado pelo IPC
402
410
  if (
403
411
  payload &&
@@ -464,7 +472,7 @@ class OpcUaServerProcess {
464
472
  }
465
473
 
466
474
  // Array de objetos
467
- else if (Array.isArray(payload)) {
475
+ else if (Array.isArray(payload) && (!isSingleTag || this.isBatchWritePayload(payload))) {
468
476
 
469
477
  if (!payload.length) {
470
478
  throw new Error("msg.payload array does not contain any items");
@@ -617,6 +625,18 @@ class OpcUaServerProcess {
617
625
  }
618
626
  }
619
627
 
628
+ isBatchWritePayload(payload) {
629
+ if (!Array.isArray(payload) || payload.length === 0) {
630
+ return false;
631
+ }
632
+ return payload.every(item =>
633
+ item &&
634
+ typeof item === "object" &&
635
+ !Array.isArray(item) &&
636
+ (item.path !== undefined || item.nodeId !== undefined || item.identifier !== undefined)
637
+ );
638
+ }
639
+
620
640
  resolveIdentifierType(target) {
621
641
  return target && target.identifierType === "nodeId" ? "nodeId" : "path";
622
642
  }
@@ -885,10 +905,12 @@ class OpcUaServerProcess {
885
905
 
886
906
  readActiveAlarms(msg, nodeId) {
887
907
  try {
888
- var result = registry.getActiveAlarms(this.node)
889
- const msg2 = {
890
- payload: result
891
- }
908
+ const result = registry.getActiveAlarms(this.node);
909
+ const safeResult = Array.isArray(result) ? result : [];
910
+
911
+ const msg2 = Object.assign({}, msg || {}, {
912
+ payload: safeResult
913
+ });
892
914
 
893
915
  process.send({
894
916
  type: "send",
@@ -901,12 +923,16 @@ class OpcUaServerProcess {
901
923
  data: {
902
924
  fill: "green",
903
925
  shape: "dot",
904
- text: result.paths.length > 1 ? "read " + result.paths.length + " tags" : "read " + result.paths[0]
926
+ text: safeResult.length + " active alarm" + (safeResult.length !== 1 ? "s" : "")
905
927
  },
906
928
  nodeId: nodeId
907
929
  });
908
- } catch {
909
-
930
+ } catch (error) {
931
+ process.send({
932
+ type: "error",
933
+ data: error.message,
934
+ nodeId: nodeId
935
+ });
910
936
  }
911
937
  }
912
938
 
@@ -1049,6 +1075,105 @@ class OpcUaServerProcess {
1049
1075
  }
1050
1076
  }
1051
1077
 
1078
+ async validateLogin(msg, nodeId) {
1079
+ try {
1080
+ await this.ensureReady();
1081
+ const runtime = this.runtime;
1082
+ if (!runtime) {
1083
+ throw new Error("OPC UA server is not available");
1084
+ }
1085
+
1086
+ const payload = msg && msg.payload ? msg.payload : {};
1087
+ const username = typeof payload.userName === "string" ? payload.userName : (typeof payload.username === "string" ? payload.username : "");
1088
+ const password = typeof payload.password === "string" ? payload.password : "";
1089
+
1090
+ const normalizedUserName = username.trim();
1091
+ const users = Array.isArray(runtime.users) ? runtime.users : [];
1092
+ const user = users.find((entry) => entry && entry.username === normalizedUserName);
1093
+
1094
+ let isValid = false;
1095
+ if (user) {
1096
+ if (user.password && user.password === password) {
1097
+ isValid = true;
1098
+ } else if (user.passwordHash) {
1099
+ let bcrypt = null;
1100
+ try {
1101
+ bcrypt = require("bcryptjs");
1102
+ } catch (e) {
1103
+ bcrypt = null;
1104
+ }
1105
+ if (bcrypt) {
1106
+ try {
1107
+ isValid = bcrypt.compareSync(password, user.passwordHash);
1108
+ } catch (err) {
1109
+ // ignore comparison error
1110
+ }
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ let result;
1116
+ if (isValid) {
1117
+ const groups = typeof user.group === "string"
1118
+ ? user.group.split(",").map(g => g.trim()).filter(Boolean)
1119
+ : Array.isArray(user.group)
1120
+ ? user.group
1121
+ : [];
1122
+
1123
+ result = {
1124
+ status: "Good",
1125
+ username: user.username,
1126
+ group: user.group,
1127
+ groups: groups
1128
+ };
1129
+ } else {
1130
+ result = {
1131
+ status: "erro",
1132
+ message: "Invalid username or password"
1133
+ };
1134
+ }
1135
+
1136
+ if (msg) {
1137
+ const outMsg = Object.assign({}, msg);
1138
+ outMsg.payload = result;
1139
+
1140
+ process.send({
1141
+ type: "send",
1142
+ data: outMsg,
1143
+ nodeId: nodeId
1144
+ });
1145
+ }
1146
+
1147
+ process.send({
1148
+ type: "status",
1149
+ data: {
1150
+ fill: isValid ? "green" : "yellow",
1151
+ shape: "dot",
1152
+ text: isValid ? "Login: Good" : "Login: erro"
1153
+ },
1154
+ nodeId: nodeId
1155
+ });
1156
+ } catch (error) {
1157
+ if (msg) {
1158
+ const outMsg = Object.assign({}, msg);
1159
+ outMsg.payload = {
1160
+ status: "erro",
1161
+ message: error.message
1162
+ };
1163
+ process.send({
1164
+ type: "send",
1165
+ data: outMsg,
1166
+ nodeId: nodeId
1167
+ });
1168
+ }
1169
+ process.send({
1170
+ type: "status",
1171
+ data: { fill: "red", shape: "ring", text: "Login error: " + error.message },
1172
+ nodeId: nodeId
1173
+ });
1174
+ }
1175
+ }
1176
+
1052
1177
  }
1053
1178
 
1054
1179
  /**
@@ -1205,6 +1330,9 @@ process.on("message", async (msg) => {
1205
1330
  case "deleteActiveSessions":
1206
1331
  await serverProcess.deleteActiveSessions(msg.msg, msg.nodeId);
1207
1332
  break;
1333
+ case "validateLogin":
1334
+ await serverProcess.validateLogin(msg.msg, msg.nodeId);
1335
+ break;
1208
1336
 
1209
1337
 
1210
1338
 
@@ -1242,4 +1370,8 @@ process.on("unhandledRejection", (reason) => {
1242
1370
  data: "Unhandled Rejection: " + (reason?.message || reason),
1243
1371
  nodeId: serverProcess.node.id
1244
1372
  });
1245
- });
1373
+ });
1374
+
1375
+ if (require.main !== module) {
1376
+ module.exports = { OpcUaServerProcess };
1377
+ }
@@ -160,6 +160,9 @@ class OpcUaServerRuntime {
160
160
  this.ensureReady();
161
161
  this.syncNamespaces(treeConfig);
162
162
  this.treeConfig = treeConfig;
163
+ // Refresh the user list in the builder so newly added/removed users are
164
+ // recognised in access events without requiring a full server restart.
165
+ this.addressSpaceBuilder.updateUsers(this.users);
163
166
  this.addressSpaceBuilder.sync(treeConfig);
164
167
  }
165
168
 
@@ -99,10 +99,41 @@ function buildServerSnapshot(serverNode) {
99
99
  },
100
100
  endpoints: Array.isArray(server.endpoints)
101
101
  ? server.endpoints.map((endpoint) => buildEndpointSnapshot(endpoint))
102
- : []
102
+ : [],
103
+ users: buildUsersSnapshot(serverNode),
104
+ groups: buildGroupsSnapshot(serverNode)
103
105
  };
104
106
  }
105
107
 
108
+ function buildUsersSnapshot(serverNode) {
109
+ const rawUsers = Array.isArray(serverNode.users) ? serverNode.users : [];
110
+ const allowAnonymous = Boolean(serverNode.allowAnonymous);
111
+
112
+ const result = rawUsers.map((user) => ({
113
+ name: extractText(user && user.username),
114
+ groups: resolveUserGroups(user, serverNode.groups)
115
+ }));
116
+
117
+ if (allowAnonymous) {
118
+ result.unshift({ name: "anonymous", groups: [] });
119
+ }
120
+
121
+ return result;
122
+ }
123
+
124
+ function buildGroupsSnapshot(serverNode) {
125
+ const rawGroups = Array.isArray(serverNode.groups) ? serverNode.groups : [];
126
+ return rawGroups.map((g) => extractText(g && (g.name || g))).filter(Boolean);
127
+ }
128
+
129
+ function resolveUserGroups(user, rawGroups) {
130
+ if (!user) return [];
131
+ // normalizeUser stores groups as user.group (comma-separated string)
132
+ const groupsStr = extractText(user.group);
133
+ if (!groupsStr) return [];
134
+ return groupsStr.split(",").map((g) => g.trim()).filter(Boolean);
135
+ }
136
+
106
137
  function buildEndpointSnapshot(endpoint) {
107
138
  const descriptions = typeof endpoint.endpointDescriptions === "function"
108
139
  ? endpoint.endpointDescriptions()
@@ -172,6 +172,7 @@
172
172
  <option value="activeAlarms">Active Alarms</option>
173
173
  <option value="getSessions">Get Sessions</option>
174
174
  <option value="deleteSessions">Delete Sessions</option>
175
+ <option value="validateLogin">Validate Login</option>
175
176
  <option value="method-input">Method Input</option>
176
177
  <option value="method-output">Method Output</option>
177
178
  </select>
@@ -410,14 +411,29 @@ msg.payload = [
410
411
  "error": "cannot find session …"
411
412
  }
412
413
  ]</code></pre>
413
- <p>Possible <code>status</code> values:</p>
414
414
  <ul>
415
415
  <li><b>deleted</b> — session was found and successfully closed with reason <code>Forcing</code>.</li>
416
416
  <li><b>not_found</b> — no active session with that <code>sessionId</code> exists.</li>
417
- <li><b>error</b> — the session was found but <code>engine.closeSession()</code> threw; the
418
- <code>error</code> field contains the message.</li>
417
+ <li><b>error</b> — the session was found but <code>engine.closeSession()</code> threw; the <code>error</code> field contains the message.</li>
419
418
  </ul>
420
419
 
421
-
422
-
420
+ <h3>Validate Login</h3>
421
+ <p>Validates a user's credentials against the OPC UA server's configured user database.</p>
422
+ <p><b>Input</b>: <code>msg.payload</code> must be a JSON object containing <code>userName</code> (or <code>username</code>) and <code>password</code>:</p>
423
+ <pre><code>{
424
+ "userName": "admin",
425
+ "password": "123"
426
+ }</code></pre>
427
+ <p><b>Output</b>: <code>msg.payload</code> contains the status of validation and user groups (excluding password hashes):</p>
428
+ <pre><code>{
429
+ "status": "Good",
430
+ "username": "admin",
431
+ "group": "admin,operator",
432
+ "groups": ["admin", "operator"]
433
+ }</code></pre>
434
+ <p>If authentication fails or the user is not found, the response contains:</p>
435
+ <pre><code>{
436
+ "status": "erro",
437
+ "message": "Invalid username or password"
438
+ }</code></pre>
423
439
  </script>
@@ -119,6 +119,13 @@ module.exports = function (RED) {
119
119
  node._pendingDoneCallbacks.delete(msg._msgid);
120
120
  }
121
121
  }
122
+ else if (node.mode === "validateLogin") {
123
+ await handleValidateLogin(node, msg, { waitForServer: true, timeoutMs: 5000 });
124
+ done();
125
+ if (msg && msg._msgid) {
126
+ node._pendingDoneCallbacks.delete(msg._msgid);
127
+ }
128
+ }
122
129
 
123
130
  } catch (error) {
124
131
  node.status({ fill: "red", shape: "ring", text: node.mode + " failed" });
@@ -255,7 +262,8 @@ module.exports = function (RED) {
255
262
  opcua: {
256
263
  server: msg.data.serverName,
257
264
  method: msg.data.methodName,
258
- data: msg.data
265
+ data: msg.data,
266
+ users: Array.isArray(msg.data.users) ? msg.data.users : []
259
267
  },
260
268
  _callId: msg.data.callId
261
269
  });
@@ -286,7 +294,8 @@ module.exports = function (RED) {
286
294
  opcua: {
287
295
  server: msg.data.serverName,
288
296
  method: msg.data.methodName,
289
- data: msg.data
297
+ data: msg.data,
298
+ users: Array.isArray(msg.data.users) ? msg.data.users : []
290
299
  },
291
300
  _callId: msg.data.callId
292
301
  });
@@ -345,6 +354,14 @@ module.exports = function (RED) {
345
354
  }, options);
346
355
  }
347
356
 
357
+ async function handleValidateLogin(node, msg, options) {
358
+ return sendToChild(node, {
359
+ type: "validateLogin",
360
+ msg: msg,
361
+ nodeId: node.id
362
+ }, options);
363
+ }
364
+
348
365
 
349
366
  async function handleWrite(node, msg) {
350
367
 
@@ -474,6 +491,10 @@ module.exports = function (RED) {
474
491
  registerMethodInput(node);
475
492
  }
476
493
 
494
+ if (node.mode === "events") {
495
+ registerEvents(node, { throwOnError: false, silentOnError: true });
496
+ }
497
+
477
498
  return true;
478
499
  }
479
500
 
@@ -216,8 +216,14 @@ function getActiveAlarms(node) {
216
216
  conditionName: ConditionName,
217
217
  ConfirmedState: ConfirmedState,
218
218
  ackedState: isAcked,
219
- alarmNode: alarmNode,
220
-
219
+ alarmNode: {
220
+ nodeId: alarmNode.nodeId,
221
+ browseName: alarmNode.browseName,
222
+ displayName: alarmNode.displayName,
223
+ description: alarmNode.description,
224
+ nodeClass: alarmNode.nodeClass,
225
+ typeDefinition: alarmNode.typeDefinition
226
+ }
221
227
  })
222
228
  });
223
229
 
@@ -315,3 +315,15 @@
315
315
  width: auto !important;
316
316
  display: inline-block;
317
317
  }
318
+
319
+ /* Drag and Drop styling for visual tree rows */
320
+ .opcua-tree-row.is-dragging {
321
+ opacity: 0.4;
322
+ border: 1px dashed var(--red-ui-form-input-border-color, #999);
323
+ }
324
+
325
+ .opcua-tree-row.drag-over {
326
+ background-color: var(--red-ui-list-item-background-hover, #f3f7fd) !important;
327
+ box-shadow: inset 0 0 0 2px var(--red-ui-form-input-border-color-selected, #2196F3);
328
+ }
329
+
@@ -211,6 +211,8 @@
211
211
  <a href="#" data-action="add-method"><i class="fa fa-plus"></i> Add Method</a>
212
212
  <a href="#" data-action="edit"><i class="fa fa-pencil"></i> Edit</a>
213
213
  <a href="#" data-action="remove"><i class="fa fa-trash"></i> Remove</a>
214
+ <a href="#" data-action="expand-all-below"><i class="fa fa-angle-double-down"></i> Expand All Below</a>
215
+ <a href="#" data-action="collapse-all-below"><i class="fa fa-angle-double-up"></i> Collapse All Below</a>
214
216
  </div>
215
217
  </div>
216
218
  </div>
@@ -576,4 +576,15 @@ body.opcua-tree-modal-open {
576
576
  .opcua-config-input[type="checkbox"] {
577
577
  width: auto !important;
578
578
  display: inline-block;
579
+ }
580
+
581
+ /* Drag and Drop styling for visual tree rows */
582
+ .opcua-tree-row.is-dragging {
583
+ opacity: 0.4;
584
+ border: 1px dashed var(--red-ui-form-input-border-color, #999);
585
+ }
586
+
587
+ .opcua-tree-row.drag-over {
588
+ background-color: var(--red-ui-list-item-background-hover, #f3f7fd) !important;
589
+ box-shadow: inset 0 0 0 2px var(--red-ui-form-input-border-color-selected, #2196F3);
579
590
  }