@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.
- package/README.md +104 -136
- package/client/lib/opcua-client-browser.js +254 -11
- package/client/lib/opcua-client-method-service.js +1 -1
- package/client/lib/opcua-client-subscription-service.js +0 -2
- package/client/lib/opcua-client-write-service.js +14 -4
- package/client/opcua-client-config.html +118 -1
- package/client/opcua-client-config.js +112 -9
- package/client/opcua-client-help.html +6 -0
- package/client/opcua-client-utils.js +158 -10
- package/client/opcua-client.html +8 -0
- package/client/opcua-client.js +97 -1
- package/client/view/opcua-client.js +106 -14
- package/examples/flows_simple_opc.json +1 -1
- package/package.json +2 -2
- package/server/lib/opcua-address-space-alarm.js +95 -32
- package/server/lib/opcua-address-space-builder.js +717 -59
- package/server/lib/opcua-config.js +110 -35
- package/server/lib/opcua-server-events-child.js +31 -5
- package/server/lib/opcua-server-runtime-child.js +424 -27
- package/server/lib/opcua-server-runtime.js +52 -5
- package/server/lib/opcua-server-status-child.js +46 -15
- 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 +93 -1
- package/server/opcua-server-io.js +153 -29
- package/server/opcua-server-registry.js +8 -2
- package/server/opcua-server.css +64 -0
- package/server/opcua-server.html +168 -44
- package/server/opcua-server.js +115 -5
- package/server/view/opcua-server.css +100 -6
- 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:
|
|
57
|
-
keepSessionAlive:
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
};
|
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>
|
|
@@ -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>
|