@jsgorana/node-red-opcua 0.1.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.
@@ -0,0 +1,47 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType("opcua-read", {
3
+ category: "OPC-UA",
4
+ color: "#4a90d9",
5
+ defaults: {
6
+ name: { value: "" },
7
+ endpoint: { value: "", type: "opcua-endpoint", required: true },
8
+ nodeId: { value: "" }
9
+ },
10
+ inputs: 1,
11
+ outputs: 1,
12
+ icon: "font-awesome/fa-download",
13
+ label: function () {
14
+ return this.name || (this.nodeId ? "read " + this.nodeId : "opcua-read");
15
+ }
16
+ });
17
+ </script>
18
+
19
+ <script type="text/html" data-template-name="opcua-read">
20
+ <div class="form-row">
21
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
22
+ <input type="text" id="node-input-name" placeholder="Name">
23
+ </div>
24
+ <div class="form-row">
25
+ <label for="node-input-endpoint"><i class="fa fa-server"></i> Endpoint</label>
26
+ <input type="text" id="node-input-endpoint">
27
+ </div>
28
+ <div class="form-row">
29
+ <label for="node-input-nodeId"><i class="fa fa-hashtag"></i> Node ID</label>
30
+ <input type="text" id="node-input-nodeId" placeholder="ns=1;s=Temperature">
31
+ </div>
32
+ </script>
33
+
34
+ <script type="text/html" data-help-name="opcua-read">
35
+ <p>Reads the Value attribute of an OPC-UA node.</p>
36
+ <h3>Inputs</h3>
37
+ <dl class="message-properties">
38
+ <dt class="optional">nodeId <span class="property-type">string</span></dt>
39
+ <dd>Overrides the configured Node ID for this message.</dd>
40
+ </dl>
41
+ <h3>Outputs</h3>
42
+ <dl class="message-properties">
43
+ <dt>payload</dt><dd>The value read from the server.</dd>
44
+ <dt>statusCode <span class="property-type">string</span></dt><dd>OPC-UA status code.</dd>
45
+ <dt>nodeId <span class="property-type">string</span></dt><dd>The Node ID that was read.</dd>
46
+ </dl>
47
+ </script>
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+
3
+ const { AttributeIds } = require("node-opcua");
4
+
5
+ const STATUS_MAP = {
6
+ connecting: { fill: "yellow", shape: "ring", text: "connecting" },
7
+ connected: { fill: "green", shape: "dot", text: "connected" },
8
+ reconnecting: { fill: "yellow", shape: "ring", text: "reconnecting" },
9
+ error: { fill: "red", shape: "ring", text: "error" },
10
+ closed: { fill: "grey", shape: "ring", text: "closed" }
11
+ };
12
+
13
+ function resolveNodeIds(node, msg) {
14
+ if (Array.isArray(msg.nodeIds)) return msg.nodeIds;
15
+ if (Array.isArray(msg.payload)) return msg.payload;
16
+ return msg.nodeId || node.nodeId;
17
+ }
18
+
19
+ function toReadResult(nodeId, dataValue) {
20
+ return {
21
+ nodeId,
22
+ payload: dataValue.value ? dataValue.value.value : null,
23
+ statusCode: dataValue.statusCode ? dataValue.statusCode.toString() : undefined
24
+ };
25
+ }
26
+
27
+ module.exports = function (RED) {
28
+ function OpcuaReadNode(config) {
29
+ RED.nodes.createNode(this, config);
30
+ const node = this;
31
+ node.endpoint = RED.nodes.getNode(config.endpoint);
32
+ node.nodeId = config.nodeId;
33
+
34
+ if (!node.endpoint) {
35
+ node.status({ fill: "red", shape: "ring", text: "no endpoint" });
36
+ return;
37
+ }
38
+ const manager = node.endpoint.register();
39
+
40
+ const unsubscribeState = manager.onState(function (state) {
41
+ node.status(STATUS_MAP[state] || {});
42
+ });
43
+
44
+ node.on("input", async function (msg, send, done) {
45
+ try {
46
+ const nodeIds = resolveNodeIds(node, msg);
47
+ if (!nodeIds || (Array.isArray(nodeIds) && nodeIds.length === 0)) {
48
+ throw new Error("No nodeId provided");
49
+ }
50
+ const result = await manager.runWithTimeout(async () => {
51
+ const session = await manager.getSession();
52
+ if (Array.isArray(nodeIds)) {
53
+ const nodesToRead = nodeIds.map((nodeId) => ({
54
+ nodeId,
55
+ attributeId: AttributeIds.Value
56
+ }));
57
+ return session.read(nodesToRead);
58
+ }
59
+ return session.read({ nodeId: nodeIds, attributeId: AttributeIds.Value });
60
+ }, "read");
61
+
62
+ if (Array.isArray(nodeIds)) {
63
+ msg.payload = result.map((dataValue, i) => toReadResult(nodeIds[i], dataValue));
64
+ msg.nodeIds = nodeIds;
65
+ msg.statusCodes = msg.payload.map((r) => r.statusCode);
66
+ } else {
67
+ msg.payload = result.value ? result.value.value : null;
68
+ msg.statusCode = result.statusCode ? result.statusCode.toString() : undefined;
69
+ msg.nodeId = nodeIds;
70
+ }
71
+ send(msg);
72
+ done();
73
+ } catch (err) {
74
+ node.status({ fill: "red", shape: "ring", text: "read error" });
75
+ done(err);
76
+ }
77
+ });
78
+
79
+ node.on("close", async function (done) {
80
+ unsubscribeState();
81
+ await node.endpoint.deregister();
82
+ done();
83
+ });
84
+ }
85
+ RED.nodes.registerType("opcua-read", OpcuaReadNode);
86
+ };
@@ -0,0 +1,191 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType("opcua-server", {
3
+ category: "OPC-UA",
4
+ color: "#4a90d9",
5
+ defaults: {
6
+ name: { value: "" },
7
+ port: { value: 4840, required: true, validate: RED.validators.number() },
8
+ resourcePath: { value: "/UA/NodeRED" },
9
+ serverName: { value: "Node-RED OPC-UA Server" },
10
+ folderName: { value: "NodeRED" },
11
+ allowAnonymous: { value: true },
12
+ securityNone: { value: true },
13
+ securitySign: { value: false },
14
+ securitySignEncrypt: { value: false },
15
+ acceptUntrusted: { value: false },
16
+ variables: { value: [] }
17
+ },
18
+ credentials: {
19
+ username: { type: "text" },
20
+ password: { type: "password" }
21
+ },
22
+ inputs: 1,
23
+ outputs: 1,
24
+ icon: "font-awesome/fa-server",
25
+ paletteLabel: "opcua server",
26
+ label: function () {
27
+ return this.name || "opcua-server :" + (this.port || 4840);
28
+ },
29
+ oneditprepare: function () {
30
+ const toggleSecure = function () {
31
+ const secure = $("#node-input-securitySign").is(":checked") ||
32
+ $("#node-input-securitySignEncrypt").is(":checked");
33
+ $(".opcua-server-secure-only").toggle(secure);
34
+ };
35
+ $("#node-input-securitySign, #node-input-securitySignEncrypt").on("change", toggleSecure);
36
+ toggleSecure();
37
+ const list = $("#node-input-variable-list").css("min-height", "180px").editableList({
38
+ addButton: true,
39
+ header: $("<div>").css({ "padding-left": "32px" }).append(
40
+ $.parseHTML(
41
+ "<div style='display:inline-grid;grid-template-columns:120px 110px auto;gap:6px;width:90%'>" +
42
+ "<b>Name</b><b>Data Type</b><b>Initial Value</b></div>"
43
+ )
44
+ ),
45
+ addItem: function (container, i, data) {
46
+ container.css({ overflow: "hidden", whiteSpace: "nowrap" });
47
+ const row = $("<div/>", {
48
+ style: "display:inline-grid;grid-template-columns:120px 110px auto;gap:6px;width:90%"
49
+ }).appendTo(container);
50
+
51
+ const name = $("<input/>", { class: "v-name", type: "text", placeholder: "Temperature" }).appendTo(row);
52
+ const type = $("<select/>", { class: "v-type" }).appendTo(row);
53
+ ["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32", "Float", "Double", "String", "DateTime"]
54
+ .forEach((t) => type.append($("<option/>").val(t).text(t)));
55
+ const val = $("<input/>", { class: "v-value", type: "text", placeholder: "0" }).appendTo(row);
56
+
57
+ name.val(data.name || "");
58
+ type.val(data.dataType || "Double");
59
+ val.val(data.value !== undefined ? data.value : "");
60
+ }
61
+ });
62
+
63
+ (this.variables || []).forEach((v) => list.editableList("addItem", v));
64
+ },
65
+ oneditsave: function () {
66
+ const vars = [];
67
+ $("#node-input-variable-list").editableList("items").each(function () {
68
+ const r = $(this);
69
+ const name = r.find(".v-name").val();
70
+ if (!name) return;
71
+ vars.push({
72
+ name: name,
73
+ dataType: r.find(".v-type").val(),
74
+ value: r.find(".v-value").val()
75
+ });
76
+ });
77
+ this.variables = vars;
78
+ }
79
+ });
80
+ </script>
81
+
82
+ <script type="text/html" data-template-name="opcua-server">
83
+ <div class="form-row">
84
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
85
+ <input type="text" id="node-input-name" placeholder="Name">
86
+ </div>
87
+ <div class="form-row">
88
+ <label for="node-input-port"><i class="fa fa-plug"></i> Port</label>
89
+ <input type="number" id="node-input-port" placeholder="4840" style="width:120px">
90
+ </div>
91
+ <div class="form-row">
92
+ <label for="node-input-resourcePath"><i class="fa fa-link"></i> Path</label>
93
+ <input type="text" id="node-input-resourcePath" placeholder="/UA/NodeRED">
94
+ </div>
95
+ <div class="form-row">
96
+ <label for="node-input-serverName"><i class="fa fa-info-circle"></i> Server</label>
97
+ <input type="text" id="node-input-serverName" placeholder="Node-RED OPC-UA Server">
98
+ </div>
99
+ <div class="form-row">
100
+ <label for="node-input-folderName"><i class="fa fa-folder"></i> Folder</label>
101
+ <input type="text" id="node-input-folderName" placeholder="NodeRED">
102
+ </div>
103
+ <hr>
104
+ <div class="form-row" style="margin-bottom:4px">
105
+ <label><i class="fa fa-lock"></i> Security</label>
106
+ <span style="color:#888">endpoints this server offers</span>
107
+ </div>
108
+ <div class="form-row" style="margin-left:104px">
109
+ <label for="node-input-securityNone" style="width:auto"><input type="checkbox" id="node-input-securityNone" style="width:auto;vertical-align:top"> None <span style="color:#a00">(insecure)</span></label>
110
+ </div>
111
+ <div class="form-row" style="margin-left:104px">
112
+ <label for="node-input-securitySign" style="width:auto"><input type="checkbox" id="node-input-securitySign" style="width:auto;vertical-align:top"> Sign (Basic256Sha256)</label>
113
+ </div>
114
+ <div class="form-row" style="margin-left:104px">
115
+ <label for="node-input-securitySignEncrypt" style="width:auto"><input type="checkbox" id="node-input-securitySignEncrypt" style="width:auto;vertical-align:top"> SignAndEncrypt (Basic256Sha256)</label>
116
+ </div>
117
+ <div class="form-row opcua-server-secure-only" style="margin-left:104px">
118
+ <label for="node-input-acceptUntrusted" style="width:auto"><input type="checkbox" id="node-input-acceptUntrusted" style="width:auto;vertical-align:top"> Accept untrusted client certs <span style="color:#a00">(dev only)</span></label>
119
+ </div>
120
+ <div class="form-row" style="margin-bottom:4px">
121
+ <label><i class="fa fa-user"></i> Authentication</label>
122
+ </div>
123
+ <div class="form-row" style="margin-left:104px">
124
+ <label for="node-input-allowAnonymous" style="width:auto"><input type="checkbox" id="node-input-allowAnonymous" style="width:auto;vertical-align:top"> Allow anonymous</label>
125
+ </div>
126
+ <div class="form-row">
127
+ <label for="node-input-username"><i class="fa fa-user"></i> Username</label>
128
+ <input type="text" id="node-input-username" placeholder="(optional) require this user">
129
+ </div>
130
+ <div class="form-row">
131
+ <label for="node-input-password"><i class="fa fa-key"></i> Password</label>
132
+ <input type="password" id="node-input-password">
133
+ </div>
134
+ <hr>
135
+ <div class="form-row" style="margin-bottom:0">
136
+ <label><i class="fa fa-list"></i> Variables</label>
137
+ </div>
138
+ <div class="form-row">
139
+ <ol id="node-input-variable-list"></ol>
140
+ </div>
141
+ </script>
142
+
143
+ <script type="text/html" data-help-name="opcua-server">
144
+ <p>Runs an OPC-UA <b>server</b> that exposes Node-RED data to OPC-UA clients
145
+ (SCADA, MES, other Node-RED instances, etc.). Variables live under
146
+ <code>ns=1;s=&lt;Name&gt;</code> inside the configured folder.</p>
147
+
148
+ <h3>Define variables</h3>
149
+ <p>Add the variables to expose in the node config (Name, Data Type, Initial
150
+ Value). They are created when the server starts.</p>
151
+
152
+ <h3>Inputs</h3>
153
+ <dl class="message-properties">
154
+ <dt>topic <span class="property-type">string</span></dt>
155
+ <dd>The variable name to update (e.g. <code>Temperature</code>).</dd>
156
+ <dt>payload</dt>
157
+ <dd>The new value clients will read / be notified of.</dd>
158
+ <dt class="optional">dataType <span class="property-type">string</span></dt>
159
+ <dd>Used only when creating a brand-new variable on the fly (default Double).</dd>
160
+ </dl>
161
+
162
+ <h3>Outputs</h3>
163
+ <p>A message is emitted whenever a <b>client writes</b> one of the variables:</p>
164
+ <dl class="message-properties">
165
+ <dt>topic <span class="property-type">string</span></dt><dd>The variable name.</dd>
166
+ <dt>payload</dt><dd>The value the client wrote.</dd>
167
+ <dt>nodeId <span class="property-type">string</span></dt><dd>The variable Node ID.</dd>
168
+ <dt>source <span class="property-type">string</span></dt><dd>Always <code>"client"</code>.</dd>
169
+ </dl>
170
+
171
+ <h3>Connecting a client</h3>
172
+ <p>Point an <code>opcua-endpoint</code> at
173
+ <code>opc.tcp://&lt;host&gt;:&lt;port&gt;&lt;path&gt;</code>, e.g.
174
+ <code>opc.tcp://localhost:4840/UA/NodeRED</code>.</p>
175
+
176
+ <h3>Security &amp; authentication</h3>
177
+ <p><b>Security</b> selects which endpoints the server offers:</p>
178
+ <ul>
179
+ <li><b>None</b> — unencrypted. Convenient for local testing; insecure for production.</li>
180
+ <li><b>Sign</b> / <b>SignAndEncrypt</b> — Basic256Sha256. The server uses a
181
+ certificate under <code>&lt;userDir&gt;/opcua-pki/server</code>. Connecting clients
182
+ must be trusted: an unknown client certificate is rejected into
183
+ <code>opcua-pki/server/rejected</code> — move it to
184
+ <code>opcua-pki/server/trusted/certs</code>, or tick <b>Accept untrusted client
185
+ certs</b> for development only.</li>
186
+ </ul>
187
+ <p><b>Authentication</b>: leave <b>Allow anonymous</b> on for open access, and/or set a
188
+ <b>Username/Password</b> to require credentials (stored encrypted by Node-RED). Disabling
189
+ anonymous with no username configured blocks all clients. For production, require
190
+ SignAndEncrypt + username/password and disable None + anonymous.</p>
191
+ </script>
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const { ServerManager } = require("./lib/server-manager.js");
5
+
6
+ const STATUS_MAP = {
7
+ starting: { fill: "yellow", shape: "ring", text: "starting" },
8
+ running: { fill: "green", shape: "dot", text: "running" },
9
+ error: { fill: "red", shape: "ring", text: "error" },
10
+ stopped: { fill: "grey", shape: "ring", text: "stopped" }
11
+ };
12
+
13
+ module.exports = function (RED) {
14
+ /**
15
+ * Runs an OPC-UA server that exposes Node-RED data.
16
+ *
17
+ * Input: msg.topic = variable name, msg.payload = new value
18
+ * (optional msg.dataType when creating a new variable on the fly).
19
+ * Output: emitted when a CLIENT writes a variable:
20
+ * { topic, payload, nodeId, source: "client" }.
21
+ */
22
+ function OpcuaServerNode(config) {
23
+ RED.nodes.createNode(this, config);
24
+ const node = this;
25
+
26
+ const variables = (config.variables || []).map((v) => ({
27
+ name: v.name,
28
+ dataType: v.dataType || "Double",
29
+ value: v.value
30
+ }));
31
+
32
+ // Which security modes to offer (None defaults on for backward compatibility).
33
+ const securityModes = [];
34
+ if (config.securityNone !== false) securityModes.push("None");
35
+ if (config.securitySign) securityModes.push("Sign");
36
+ if (config.securitySignEncrypt) securityModes.push("SignAndEncrypt");
37
+
38
+ // Single user credentials are stored encrypted by Node-RED.
39
+ const username = node.credentials ? node.credentials.username : undefined;
40
+ const password = node.credentials ? node.credentials.password : undefined;
41
+
42
+ const userDir = (RED.settings && RED.settings.userDir) || process.cwd();
43
+
44
+ node.manager = new ServerManager({
45
+ port: parseInt(config.port, 10) || 4840,
46
+ resourcePath: config.resourcePath || "/UA/NodeRED",
47
+ serverName: config.serverName || "Node-RED OPC-UA Server",
48
+ allowAnonymous: config.allowAnonymous !== false,
49
+ username: username,
50
+ password: password,
51
+ securityModes: securityModes,
52
+ pkiFolder: path.join(userDir, "opcua-pki"),
53
+ acceptUntrusted: config.acceptUntrusted === true,
54
+ folderName: config.folderName || "NodeRED",
55
+ variables: variables,
56
+ onClientWrite: (name, value, nodeId) => {
57
+ node.send({ topic: name, payload: value, nodeId: nodeId, source: "client" });
58
+ }
59
+ });
60
+
61
+ // Surface best-practice security advisories in the runtime log.
62
+ node.manager.serverSecurityWarnings().forEach((w) => node.warn(w));
63
+
64
+ node.manager.onState((state, info) => {
65
+ if (state === "running") {
66
+ node.status({ fill: "green", shape: "dot", text: "running :" + node.manager.port });
67
+ } else if (state === "error") {
68
+ node.status({ fill: "red", shape: "ring", text: (info && info.message) ? info.message : "error" });
69
+ } else {
70
+ node.status(STATUS_MAP[state] || {});
71
+ }
72
+ });
73
+
74
+ node.manager.start().catch((err) => {
75
+ node.error("OPC-UA server failed to start: " + err.message);
76
+ });
77
+
78
+ node.on("input", function (msg, send, done) {
79
+ try {
80
+ const name = msg.topic;
81
+ if (!name) throw new Error("msg.topic must be the variable name");
82
+ node.manager.setValue(name, msg.payload, msg.dataType);
83
+ done();
84
+ } catch (err) {
85
+ done(err);
86
+ }
87
+ });
88
+
89
+ node.on("close", async function (done) {
90
+ try {
91
+ await node.manager.stop();
92
+ } catch (err) {
93
+ node.error("OPC-UA server stop error: " + err.message);
94
+ }
95
+ done();
96
+ });
97
+ }
98
+
99
+ RED.nodes.registerType("opcua-server", OpcuaServerNode, {
100
+ credentials: {
101
+ username: { type: "text" },
102
+ password: { type: "password" }
103
+ }
104
+ });
105
+ };
@@ -0,0 +1,84 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType("opcua-subscribe", {
3
+ category: "OPC-UA",
4
+ color: "#4a90d9",
5
+ defaults: {
6
+ name: { value: "" },
7
+ endpoint: { value: "", type: "opcua-endpoint", required: true },
8
+ nodeId: { value: "", required: true },
9
+ samplingInterval: { value: 1000, validate: RED.validators.number() },
10
+ publishingInterval: { value: 1000, validate: RED.validators.number() },
11
+ queueSize: { value: 10, validate: RED.validators.number() },
12
+ trigger: { value: "StatusValue" },
13
+ deadbandType: { value: "None" },
14
+ deadbandValue: { value: 0, validate: RED.validators.number() }
15
+ },
16
+ inputs: 0,
17
+ outputs: 1,
18
+ icon: "font-awesome/fa-bell",
19
+ label: function () {
20
+ return this.name || (this.nodeId ? "subscribe " + this.nodeId : "opcua-subscribe");
21
+ }
22
+ });
23
+ </script>
24
+
25
+ <script type="text/html" data-template-name="opcua-subscribe">
26
+ <div class="form-row">
27
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
28
+ <input type="text" id="node-input-name" placeholder="Name">
29
+ </div>
30
+ <div class="form-row">
31
+ <label for="node-input-endpoint"><i class="fa fa-server"></i> Endpoint</label>
32
+ <input type="text" id="node-input-endpoint">
33
+ </div>
34
+ <div class="form-row">
35
+ <label for="node-input-nodeId"><i class="fa fa-hashtag"></i> Node ID</label>
36
+ <input type="text" id="node-input-nodeId" placeholder="ns=1;s=Temperature">
37
+ </div>
38
+ <div class="form-row">
39
+ <label for="node-input-samplingInterval"><i class="fa fa-clock-o"></i> Sampling (ms)</label>
40
+ <input type="number" id="node-input-samplingInterval" placeholder="1000">
41
+ </div>
42
+ <div class="form-row">
43
+ <label for="node-input-publishingInterval"><i class="fa fa-clock-o"></i> Publishing (ms)</label>
44
+ <input type="number" id="node-input-publishingInterval" placeholder="1000">
45
+ </div>
46
+ <div class="form-row">
47
+ <label for="node-input-queueSize"><i class="fa fa-list-ol"></i> Queue Size</label>
48
+ <input type="number" id="node-input-queueSize" placeholder="10">
49
+ </div>
50
+ <div class="form-row">
51
+ <label for="node-input-trigger"><i class="fa fa-bolt"></i> Trigger</label>
52
+ <select id="node-input-trigger">
53
+ <option value="Status">Status</option>
54
+ <option value="StatusValue">Status + value</option>
55
+ <option value="StatusValueTimestamp">Status + value + timestamp</option>
56
+ </select>
57
+ </div>
58
+ <div class="form-row">
59
+ <label for="node-input-deadbandType"><i class="fa fa-filter"></i> Deadband</label>
60
+ <select id="node-input-deadbandType">
61
+ <option value="None">None</option>
62
+ <option value="Absolute">Absolute</option>
63
+ <option value="Percent">Percent</option>
64
+ </select>
65
+ </div>
66
+ <div class="form-row">
67
+ <label for="node-input-deadbandValue"><i class="fa fa-sliders"></i> Deadband Value</label>
68
+ <input type="number" id="node-input-deadbandValue" placeholder="0">
69
+ </div>
70
+ </script>
71
+
72
+ <script type="text/html" data-help-name="opcua-subscribe">
73
+ <p>Subscribes to value changes of an OPC-UA node and emits a message on each change.
74
+ Requires no input — it arms automatically when the endpoint connects and re-arms
75
+ after a reconnect. Subscribe nodes sharing the same endpoint use one OPC-UA
76
+ subscription with separate monitored items.</p>
77
+ <h3>Outputs</h3>
78
+ <dl class="message-properties">
79
+ <dt>payload</dt><dd>The changed value.</dd>
80
+ <dt>nodeId <span class="property-type">string</span></dt><dd>The monitored Node ID.</dd>
81
+ <dt>statusCode <span class="property-type">string</span></dt><dd>OPC-UA status code.</dd>
82
+ <dt>sourceTimestamp <span class="property-type">date</span></dt><dd>Server source timestamp.</dd>
83
+ </dl>
84
+ </script>
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+
3
+ const {
4
+ DataChangeFilter,
5
+ DataChangeTrigger,
6
+ DeadbandType
7
+ } = require("node-opcua");
8
+
9
+ const STATUS_MAP = {
10
+ connecting: { fill: "yellow", shape: "ring", text: "connecting" },
11
+ connected: { fill: "green", shape: "dot", text: "connected" },
12
+ reconnecting: { fill: "yellow", shape: "ring", text: "reconnecting" },
13
+ error: { fill: "red", shape: "ring", text: "error" },
14
+ closed: { fill: "grey", shape: "ring", text: "closed" }
15
+ };
16
+
17
+ function positiveInt(value, fallback) {
18
+ const n = parseInt(value, 10);
19
+ return (Number.isFinite(n) && n > 0) ? n : fallback;
20
+ }
21
+
22
+ function nonNegativeNumber(value, fallback) {
23
+ const n = parseFloat(value);
24
+ return (Number.isFinite(n) && n >= 0) ? n : fallback;
25
+ }
26
+
27
+ function buildDataChangeFilter(node) {
28
+ if (!node.deadbandType || node.deadbandType === "None") return null;
29
+ const deadbandType = DeadbandType[node.deadbandType];
30
+ if (deadbandType === undefined) return null;
31
+ return new DataChangeFilter({
32
+ trigger: DataChangeTrigger[node.trigger] || DataChangeTrigger.StatusValue,
33
+ deadbandType,
34
+ deadbandValue: node.deadbandValue
35
+ });
36
+ }
37
+
38
+ module.exports = function (RED) {
39
+ function OpcuaSubscribeNode(config) {
40
+ RED.nodes.createNode(this, config);
41
+ const node = this;
42
+ node.endpoint = RED.nodes.getNode(config.endpoint);
43
+ node.nodeId = config.nodeId;
44
+ node.samplingInterval = positiveInt(config.samplingInterval, 1000);
45
+ node.publishingInterval = positiveInt(config.publishingInterval, 1000);
46
+ node.queueSize = positiveInt(config.queueSize, 10);
47
+ node.deadbandType = config.deadbandType || "None";
48
+ node.deadbandValue = nonNegativeNumber(config.deadbandValue, 0);
49
+ node.trigger = config.trigger || "StatusValue";
50
+
51
+ if (!node.endpoint) {
52
+ node.status({ fill: "red", shape: "ring", text: "no endpoint" });
53
+ return;
54
+ }
55
+ if (!node.nodeId) {
56
+ node.status({ fill: "red", shape: "ring", text: "no nodeId" });
57
+ return;
58
+ }
59
+
60
+ const manager = node.endpoint.register();
61
+ let monitoredItem = null;
62
+ let starting = false;
63
+
64
+ async function teardownMonitoring() {
65
+ const item = monitoredItem;
66
+ monitoredItem = null;
67
+ if (item) {
68
+ await item.terminate();
69
+ }
70
+ }
71
+
72
+ async function startMonitoring() {
73
+ if (starting || monitoredItem) return;
74
+ starting = true;
75
+ try {
76
+ monitoredItem = await manager.monitorValue({
77
+ nodeId: node.nodeId,
78
+ samplingInterval: node.samplingInterval,
79
+ publishingInterval: node.publishingInterval,
80
+ queueSize: node.queueSize,
81
+ filter: buildDataChangeFilter(node),
82
+ onChanged: (dataValue) => {
83
+ node.send({
84
+ payload: dataValue.value ? dataValue.value.value : null,
85
+ nodeId: node.nodeId,
86
+ statusCode: dataValue.statusCode ? dataValue.statusCode.toString() : undefined,
87
+ sourceTimestamp: dataValue.sourceTimestamp
88
+ });
89
+ },
90
+ onError: (err) => {
91
+ node.error("OPC-UA monitored item error: " + err.message);
92
+ }
93
+ });
94
+ } catch (err) {
95
+ node.error("OPC-UA subscribe failed: " + err.message);
96
+ await teardownMonitoring();
97
+ } finally {
98
+ starting = false;
99
+ }
100
+ }
101
+
102
+ const unsubscribeState = manager.onState(async function (state) {
103
+ node.status(STATUS_MAP[state] || {});
104
+ if (state === "connected") {
105
+ await startMonitoring();
106
+ } else if (state === "reconnecting" || state === "closed" || state === "error") {
107
+ await teardownMonitoring();
108
+ }
109
+ });
110
+
111
+ // Kick off a connection so the subscription arms without needing an input.
112
+ manager.connect().catch((err) => {
113
+ node.error("OPC-UA connect failed: " + err.message);
114
+ });
115
+
116
+ node.on("close", async function (done) {
117
+ unsubscribeState();
118
+ await teardownMonitoring();
119
+ await node.endpoint.deregister();
120
+ done();
121
+ });
122
+ }
123
+ RED.nodes.registerType("opcua-subscribe", OpcuaSubscribeNode);
124
+ };