@vitormnm/node-red-simple-opcua 1.6.3 → 1.7.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 +89 -136
- package/client/lib/opcua-client-browser.js +238 -10
- package/client/lib/opcua-client-method-service.js +1 -1
- package/client/lib/opcua-client-subscription-service.js +0 -2
- package/client/opcua-client-config.html +118 -1
- package/client/opcua-client-config.js +74 -8
- package/client/opcua-client-help.html +6 -0
- package/client/opcua-client-utils.js +34 -10
- package/client/opcua-client.html +7 -0
- package/client/opcua-client.js +97 -1
- package/examples/flows_simple_opc.json +1 -1
- package/package.json +1 -1
- package/server/lib/opcua-address-space-alarm.js +11 -5
- package/server/lib/opcua-address-space-builder.js +65 -15
- package/server/lib/opcua-config.js +81 -23
- package/server/lib/opcua-server-events-child.js +1 -1
- package/server/lib/opcua-server-runtime-child.js +284 -19
- package/server/lib/opcua-server-runtime.js +49 -5
- package/server/lib/opcua-server-status-child.js +14 -14
- package/server/nodered/simple_opcua/server/certificates/mutex +0 -0
- package/server/nodered/simple_opcua/server/certificates/own/certs/server_selfsigned_cert_2048.pem +25 -0
- package/server/nodered/simple_opcua/server/certificates/own/certs/server_selfsigned_cert_2048.pem.mutex +0 -0
- package/server/nodered/simple_opcua/server/certificates/own/openssl.cnf +72 -0
- package/server/nodered/simple_opcua/server/certificates/own/private/private_key.pem +28 -0
- package/server/nodered/simple_opcua/server/certificates/trusted/certs/NodeOPCUA-Client@tuf[c5a9e20a8b680cdff76aaf0165bb3c9318da37a5].pem +25 -0
- package/server/nodered/simple_opcua/server/myServer1/mutex +0 -0
- package/server/nodered/simple_opcua/server/myServer1/own/certs/server_selfsigned_cert_2048.pem +25 -0
- package/server/nodered/simple_opcua/server/myServer1/own/certs/server_selfsigned_cert_2048.pem.mutex +0 -0
- package/server/nodered/simple_opcua/server/myServer1/own/openssl.cnf +72 -0
- package/server/nodered/simple_opcua/server/myServer1/own/private/private_key.pem +28 -0
- package/server/nodered/simple_opcua/server/myServer1/trusted/certs/NodeOPCUA-Client@tuf[91e520c64ff891c67168f08a46dd194071e15dae].pem +25 -0
- package/server/nodered/simple_opcua/server/myServer1/trusted/certs/NodeOPCUA-Client@tuf[98ae95da627cea4c500753c319161a3554ee38d7].pem +25 -0
- package/server/nodered/simple_opcua/server/myServer1/trusted/certs/NodeOPCUA-Client@tuf[aef8d7a1cfba13d84189a0bcf1694208fc51a7f9].pem +25 -0
- package/server/nodered/simple_opcua/server/myServer1/trusted/certs/NodeOPCUA-Client@tuf[c5a9e20a8b680cdff76aaf0165bb3c9318da37a5].pem +25 -0
- package/server/nodered/simple_opcua/server/myServer1/trusted/certs/NodeOPCUA-Client@tuf[ebdf9acf1d02e347917a14108d3144799c638ea3].pem +25 -0
- package/server/opcua-server-io.html +76 -0
- package/server/opcua-server-io.js +130 -27
- package/server/opcua-server.css +52 -0
- package/server/opcua-server.html +166 -44
- package/server/opcua-server.js +115 -5
- package/server/view/opcua-server.css +89 -6
- package/server/view/opcua-server.js +523 -42
|
@@ -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,5 +1,8 @@
|
|
|
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,
|
|
@@ -23,10 +26,18 @@ module.exports = function (RED) {
|
|
|
23
26
|
|
|
24
27
|
|
|
25
28
|
node.name = (config.name || "").trim();
|
|
29
|
+
node.sessionName = (config.sessionName || "ClientSession1").trim();
|
|
26
30
|
node.endpoint = (config.endpoint || "").trim();
|
|
27
31
|
node.securityPolicy = config.securityPolicy || "None";
|
|
28
32
|
node.securityMode = config.securityMode || "None";
|
|
29
33
|
node.authType = config.authType || "anonymous";
|
|
34
|
+
node.initialDelay = config.initialDelay !== undefined ? Number(config.initialDelay) : 1000;
|
|
35
|
+
node.maxDelay = config.maxDelay !== undefined ? Number(config.maxDelay) : 10000;
|
|
36
|
+
node.maxRetry = (config.maxRetry !== undefined && Number(config.maxRetry) !== 10) ? Number(config.maxRetry) : -1;
|
|
37
|
+
node.requestedSessionTimeout = config.requestedSessionTimeout !== undefined ? Number(config.requestedSessionTimeout) : 300000;
|
|
38
|
+
node.keepSessionAlive = config.keepSessionAlive !== false;
|
|
39
|
+
node.autoReconnect = config.autoReconnect !== false;
|
|
40
|
+
node.endpointMustExist = config.endpointMustExist === true;
|
|
30
41
|
node.client = null;
|
|
31
42
|
node.session = null;
|
|
32
43
|
node.connectPromise = null;
|
|
@@ -52,23 +63,72 @@ module.exports = function (RED) {
|
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
this.connectPromise = (async () => {
|
|
66
|
+
const userDir = (RED.settings && RED.settings.userDir) || path.join(require('os').homedir(), ".node-red");
|
|
67
|
+
let flowFile = (RED.settings && RED.settings.flowFile) || "flows.json";
|
|
68
|
+
if (typeof flowFile !== "string") {
|
|
69
|
+
flowFile = "flows.json";
|
|
70
|
+
}
|
|
71
|
+
const flowFileFolder = path.isAbsolute(flowFile) ? path.dirname(flowFile) : path.join(userDir, path.dirname(flowFile));
|
|
72
|
+
const clientName = (this.name || "").trim() || this.sessionName || "default";
|
|
73
|
+
const safeClientName = clientName
|
|
74
|
+
.replace(/[\\/:\*\?"<>|]/g, "_")
|
|
75
|
+
.replace(/^\.+$/, "");
|
|
76
|
+
const clientCertificateFolder = path.join(flowFileFolder, "simple_opcua", "client", safeClientName);
|
|
77
|
+
|
|
78
|
+
// Ensure directories exist immediately
|
|
79
|
+
const fs = require("fs");
|
|
80
|
+
try {
|
|
81
|
+
const trustedDir = path.join(clientCertificateFolder, "trusted", "certs");
|
|
82
|
+
const rejectedDir = path.join(clientCertificateFolder, "rejected");
|
|
83
|
+
if (!fs.existsSync(trustedDir)) {
|
|
84
|
+
fs.mkdirSync(trustedDir, { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
if (!fs.existsSync(rejectedDir)) {
|
|
87
|
+
fs.mkdirSync(rejectedDir, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
} catch (e) {
|
|
90
|
+
// Ignore directory creation errors
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const clientCertificateManager = new OPCUACertificateManager({
|
|
94
|
+
rootFolder: clientCertificateFolder,
|
|
95
|
+
automaticallyAcceptUnknownCertificate: true
|
|
96
|
+
});
|
|
97
|
+
await clientCertificateManager.initialize();
|
|
98
|
+
|
|
55
99
|
const client = OPCUAClient.create({
|
|
56
|
-
endpointMustExist:
|
|
57
|
-
keepSessionAlive:
|
|
100
|
+
endpointMustExist: this.endpointMustExist,
|
|
101
|
+
keepSessionAlive: this.keepSessionAlive,
|
|
58
102
|
securityMode: resolveSecurityMode(this.securityMode),
|
|
59
|
-
securityPolicy: resolveSecurityPolicy(this.securityPolicy)
|
|
103
|
+
securityPolicy: resolveSecurityPolicy(this.securityPolicy),
|
|
104
|
+
clientName: this.sessionName || "ClientSession",
|
|
105
|
+
clientCertificateManager: clientCertificateManager,
|
|
106
|
+
requestedSessionTimeout: this.requestedSessionTimeout,
|
|
107
|
+
connectionStrategy: {
|
|
108
|
+
maxRetry: this.autoReconnect ? this.maxRetry : 0,
|
|
109
|
+
initialDelay: this.initialDelay,
|
|
110
|
+
maxDelay: this.maxDelay
|
|
111
|
+
}
|
|
60
112
|
});
|
|
61
113
|
|
|
114
|
+
client._nextSessionName = () => {
|
|
115
|
+
return this.sessionName || "ClientSession1";
|
|
116
|
+
};
|
|
117
|
+
|
|
62
118
|
try {
|
|
63
119
|
await client.connect(this.endpoint);
|
|
64
120
|
|
|
65
121
|
const credentials = this.credentials || {};
|
|
66
|
-
|
|
67
|
-
|
|
122
|
+
let userIdentity = { type: UserTokenType.Anonymous };
|
|
123
|
+
|
|
124
|
+
if (this.authType === "username") {
|
|
125
|
+
userIdentity = {
|
|
126
|
+
type: UserTokenType.UserName,
|
|
68
127
|
userName: credentials.username || "",
|
|
69
128
|
password: credentials.password || ""
|
|
70
|
-
}
|
|
71
|
-
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const session = await client.createSession(userIdentity);
|
|
72
132
|
|
|
73
133
|
session.on("session_closed", () => {
|
|
74
134
|
this.session = null;
|
|
@@ -158,7 +218,13 @@ module.exports = function (RED) {
|
|
|
158
218
|
|
|
159
219
|
async function browseForEditor(configNode, nodeId) {
|
|
160
220
|
const session = await configNode.getSession();
|
|
161
|
-
|
|
221
|
+
const result = await browseNode(session, {
|
|
162
222
|
nodeID: nodeId || ROOT_NODE_ID
|
|
163
223
|
});
|
|
224
|
+
if (result && result.children) {
|
|
225
|
+
result.browse = result.children;
|
|
226
|
+
delete result.children;
|
|
227
|
+
}
|
|
228
|
+
return result;
|
|
164
229
|
}
|
|
230
|
+
|
|
@@ -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>
|
|
@@ -469,7 +469,15 @@ async function getMethodArgumentDefinition(session, methodNodeId, cache) {
|
|
|
469
469
|
}
|
|
470
470
|
|
|
471
471
|
async function enrichItemResultWithEnumeration(result, session, cache, nodeId) {
|
|
472
|
-
|
|
472
|
+
const type = result.type || result.dataType;
|
|
473
|
+
if (!type) {
|
|
474
|
+
return result;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const isStandardEnum = type === "Int32" || type === "Enumeration";
|
|
478
|
+
const isCustomNodeId = typeof type === "string" && (type.includes("i=") || type.includes("ns="));
|
|
479
|
+
|
|
480
|
+
if (!isStandardEnum && !isCustomNodeId) {
|
|
473
481
|
return result;
|
|
474
482
|
}
|
|
475
483
|
|
|
@@ -481,15 +489,29 @@ async function enrichItemResultWithEnumeration(result, session, cache, nodeId) {
|
|
|
481
489
|
const cacheKeyType = "dt:" + nodeId;
|
|
482
490
|
let dtNodeId = cache ? cache.get(cacheKeyType) : undefined;
|
|
483
491
|
if (dtNodeId === undefined) {
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (dtNodeId === undefined) {
|
|
505
|
+
const dv = await session.read({
|
|
506
|
+
nodeId: nodeId,
|
|
507
|
+
attributeId: AttributeIds.DataType
|
|
508
|
+
});
|
|
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
|
+
}
|
|
493
515
|
}
|
|
494
516
|
}
|
|
495
517
|
|
|
@@ -549,6 +571,8 @@ async function enrichItemResultWithEnumeration(result, session, cache, nodeId) {
|
|
|
549
571
|
|
|
550
572
|
if (enumStrings && enumStrings[result.value] !== undefined) {
|
|
551
573
|
result.valueEnumeration = enumStrings[result.value];
|
|
574
|
+
if (result.type) result.type = "Enumeration";
|
|
575
|
+
if (result.dataType) result.dataType = "Enumeration";
|
|
552
576
|
}
|
|
553
577
|
} catch (e) {
|
|
554
578
|
console.error("Error in enrichItemResultWithEnumeration:", e);
|
package/client/opcua-client.html
CHANGED
|
@@ -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>
|
|
@@ -85,6 +86,7 @@
|
|
|
85
86
|
<p><b>Read</b>: reads one or more variable values.</p>
|
|
86
87
|
<p><b>Write</b>: writes one or more variable values.</p>
|
|
87
88
|
<p><b>Browse</b>: browses one or more OPC UA nodes and returns their children.</p>
|
|
89
|
+
<p><b>Browse Recursive</b>: perform a recursive search across all nodes starting from the specified node.</p>
|
|
88
90
|
<p><b>Method</b>: calls one or more OPC UA methods.</p>
|
|
89
91
|
<p><b>Subscription</b>: subscribes to one or more variable values and emits one message per change.</p>
|
|
90
92
|
|
|
@@ -113,6 +115,11 @@
|
|
|
113
115
|
<p>If <code>msg.payload</code> is not an array and <code>NodeId</code> is configured, the node browses that configured node.</p>
|
|
114
116
|
<p>If <code>msg.payload = []</code>, the node browses the OPC UA <code>RootFolder</code>.</p>
|
|
115
117
|
|
|
118
|
+
<h3>Browse Recursive</h3>
|
|
119
|
+
<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>
|
|
120
|
+
<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>
|
|
121
|
+
<p>If <code>msg.payload = []</code>, the node browses recursively starting from the OPC UA <code>RootFolder</code>.</p>
|
|
122
|
+
|
|
116
123
|
<h3>Method</h3>
|
|
117
124
|
<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
125
|
<p><b>Input</b> <code>msg.payload</code>:</p>
|
package/client/opcua-client.js
CHANGED
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
|
|
16
16
|
const {
|
|
17
17
|
browseNode: browseNodeWithSession,
|
|
18
|
+
browseRecursiveNode,
|
|
18
19
|
ROOT_NODE_ID
|
|
19
20
|
} = require("./lib/opcua-client-browser");
|
|
20
21
|
|
|
@@ -92,6 +93,9 @@ module.exports = function (RED) {
|
|
|
92
93
|
} else if (node.mode === "browse") {
|
|
93
94
|
payload = await executeBrowse(node, msg, session);
|
|
94
95
|
node.status({ fill: "green", shape: "dot", text: "browsed " + payload.length + " nodes" });
|
|
96
|
+
} else if (node.mode === "browseRecursive") {
|
|
97
|
+
payload = await executeBrowseRecursive(node, msg, session);
|
|
98
|
+
node.status({ fill: "green", shape: "dot", text: "browsed recursive " + payload.length + " nodes" });
|
|
95
99
|
} else if (node.mode === "method") {
|
|
96
100
|
//payload = await executeMethod(node, msg, session);
|
|
97
101
|
|
|
@@ -109,7 +113,88 @@ module.exports = function (RED) {
|
|
|
109
113
|
done();
|
|
110
114
|
} catch (error) {
|
|
111
115
|
node.status({ fill: "red", shape: "ring", text: node.mode + " failed" });
|
|
112
|
-
|
|
116
|
+
|
|
117
|
+
let errorPayload;
|
|
118
|
+
try {
|
|
119
|
+
if (node.mode === "read") {
|
|
120
|
+
const items = itemsResolver.ensureClientItems(node, msg, "OPC UA read");
|
|
121
|
+
errorPayload = items.map(item => ({
|
|
122
|
+
name: resolveName(item, resolveNodeId(item)),
|
|
123
|
+
nodeID: resolveNodeId(item),
|
|
124
|
+
value: null,
|
|
125
|
+
type: null,
|
|
126
|
+
status: error.message || String(error),
|
|
127
|
+
sourceTimestamp: null,
|
|
128
|
+
serverTimestamp: null
|
|
129
|
+
}));
|
|
130
|
+
} else if (node.mode === "write") {
|
|
131
|
+
const items = itemsResolver.ensureWriteItems(node, msg);
|
|
132
|
+
errorPayload = items.map(item => ({
|
|
133
|
+
name: item.name,
|
|
134
|
+
nodeID: item.nodeID,
|
|
135
|
+
value: item.value,
|
|
136
|
+
type: item.type,
|
|
137
|
+
status: error.message || String(error)
|
|
138
|
+
}));
|
|
139
|
+
} else if (node.mode === "browse" || node.mode === "browseRecursive") {
|
|
140
|
+
const roots = normalizeBrowseRoots(node, msg ? msg.payload : undefined);
|
|
141
|
+
errorPayload = roots.map(root => ({
|
|
142
|
+
name: root.name,
|
|
143
|
+
nodeID: root.nodeID,
|
|
144
|
+
status: error.message || String(error),
|
|
145
|
+
results: [],
|
|
146
|
+
children: []
|
|
147
|
+
}));
|
|
148
|
+
} else if (node.mode === "method") {
|
|
149
|
+
const items = itemsResolver.ensureMethodItems(node, msg);
|
|
150
|
+
errorPayload = items.map(item => ({
|
|
151
|
+
name: item.name,
|
|
152
|
+
nodeID: item.nodeID,
|
|
153
|
+
status: error.message || String(error),
|
|
154
|
+
value: null
|
|
155
|
+
}));
|
|
156
|
+
} else if (node.mode === "subscription" || node.mode === "events") {
|
|
157
|
+
const items = itemsResolver.ensureClientItems(node, msg, "OPC UA subscription");
|
|
158
|
+
errorPayload = items.map(item => ({
|
|
159
|
+
name: resolveName(item, resolveNodeId(item)),
|
|
160
|
+
nodeID: resolveNodeId(item),
|
|
161
|
+
value: null,
|
|
162
|
+
type: null,
|
|
163
|
+
status: error.message || String(error),
|
|
164
|
+
sourceTimestamp: null,
|
|
165
|
+
serverTimestamp: null
|
|
166
|
+
}));
|
|
167
|
+
} else {
|
|
168
|
+
errorPayload = {
|
|
169
|
+
status: "error",
|
|
170
|
+
error: error.message || String(error)
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
} catch (payloadError) {
|
|
174
|
+
errorPayload = {
|
|
175
|
+
status: "error",
|
|
176
|
+
error: error.message || String(error)
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const safeClone = {};
|
|
181
|
+
for (const key in msg) {
|
|
182
|
+
if (msg.hasOwnProperty(key) && key !== "req" && key !== "res" && key !== "payload") {
|
|
183
|
+
safeClone[key] = msg[key];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
safeClone.payload = msg.payload;
|
|
187
|
+
|
|
188
|
+
if (errorPayload && (Array.isArray(errorPayload) || typeof errorPayload === "object")) {
|
|
189
|
+
errorPayload.msg = safeClone;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
msg.payload = errorPayload;
|
|
193
|
+
|
|
194
|
+
node.error(error.message || String(error), msg);
|
|
195
|
+
if (done) {
|
|
196
|
+
done();
|
|
197
|
+
}
|
|
113
198
|
}
|
|
114
199
|
});
|
|
115
200
|
|
|
@@ -134,6 +219,17 @@ module.exports = function (RED) {
|
|
|
134
219
|
return payload;
|
|
135
220
|
}
|
|
136
221
|
|
|
222
|
+
async function executeBrowseRecursive(node, msg, session) {
|
|
223
|
+
const roots = normalizeBrowseRoots(node, msg ? msg.payload : undefined);
|
|
224
|
+
const payload = [];
|
|
225
|
+
|
|
226
|
+
for (const root of roots) {
|
|
227
|
+
payload.push(await browseRecursiveNode(session, root));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return payload;
|
|
231
|
+
}
|
|
232
|
+
|
|
137
233
|
|
|
138
234
|
|
|
139
235
|
|