@tier0/opcua-client 0.2.0 → 0.4.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.
- package/README.md +52 -3
- package/lib/connection-registry.js +68 -0
- package/lib/security-file-store.js +119 -0
- package/nodes/browse.html +27 -2
- package/nodes/connection.html +132 -4
- package/nodes/connection.js +89 -5
- package/nodes/read.html +40 -4
- package/nodes/read.js +48 -11
- package/nodes/subscribe.html +50 -4
- package/nodes/subscribe.js +80 -16
- package/nodes/write.html +27 -3
- package/nodes/write.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,6 +12,10 @@ High-scale OPC UA client nodes for Node-RED, backed by open62541 and designed fo
|
|
|
12
12
|
|
|
13
13
|
The OPC UA server remains a separate service. This package contains client and collection nodes only.
|
|
14
14
|
|
|
15
|
+
Every node includes bilingual Chinese and English guidance in the Node-RED Help
|
|
16
|
+
sidebar, covering its purpose, message contract, key settings, and operational
|
|
17
|
+
notes for industrial deployments.
|
|
18
|
+
|
|
15
19
|
## Point contract
|
|
16
20
|
|
|
17
21
|
Browse output can be wired directly into Read or Subscribe. In the Read and Write editors, manually added and imported points appear in one editable list. Import accepts JSON/CSV, merges by NodeId, and immediately shows the parsed rows. Both nodes also accept arrays, JSON strings/buffers, or CSV strings/buffers dynamically:
|
|
@@ -35,6 +39,14 @@ Browse output can be wired directly into Read or Subscribe. In the Read and Writ
|
|
|
35
39
|
|
|
36
40
|
Subscription commands are `replace`, `add`, `remove`, and `clear`. Read and Write split large arrays into configurable service batches; they do not loop over scalar OPC UA requests.
|
|
37
41
|
|
|
42
|
+
New Read nodes default to compact results: `nodeId`, optional `alias`, `value`,
|
|
43
|
+
`statusCode`, and `sourceTimestamp`. Browse metadata and subscription-only point
|
|
44
|
+
settings are not repeated on every Read. **Value only** emits an array in point-
|
|
45
|
+
list order for batch output and a scalar for single/split output. **Full
|
|
46
|
+
metadata** preserves the original descriptor-rich output and includes client
|
|
47
|
+
diagnostics. Flow JSON created before this option existed continues to use Full
|
|
48
|
+
metadata until the node is edited and a result detail is selected.
|
|
49
|
+
|
|
38
50
|
Subscription notifications use a bounded native C++ queue and cross into JavaScript in batches. In Node-RED batch mode, changes from every server Subscription are merged and emitted when 500 values are ready or the default 50 ms flush window expires. Queue options are:
|
|
39
51
|
|
|
40
52
|
- `callbackBatchSize`: maximum changes per native callback and Node-RED output message.
|
|
@@ -46,6 +58,21 @@ Subscription notifications use a bounded native C++ queue and cross into JavaScr
|
|
|
46
58
|
|
|
47
59
|
`timestampMode` defaults to `source`, avoiding an unnecessary server timestamp on every high-rate notification. Advanced settings can request `both`, `server`, or `neither`. Notification outputs contain the changing value/status/timestamp plus useful custom point metadata such as `alias`; monitoring configuration that is constant for the lifetime of the item is not repeated on every change.
|
|
48
60
|
|
|
61
|
+
New Subscribe nodes default to compact notifications: `nodeId`, optional
|
|
62
|
+
`alias`, `value`, `statusCode`, and `sourceTimestamp`. Browse metadata, native
|
|
63
|
+
MonitoredItem handles, and sharding details are omitted. In batch **Value only**
|
|
64
|
+
mode, `msg.payload` is a value array and `msg.opcua.identifiers` is the matching
|
|
65
|
+
NodeId/alias array. Parallel arrays preserve order and repeated changes from the
|
|
66
|
+
same point. **Full metadata** preserves the original output for diagnostics and
|
|
67
|
+
Flow JSON created before this option existed continues to use it.
|
|
68
|
+
|
|
69
|
+
New Subscribe nodes also default to server queue size `1`, `discardOldest=true`,
|
|
70
|
+
and native queue mode `latest`. This keeps real-time state current during
|
|
71
|
+
backpressure instead of replaying stale values. Set queue mode to `all` and
|
|
72
|
+
increase the server queue size for pulses, alarms, counters, or history capture
|
|
73
|
+
where every intermediate transition must be retained. Existing Flow JSON keeps
|
|
74
|
+
its saved queue settings; runtime fallbacks remain compatible with older nodes.
|
|
75
|
+
|
|
49
76
|
Large point sets are automatically split across server Subscriptions. `maxItemsPerSubscription` defaults to 5,000 and acts as the fallback safety limit. When the server advertises `MaxMonitoredItemsPerSubscription`, the node uses the smaller limit. Status messages expose `subscriptionCount`, `subscriptionIds`, `desired`, `monitored`, and `failed`.
|
|
50
77
|
|
|
51
78
|
Every batch includes queue diagnostics in `msg.opcua.queue`, including depth, capacity, maximum depth, enqueued, delivered, dropped, coalesced, batch sequence, and maximum queue lag. Queue overflow is also reported through the node status and warning log; it is never silent.
|
|
@@ -64,6 +91,22 @@ The native command queue defaults to 256 pending commands and is configurable as
|
|
|
64
91
|
|
|
65
92
|
`client.getDiagnostics().commandQueue` reports `depth`, `capacity`, `maxDepth`, `active`, `enqueued`, `completed`, `rejected`, and `timedOut`.
|
|
66
93
|
|
|
94
|
+
## Session ownership and duplicate-connection governance
|
|
95
|
+
|
|
96
|
+
One `tier0-opcua-connection` configuration node owns one native client and one
|
|
97
|
+
OPC UA Session. Read, Write, Browse, and Subscribe nodes that reference that
|
|
98
|
+
same configuration reuse the Session, including when the configuration is
|
|
99
|
+
shared across Node-RED flow tabs. Different Connection configuration nodes are
|
|
100
|
+
never merged implicitly because separate Sessions may be required for isolation.
|
|
101
|
+
|
|
102
|
+
When equivalent Connection configurations target the same endpoint, security
|
|
103
|
+
mode/policy, and user identity, the runtime emits a warning that an additional
|
|
104
|
+
Session will be created. Reuse the existing Connection configuration to avoid
|
|
105
|
+
duplicate Server load. Enable **Allow parallel Session** only when the extra
|
|
106
|
+
Session is intentional. Read/Write diagnostics and subscription status outputs
|
|
107
|
+
include `connectionGovernance`, with the equivalent and active configuration
|
|
108
|
+
counts.
|
|
109
|
+
|
|
67
110
|
## Connection and subscription recovery
|
|
68
111
|
|
|
69
112
|
The native client reports secure-channel, session, connectivity-check, and subscription-inactivity transitions to a JavaScript connection supervisor. Recoverable channel interruptions keep the native client alive. Terminal failures replace the native client and retry with exponential backoff, 20% jitter, and the configured maximum interval. Operations fail fast with `OPCUA_RECONNECTING` while the connection is unavailable instead of building an unbounded request queue.
|
|
@@ -129,8 +172,14 @@ uses the selected secure endpoint and its advertised UserTokenPolicy.
|
|
|
129
172
|
|
|
130
173
|
The Connection editor can call `GetEndpoints`, filters the response to the
|
|
131
174
|
implemented policies, and selects an endpoint compatible with Anonymous or
|
|
132
|
-
Username authentication.
|
|
133
|
-
|
|
175
|
+
Username authentication. Client certificates, private keys, trusted Server/CA
|
|
176
|
+
certificates, and CRLs can be uploaded from the editor. Uploaded files are kept
|
|
177
|
+
under `<Node-RED userDir>/opcua-pki/<connection>/` with owner-only file
|
|
178
|
+
permissions and the managed runtime path is stored in the Connection
|
|
179
|
+
configuration. Existing and advanced deployments can still enter filesystem
|
|
180
|
+
paths visible to the Node-RED runtime directly. Uploaded private-key bytes are
|
|
181
|
+
never stored in the Flow JSON, and removing the Connection node removes its
|
|
182
|
+
managed security-file directory.
|
|
134
183
|
|
|
135
184
|
## Offline installation
|
|
136
185
|
|
|
@@ -141,7 +190,7 @@ Node-RED Alpine image, the pinned open62541 v1.4.16 source archive, and its
|
|
|
141
190
|
not contact npm or GitHub and does not invoke a compiler:
|
|
142
191
|
|
|
143
192
|
```bash
|
|
144
|
-
npm install ./tier0-opcua-client-0.1.
|
|
193
|
+
npm install ./tier0-opcua-client-0.4.1.tgz --offline
|
|
145
194
|
```
|
|
146
195
|
|
|
147
196
|
The loader uses the bundled native addon directly. Set
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
function canonicalize(value) {
|
|
6
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
7
|
+
if (value && typeof value === 'object') {
|
|
8
|
+
return Object.fromEntries(
|
|
9
|
+
Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function connectionSignature(options) {
|
|
16
|
+
const source = options || {};
|
|
17
|
+
const identity = {
|
|
18
|
+
endpoint: String(source.endpoint || '').trim(),
|
|
19
|
+
securityMode: String(source.securityMode || 'None'),
|
|
20
|
+
securityPolicy: String(source.securityPolicy || 'None'),
|
|
21
|
+
user: String(source.user || '').trim(),
|
|
22
|
+
};
|
|
23
|
+
const serialized = JSON.stringify(canonicalize(identity));
|
|
24
|
+
return crypto.createHash('sha256').update(serialized).digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class ConnectionRegistry {
|
|
28
|
+
constructor() {
|
|
29
|
+
this.entries = new Map();
|
|
30
|
+
this.configurationCount = 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
register(options) {
|
|
34
|
+
const signature = connectionSignature(options);
|
|
35
|
+
let records = this.entries.get(signature);
|
|
36
|
+
if (!records) {
|
|
37
|
+
records = new Set();
|
|
38
|
+
this.entries.set(signature, records);
|
|
39
|
+
}
|
|
40
|
+
const record = {};
|
|
41
|
+
records.add(record);
|
|
42
|
+
this.configurationCount += 1;
|
|
43
|
+
let unregistered = false;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
duplicate: records.size > 1,
|
|
47
|
+
diagnostics: () => ({
|
|
48
|
+
equivalentConfigurations: records.size,
|
|
49
|
+
duplicateConfiguration: records.size > 1,
|
|
50
|
+
activeConfigurations: this.configurationCount,
|
|
51
|
+
}),
|
|
52
|
+
unregister: () => {
|
|
53
|
+
if (unregistered) return false;
|
|
54
|
+
unregistered = true;
|
|
55
|
+
records.delete(record);
|
|
56
|
+
this.configurationCount = Math.max(0, this.configurationCount - 1);
|
|
57
|
+
if (records.size === 0) this.entries.delete(signature);
|
|
58
|
+
return true;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get size() {
|
|
64
|
+
return this.configurationCount;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { ConnectionRegistry, connectionSignature };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs/promises');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const MAX_SECURITY_FILE_SIZE = 2 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
const FILE_KINDS = Object.freeze({
|
|
10
|
+
clientCertificate: {
|
|
11
|
+
prefix: 'client-certificate',
|
|
12
|
+
extensions: new Set(['.der', '.pem', '.crt', '.cer']),
|
|
13
|
+
},
|
|
14
|
+
privateKey: {
|
|
15
|
+
prefix: 'client-private-key',
|
|
16
|
+
extensions: new Set(['.der', '.pem', '.key']),
|
|
17
|
+
},
|
|
18
|
+
trustedCertificate: {
|
|
19
|
+
prefix: 'trusted-certificate',
|
|
20
|
+
extensions: new Set(['.der', '.pem', '.crt', '.cer']),
|
|
21
|
+
},
|
|
22
|
+
revocationList: {
|
|
23
|
+
prefix: 'revocation-list',
|
|
24
|
+
extensions: new Set(['.der', '.pem', '.crl']),
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function storageError(message, code = 'OPCUA_SECURITY_FILE_INVALID') {
|
|
29
|
+
const error = new Error(message);
|
|
30
|
+
error.code = code;
|
|
31
|
+
return error;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function decodeBase64(value) {
|
|
35
|
+
const encoded = String(value || '').trim();
|
|
36
|
+
if (!encoded || encoded.length % 4 !== 0 ||
|
|
37
|
+
!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) {
|
|
38
|
+
throw storageError('Security file content must be valid base64');
|
|
39
|
+
}
|
|
40
|
+
const decoded = Buffer.from(encoded, 'base64');
|
|
41
|
+
if (!decoded.length || decoded.toString('base64') !== encoded) {
|
|
42
|
+
throw storageError('Security file content must be valid base64');
|
|
43
|
+
}
|
|
44
|
+
return decoded;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function connectionDirectoryName(connectionId) {
|
|
48
|
+
const value = String(connectionId || '').trim();
|
|
49
|
+
if (!value || value.length > 256) {
|
|
50
|
+
throw storageError('A valid Connection node id is required');
|
|
51
|
+
}
|
|
52
|
+
return crypto.createHash('sha256').update(value).digest('hex').slice(0, 32);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class SecurityFileStore {
|
|
56
|
+
constructor(options = {}) {
|
|
57
|
+
const userDir = path.resolve(String(options.userDir || process.cwd()));
|
|
58
|
+
this.root = path.join(userDir, 'opcua-pki');
|
|
59
|
+
this.maxFileSize = Number(options.maxFileSize) || MAX_SECURITY_FILE_SIZE;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
connectionDirectory(connectionId) {
|
|
63
|
+
return path.join(this.root, connectionDirectoryName(connectionId));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async save({ connectionId, kind, fileName, contentBase64 }) {
|
|
67
|
+
const definition = FILE_KINDS[kind];
|
|
68
|
+
if (!definition) throw storageError(`Unsupported OPC UA security file kind: ${kind}`);
|
|
69
|
+
|
|
70
|
+
const extension = path.extname(String(fileName || '')).toLowerCase();
|
|
71
|
+
if (!definition.extensions.has(extension)) {
|
|
72
|
+
throw storageError(
|
|
73
|
+
`Unsupported ${kind} file extension; allowed: ${[...definition.extensions].join(', ')}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const content = decodeBase64(contentBase64);
|
|
78
|
+
if (content.length > this.maxFileSize) {
|
|
79
|
+
throw storageError(
|
|
80
|
+
`Security file exceeds the ${this.maxFileSize} byte limit`,
|
|
81
|
+
'OPCUA_SECURITY_FILE_TOO_LARGE'
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const directory = this.connectionDirectory(connectionId);
|
|
86
|
+
const sha256 = crypto.createHash('sha256').update(content).digest('hex');
|
|
87
|
+
const managedName = `${definition.prefix}-${sha256.slice(0, 24)}${extension}`;
|
|
88
|
+
const managedPath = path.join(directory, managedName);
|
|
89
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
90
|
+
try {
|
|
91
|
+
await fs.writeFile(managedPath, content, { flag: 'wx', mode: 0o600 });
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error.code !== 'EEXIST') throw error;
|
|
94
|
+
}
|
|
95
|
+
await fs.chmod(managedPath, 0o600);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
kind,
|
|
99
|
+
name: path.basename(String(fileName || '')),
|
|
100
|
+
path: managedPath,
|
|
101
|
+
size: content.length,
|
|
102
|
+
sha256,
|
|
103
|
+
managed: true,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async removeConnection(connectionId) {
|
|
108
|
+
const directory = this.connectionDirectory(connectionId);
|
|
109
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
FILE_KINDS,
|
|
115
|
+
MAX_SECURITY_FILE_SIZE,
|
|
116
|
+
SecurityFileStore,
|
|
117
|
+
connectionDirectoryName,
|
|
118
|
+
decodeBase64,
|
|
119
|
+
};
|
package/nodes/browse.html
CHANGED
|
@@ -43,8 +43,33 @@
|
|
|
43
43
|
<div class="form-row"><label for="node-input-name">Name</label><input type="text" id="node-input-name"></div>
|
|
44
44
|
</script>
|
|
45
45
|
<script type="text/html" data-help-name="tier0-opcua-browse">
|
|
46
|
-
<
|
|
47
|
-
|
|
46
|
+
<section lang="zh-CN">
|
|
47
|
+
<h3>浏览(Browse)</h3>
|
|
48
|
+
<p>浏览 OPC UA 地址空间并选择变量点位。Browse 返回节点描述信息,不读取实时值。</p>
|
|
49
|
+
<h4>配置与输入</h4>
|
|
50
|
+
<ul>
|
|
51
|
+
<li>在编辑器中加载树并勾选变量;勾选父节点会选择其下所有变量。</li>
|
|
52
|
+
<li>收到任意输入消息后,默认输出已经保存的选择。</li>
|
|
53
|
+
<li>设置 <code>msg.opcua.liveBrowse=true</code> 可忽略保存的选择并实时浏览。</li>
|
|
54
|
+
<li><code>msg.nodeId</code> 或 <code>msg.topic</code> 可覆盖根 NodeId;默认根节点为 ObjectsFolder。</li>
|
|
55
|
+
</ul>
|
|
56
|
+
<h4>输出</h4>
|
|
57
|
+
<p><code>msg.payload</code> 是点位描述数组,包含 NodeId、名称、Browse 路径、数据类型和访问属性,可直接连接 Read 或 Subscribe。Paged 模式会分批输出,并通过 <code>msg.opcua.command</code> 标记 replace/add。</p>
|
|
58
|
+
</section>
|
|
59
|
+
<hr>
|
|
60
|
+
<section lang="en">
|
|
61
|
+
<h3>Browse</h3>
|
|
62
|
+
<p>Browses the OPC UA address space and selects Variable nodes. Browse returns node descriptors; it does not read live values.</p>
|
|
63
|
+
<h4>Configuration and input</h4>
|
|
64
|
+
<ul>
|
|
65
|
+
<li>Load the tree in the editor and check Variables. Checking a parent selects all Variables below it.</li>
|
|
66
|
+
<li>Any input message outputs the saved selection by default.</li>
|
|
67
|
+
<li>Set <code>msg.opcua.liveBrowse=true</code> to ignore the saved selection and perform a live Browse.</li>
|
|
68
|
+
<li><code>msg.nodeId</code> or <code>msg.topic</code> can override the root NodeId; the default root is ObjectsFolder.</li>
|
|
69
|
+
</ul>
|
|
70
|
+
<h4>Output</h4>
|
|
71
|
+
<p><code>msg.payload</code> is an array of point descriptors containing NodeId, names, Browse path, data type, and access attributes. It can feed Read or Subscribe directly. Paged mode emits multiple messages with replace/add in <code>msg.opcua.command</code>.</p>
|
|
72
|
+
</section>
|
|
48
73
|
</script>
|
|
49
74
|
<script type="text/javascript">
|
|
50
75
|
RED.nodes.registerType('tier0-opcua-browse', {
|
package/nodes/connection.html
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
.opcua-standard-note { margin: 0 0 10px 105px; color: var(--red-ui-form-text-color, #666); font-size: 0.9em; }
|
|
5
5
|
.opcua-discovery-result { margin: 6px 0 8px 105px; max-width: 70%; }
|
|
6
6
|
.opcua-discovery-result select { width: 100%; }
|
|
7
|
+
.opcua-security-path { width: calc(70% - 88px) !important; }
|
|
8
|
+
.opcua-security-list { display: inline-block; width: 70%; vertical-align: top; }
|
|
9
|
+
.opcua-security-list textarea { width: 100%; box-sizing: border-box; }
|
|
10
|
+
.opcua-security-upload { margin-top: 4px; }
|
|
11
|
+
.opcua-hidden-file { display: none !important; }
|
|
7
12
|
</style>
|
|
8
13
|
<script type="text/html" data-template-name="tier0-opcua-connection">
|
|
9
14
|
<div class="form-row"><label for="node-config-input-endpoint">Endpoint</label><input type="text" id="node-config-input-endpoint" placeholder="opc.tcp://127.0.0.1:4840"></div>
|
|
@@ -17,12 +22,13 @@
|
|
|
17
22
|
<div class="form-row"><label for="node-config-input-securityPolicy">Policy</label><select id="node-config-input-securityPolicy"><option value="None">None</option><option value="Basic256Sha256">Basic256Sha256</option></select></div>
|
|
18
23
|
<div class="form-row"><label for="node-config-input-user">User</label><input type="text" id="node-config-input-user"></div>
|
|
19
24
|
<div class="form-row"><label for="node-config-input-password">Password</label><input type="password" id="node-config-input-password"></div>
|
|
20
|
-
<div class="form-row opcua-secure-field"><label for="node-config-input-certificate">Client certificate</label><input type="text" id="node-config-input-certificate" placeholder="/path/to/client.der"></div>
|
|
21
|
-
<div class="form-row opcua-secure-field"><label for="node-config-input-privateKey">Client private key</label><input type="text" id="node-config-input-privateKey" placeholder="/path/to/client-key.pem"></div>
|
|
25
|
+
<div class="form-row opcua-secure-field"><label for="node-config-input-certificate">Client certificate</label><input type="text" class="opcua-security-path" id="node-config-input-certificate" placeholder="Upload or enter /path/to/client.der"> <button type="button" class="red-ui-button" id="node-config-upload-certificate"><i class="fa fa-upload"></i> Upload</button><input type="file" class="opcua-hidden-file" id="node-config-file-certificate" accept=".der,.pem,.crt,.cer"></div>
|
|
26
|
+
<div class="form-row opcua-secure-field"><label for="node-config-input-privateKey">Client private key</label><input type="text" class="opcua-security-path" id="node-config-input-privateKey" placeholder="Upload or enter /path/to/client-key.pem"> <button type="button" class="red-ui-button" id="node-config-upload-privateKey"><i class="fa fa-upload"></i> Upload</button><input type="file" class="opcua-hidden-file" id="node-config-file-privateKey" accept=".der,.pem,.key"></div>
|
|
22
27
|
<div class="form-row opcua-secure-field"><label for="node-config-input-privateKeyPassword">Key password</label><input type="password" id="node-config-input-privateKeyPassword"></div>
|
|
23
28
|
<div class="form-row opcua-secure-field"><label for="node-config-input-applicationUri">Application URI</label><input type="text" id="node-config-input-applicationUri" placeholder="Must match the client certificate"></div>
|
|
24
|
-
<div class="form-row opcua-secure-field"><label for="node-config-input-trustedCertificates">Trusted certificates</label><textarea id="node-config-input-trustedCertificates" rows="3" placeholder="
|
|
25
|
-
<div class="form-row opcua-secure-field"><label for="node-config-input-revocationList">Revocation list</label><textarea id="node-config-input-revocationList" rows="2" placeholder="
|
|
29
|
+
<div class="form-row opcua-secure-field"><label for="node-config-input-trustedCertificates">Trusted certificates</label><div class="opcua-security-list"><textarea id="node-config-input-trustedCertificates" rows="3" placeholder="Upload files or enter one Server/CA path per line"></textarea><button type="button" class="red-ui-button opcua-security-upload" id="node-config-upload-trustedCertificates"><i class="fa fa-upload"></i> Upload files</button><input type="file" multiple class="opcua-hidden-file" id="node-config-file-trustedCertificates" accept=".der,.pem,.crt,.cer"></div></div>
|
|
30
|
+
<div class="form-row opcua-secure-field"><label for="node-config-input-revocationList">Revocation list</label><div class="opcua-security-list"><textarea id="node-config-input-revocationList" rows="2" placeholder="Upload files or enter one optional CRL path per line"></textarea><button type="button" class="red-ui-button opcua-security-upload" id="node-config-upload-revocationList"><i class="fa fa-upload"></i> Upload files</button><input type="file" multiple class="opcua-hidden-file" id="node-config-file-revocationList" accept=".der,.pem,.crl"></div></div>
|
|
31
|
+
<div class="opcua-standard-note opcua-secure-field">Uploaded files are stored under the Node-RED user directory. Runtime-visible paths may still be entered directly.</div>
|
|
26
32
|
<div class="form-row"><label for="node-config-input-requestedSessionTimeout">Session timeout</label><input type="number" id="node-config-input-requestedSessionTimeout"></div>
|
|
27
33
|
<div class="form-row"><label for="node-config-input-requestTimeout">Request timeout</label><input type="number" id="node-config-input-requestTimeout"></div>
|
|
28
34
|
<div class="form-row"><label for="node-config-input-connectivityCheckInterval">Connectivity check</label><input type="number" id="node-config-input-connectivityCheckInterval"></div>
|
|
@@ -30,9 +36,42 @@
|
|
|
30
36
|
<div class="form-row"><label for="node-config-input-maxReconnectInterval">Max reconnect</label><input type="number" id="node-config-input-maxReconnectInterval"></div>
|
|
31
37
|
<div class="form-row"><label for="node-config-input-nodeIdCacheSize">NodeId cache</label><input type="number" id="node-config-input-nodeIdCacheSize"></div>
|
|
32
38
|
<div class="form-row"><label for="node-config-input-commandQueueSize">Command queue</label><input type="number" id="node-config-input-commandQueueSize"></div>
|
|
39
|
+
<div class="form-row"><label for="node-config-input-allowParallelSession">Parallel Session</label><input type="checkbox" id="node-config-input-allowParallelSession" style="width:auto"></div>
|
|
40
|
+
<div class="opcua-standard-note">Leave disabled and reuse one Connection configuration for one Session. Enable only when a separate Session with equivalent settings is intentional.</div>
|
|
33
41
|
</details>
|
|
34
42
|
<div class="form-row"><label for="node-config-input-name">Name</label><input type="text" id="node-config-input-name"></div>
|
|
35
43
|
</script>
|
|
44
|
+
<script type="text/html" data-help-name="tier0-opcua-connection">
|
|
45
|
+
<section lang="zh-CN">
|
|
46
|
+
<h3>连接(Connection)</h3>
|
|
47
|
+
<p>创建并维护共享的 OPC UA SecureChannel 和 Session,供 Browse、Read、Write、Subscribe 节点复用。配置节点本身没有消息输入或输出。</p>
|
|
48
|
+
<h4>主要配置</h4>
|
|
49
|
+
<ul>
|
|
50
|
+
<li><b>Endpoint</b>:服务器地址,例如 <code>opc.tcp://192.168.1.10:4840</code>。</li>
|
|
51
|
+
<li><b>Discover endpoints</b>:读取服务器公开的安全模式、安全策略和用户认证方式。</li>
|
|
52
|
+
<li><b>Test connection</b>:使用当前配置建立临时连接并验证认证。</li>
|
|
53
|
+
<li><b>Security</b>:支持 None、Sign、SignAndEncrypt;安全模式、策略和认证方式必须与服务器 Endpoint 匹配。</li>
|
|
54
|
+
<li><b>证书</b>:安全连接可上传或指定客户端证书、私钥、信任证书和 CRL。Application URI 应与客户端证书一致。</li>
|
|
55
|
+
</ul>
|
|
56
|
+
<h4>建议</h4>
|
|
57
|
+
<p>一个 Connection 配置对应一条客户端 Session。多个业务节点应复用同一个 Connection,避免为相同 Endpoint 重复创建 Session。节点内置自动重连、连通性检查以及有界命令队列。</p>
|
|
58
|
+
</section>
|
|
59
|
+
<hr>
|
|
60
|
+
<section lang="en">
|
|
61
|
+
<h3>Connection</h3>
|
|
62
|
+
<p>Creates and maintains a shared OPC UA SecureChannel and Session for Browse, Read, Write, and Subscribe nodes. The configuration node itself has no message input or output.</p>
|
|
63
|
+
<h4>Main settings</h4>
|
|
64
|
+
<ul>
|
|
65
|
+
<li><b>Endpoint</b>: Server URL, for example <code>opc.tcp://192.168.1.10:4840</code>.</li>
|
|
66
|
+
<li><b>Discover endpoints</b>: Reads advertised security modes, policies, and user token types.</li>
|
|
67
|
+
<li><b>Test connection</b>: Opens a temporary connection with the current settings and validates authentication.</li>
|
|
68
|
+
<li><b>Security</b>: Supports None, Sign, and SignAndEncrypt. Mode, policy, and identity must match an advertised endpoint.</li>
|
|
69
|
+
<li><b>Certificates</b>: Secure connections can upload or reference a client certificate, private key, trust list, and CRLs. The Application URI should match the client certificate.</li>
|
|
70
|
+
</ul>
|
|
71
|
+
<h4>Recommendation</h4>
|
|
72
|
+
<p>One Connection configuration owns one client Session. Reuse it across nodes targeting the same server instead of creating duplicate Sessions. Automatic reconnect, connectivity checks, and a bounded command queue are enabled.</p>
|
|
73
|
+
</section>
|
|
74
|
+
</script>
|
|
36
75
|
<script type="text/javascript">
|
|
37
76
|
RED.nodes.registerType('tier0-opcua-connection', {
|
|
38
77
|
category: 'config',
|
|
@@ -48,6 +87,7 @@
|
|
|
48
87
|
maxReconnectInterval: { value: 30000, validate: RED.validators.number() },
|
|
49
88
|
nodeIdCacheSize: { value: 100000, validate: RED.validators.number() },
|
|
50
89
|
commandQueueSize: { value: 256, validate: RED.validators.number() },
|
|
90
|
+
allowParallelSession: { value: false },
|
|
51
91
|
name: { value: '' }
|
|
52
92
|
},
|
|
53
93
|
credentials: {
|
|
@@ -73,6 +113,94 @@
|
|
|
73
113
|
mode.on('change', updateSecurityFields);
|
|
74
114
|
updateSecurityFields();
|
|
75
115
|
|
|
116
|
+
const maxSecurityFileSize = 2 * 1024 * 1024;
|
|
117
|
+
const uploadSecurityFile = function (file, kind) {
|
|
118
|
+
return new Promise(function (resolve, reject) {
|
|
119
|
+
if (!connectionId) {
|
|
120
|
+
reject(new Error('Save the Connection node once before uploading security files'));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (file.size > maxSecurityFileSize) {
|
|
124
|
+
reject(new Error(`${file.name} exceeds the 2 MiB security file limit`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const reader = new FileReader();
|
|
128
|
+
reader.onerror = function () {
|
|
129
|
+
reject(new Error(`Cannot read ${file.name}`));
|
|
130
|
+
};
|
|
131
|
+
reader.onload = function () {
|
|
132
|
+
const encoded = String(reader.result || '');
|
|
133
|
+
const separator = encoded.indexOf(',');
|
|
134
|
+
if (separator < 0) {
|
|
135
|
+
reject(new Error(`Cannot encode ${file.name}`));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
$.ajax({
|
|
139
|
+
url: 'opcua-client/security-files',
|
|
140
|
+
method: 'POST',
|
|
141
|
+
contentType: 'application/json',
|
|
142
|
+
data: JSON.stringify({
|
|
143
|
+
connectionId,
|
|
144
|
+
kind,
|
|
145
|
+
fileName: file.name,
|
|
146
|
+
contentBase64: encoded.slice(separator + 1)
|
|
147
|
+
})
|
|
148
|
+
}).done(function (response) {
|
|
149
|
+
resolve(response.file);
|
|
150
|
+
}).fail(function (xhr) {
|
|
151
|
+
reject(new Error(xhr.responseJSON?.error || `Cannot upload ${file.name}`));
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
reader.readAsDataURL(file);
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const bindSecurityUpload = function (buttonSelector, fileSelector, targetSelector, kind, append) {
|
|
159
|
+
const button = $(buttonSelector);
|
|
160
|
+
const fileInput = $(fileSelector);
|
|
161
|
+
const target = $(targetSelector);
|
|
162
|
+
button.on('click', function () { fileInput.trigger('click'); });
|
|
163
|
+
fileInput.on('change', function () {
|
|
164
|
+
const input = this;
|
|
165
|
+
const files = Array.from(input.files || []);
|
|
166
|
+
if (!files.length) return;
|
|
167
|
+
button.prop('disabled', true);
|
|
168
|
+
files.reduce(function (chain, file) {
|
|
169
|
+
return chain.then(function () {
|
|
170
|
+
return uploadSecurityFile(file, kind);
|
|
171
|
+
}).then(function (uploaded) {
|
|
172
|
+
if (!append) {
|
|
173
|
+
target.val(uploaded.path);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const paths = String(target.val() || '').split(/\r?\n|;/)
|
|
177
|
+
.map(function (entry) { return entry.trim(); })
|
|
178
|
+
.filter(Boolean);
|
|
179
|
+
if (paths.indexOf(uploaded.path) < 0) paths.push(uploaded.path);
|
|
180
|
+
target.val(paths.join('\n'));
|
|
181
|
+
});
|
|
182
|
+
}, Promise.resolve()).then(function () {
|
|
183
|
+
RED.notify(`${files.length} OPC UA security file(s) uploaded`, 'success');
|
|
184
|
+
}).catch(function (error) {
|
|
185
|
+
RED.notify(error.message || String(error), 'error');
|
|
186
|
+
}).then(function () {
|
|
187
|
+
input.value = '';
|
|
188
|
+
button.prop('disabled', false);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
bindSecurityUpload('#node-config-upload-certificate', '#node-config-file-certificate',
|
|
194
|
+
'#node-config-input-certificate', 'clientCertificate', false);
|
|
195
|
+
bindSecurityUpload('#node-config-upload-privateKey', '#node-config-file-privateKey',
|
|
196
|
+
'#node-config-input-privateKey', 'privateKey', false);
|
|
197
|
+
bindSecurityUpload('#node-config-upload-trustedCertificates',
|
|
198
|
+
'#node-config-file-trustedCertificates', '#node-config-input-trustedCertificates',
|
|
199
|
+
'trustedCertificate', true);
|
|
200
|
+
bindSecurityUpload('#node-config-upload-revocationList',
|
|
201
|
+
'#node-config-file-revocationList', '#node-config-input-revocationList',
|
|
202
|
+
'revocationList', true);
|
|
203
|
+
|
|
76
204
|
const connectionSettings = function () {
|
|
77
205
|
return {
|
|
78
206
|
connectionId,
|
package/nodes/connection.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { OpcUaClient, discoverEndpoints } = require('../lib/client');
|
|
4
|
+
const { ConnectionRegistry } = require('../lib/connection-registry');
|
|
5
|
+
const { SecurityFileStore } = require('../lib/security-file-store');
|
|
4
6
|
|
|
5
7
|
function pathList(value) {
|
|
6
8
|
const source = Array.isArray(value) ? value : String(value || '').split(/\r?\n|;/);
|
|
@@ -37,6 +39,28 @@ function clientOptions(config, credentials = {}) {
|
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
module.exports = function registerConnectionNode(RED) {
|
|
42
|
+
const connectionRegistry = new ConnectionRegistry();
|
|
43
|
+
const securityFileStore = new SecurityFileStore({ userDir: RED.settings?.userDir });
|
|
44
|
+
|
|
45
|
+
RED.httpAdmin.post(
|
|
46
|
+
'/opcua-client/security-files',
|
|
47
|
+
RED.auth.needsPermission('opcua-client.security-files.write'),
|
|
48
|
+
async (req, res) => {
|
|
49
|
+
try {
|
|
50
|
+
const file = await securityFileStore.save(req.body || {});
|
|
51
|
+
res.send({ file });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const status = error?.code === 'OPCUA_SECURITY_FILE_TOO_LARGE'
|
|
54
|
+
? 413
|
|
55
|
+
: String(error?.code || '').startsWith('OPCUA_SECURITY_FILE_') ? 400 : 500;
|
|
56
|
+
res.status(status).send({
|
|
57
|
+
error: error.message || String(error),
|
|
58
|
+
code: error.code || 'OPCUA_SECURITY_FILE_UPLOAD_FAILED',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
|
|
40
64
|
RED.httpAdmin.post(
|
|
41
65
|
'/opcua-client/endpoints',
|
|
42
66
|
RED.auth.needsPermission('opcua-client.discovery'),
|
|
@@ -102,19 +126,79 @@ module.exports = function registerConnectionNode(RED) {
|
|
|
102
126
|
function OpcUaConnectionNode(config) {
|
|
103
127
|
RED.nodes.createNode(this, config);
|
|
104
128
|
const node = this;
|
|
129
|
+
const dependentCleanups = new Set();
|
|
105
130
|
|
|
106
|
-
|
|
131
|
+
function runDependentCleanup(record) {
|
|
132
|
+
if (!record.promise) {
|
|
133
|
+
record.promise = Promise.resolve()
|
|
134
|
+
.then(record.cleanup)
|
|
135
|
+
.finally(() => dependentCleanups.delete(record));
|
|
136
|
+
}
|
|
137
|
+
return record.promise;
|
|
138
|
+
}
|
|
107
139
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
140
|
+
const options = clientOptions(config, node.credentials);
|
|
141
|
+
const registration = connectionRegistry.register(options);
|
|
142
|
+
node.client = new OpcUaClient(options);
|
|
143
|
+
node.getConnectionGovernance = () => ({
|
|
144
|
+
...registration.diagnostics(),
|
|
145
|
+
allowParallelSession: config.allowParallelSession === true,
|
|
146
|
+
});
|
|
147
|
+
node.getDiagnostics = () => ({
|
|
148
|
+
...node.client.getDiagnostics(),
|
|
149
|
+
connectionGovernance: node.getConnectionGovernance(),
|
|
111
150
|
});
|
|
112
|
-
node.
|
|
151
|
+
node.registerDependentCleanup = (cleanup) => {
|
|
152
|
+
if (typeof cleanup !== 'function') {
|
|
153
|
+
throw new TypeError('OPC UA dependent cleanup must be a function');
|
|
154
|
+
}
|
|
155
|
+
const record = { cleanup, promise: null };
|
|
156
|
+
dependentCleanups.add(record);
|
|
157
|
+
return () => runDependentCleanup(record);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
if (registration.duplicate && config.allowParallelSession !== true) {
|
|
161
|
+
node.warn(
|
|
162
|
+
`Equivalent OPC UA Connection configuration detected for ${options.endpoint}; ` +
|
|
163
|
+
'this creates an additional Session. Reuse the existing Connection configuration ' +
|
|
164
|
+
'or enable "Allow parallel Session" when isolation is intentional.'
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const onFault = (error) => node.warn(`OPC UA connection fault: ${error.message}`);
|
|
169
|
+
const onState = (state) => {
|
|
170
|
+
if (state.state === 'failed') node.warn(`OPC UA connection failed: ${state.statusCode}`);
|
|
171
|
+
};
|
|
172
|
+
node.client.on('fault', onFault);
|
|
173
|
+
node.client.on('state', onState);
|
|
174
|
+
node.on('close', async (removed, done) => {
|
|
175
|
+
if (typeof removed === 'function') {
|
|
176
|
+
done = removed;
|
|
177
|
+
removed = false;
|
|
178
|
+
}
|
|
179
|
+
node.client.off('fault', onFault);
|
|
180
|
+
node.client.off('state', onState);
|
|
181
|
+
registration.unregister();
|
|
182
|
+
const cleanupResults = await Promise.allSettled(
|
|
183
|
+
[...dependentCleanups].map(runDependentCleanup)
|
|
184
|
+
);
|
|
185
|
+
for (const result of cleanupResults) {
|
|
186
|
+
if (result.status === 'rejected') {
|
|
187
|
+
node.warn(`OPC UA dependent cleanup failed: ${result.reason?.message || result.reason}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
113
190
|
try {
|
|
114
191
|
await node.client.disconnect();
|
|
115
192
|
} catch (error) {
|
|
116
193
|
node.warn(`OPC UA disconnect failed: ${error.message}`);
|
|
117
194
|
}
|
|
195
|
+
if (removed) {
|
|
196
|
+
try {
|
|
197
|
+
await securityFileStore.removeConnection(config.id);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
node.warn(`OPC UA managed security file cleanup failed: ${error.message}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
118
202
|
done();
|
|
119
203
|
});
|
|
120
204
|
}
|
package/nodes/read.html
CHANGED
|
@@ -39,13 +39,48 @@
|
|
|
39
39
|
<div class="form-row"><label for="node-input-indexRange">Index range</label><input type="text" id="node-input-indexRange" placeholder="For example 0:9 or 0:1,2:3"></div>
|
|
40
40
|
<div class="form-row"><label for="node-input-batchSize">Batch size</label><input type="number" id="node-input-batchSize"></div>
|
|
41
41
|
<div class="form-row"><label for="node-input-outputMode">Output</label><select id="node-input-outputMode"><option value="batch">Batch</option><option value="split">One message per point</option></select></div>
|
|
42
|
+
<div class="form-row"><label for="node-input-resultDetail">Result detail</label><select id="node-input-resultDetail"><option value="compact">Compact</option><option value="value">Value only</option><option value="full">Full metadata</option></select></div>
|
|
42
43
|
</details>
|
|
43
44
|
<div class="form-row"><label for="node-input-name">Name</label><input type="text" id="node-input-name"></div>
|
|
44
45
|
</script>
|
|
45
46
|
<script type="text/html" data-help-name="tier0-opcua-read">
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
<section lang="zh-CN">
|
|
48
|
+
<h3>读取(Read)</h3>
|
|
49
|
+
<p>每收到一条输入消息执行一次 OPC UA Read。它不是定时器;读取频率由上游 Inject、Function 或其他节点的触发频率决定。</p>
|
|
50
|
+
<h4>输入</h4>
|
|
51
|
+
<ul>
|
|
52
|
+
<li>可在编辑器中添加 NodeId,或导入 JSON/CSV 点表;CSV 必须包含 <code>nodeId</code> 列。</li>
|
|
53
|
+
<li>也可通过 <code>msg.payload</code> 或 <code>msg.items</code> 动态传入点表。</li>
|
|
54
|
+
<li>单点读取可使用节点配置、<code>msg.nodeId</code> 或 <code>msg.topic</code>。</li>
|
|
55
|
+
<li><code>indexRange</code> 可读取数组或矩阵的指定范围。</li>
|
|
56
|
+
</ul>
|
|
57
|
+
<h4>输出</h4>
|
|
58
|
+
<ul>
|
|
59
|
+
<li><b>Compact</b>:NodeId/alias、值、状态码和源时间戳,推荐用于采集。</li>
|
|
60
|
+
<li><b>Value only</b>:批量时按点表顺序输出值数组,单点时输出标量。</li>
|
|
61
|
+
<li><b>Full metadata</b>:同时输出完整点表描述和客户端诊断,适合排障。</li>
|
|
62
|
+
</ul>
|
|
63
|
+
<p>大点表会按照 Batch size 和服务器 OperationLimits 自动拆分为批量服务请求,不会逐点建立连接。</p>
|
|
64
|
+
</section>
|
|
65
|
+
<hr>
|
|
66
|
+
<section lang="en">
|
|
67
|
+
<h3>Read</h3>
|
|
68
|
+
<p>Executes one OPC UA Read for each input message. It is not a timer; the upstream Inject, Function, or other node controls the read frequency.</p>
|
|
69
|
+
<h4>Input</h4>
|
|
70
|
+
<ul>
|
|
71
|
+
<li>Add NodeIds in the editor or import a JSON/CSV point list. CSV requires a <code>nodeId</code> column.</li>
|
|
72
|
+
<li>A dynamic point list may arrive in <code>msg.payload</code> or <code>msg.items</code>.</li>
|
|
73
|
+
<li>A single point may come from the node setting, <code>msg.nodeId</code>, or <code>msg.topic</code>.</li>
|
|
74
|
+
<li>Use <code>indexRange</code> to read part of an array or matrix.</li>
|
|
75
|
+
</ul>
|
|
76
|
+
<h4>Output</h4>
|
|
77
|
+
<ul>
|
|
78
|
+
<li><b>Compact</b>: NodeId/alias, value, status code, and source timestamp. Recommended for collection flows.</li>
|
|
79
|
+
<li><b>Value only</b>: A value array in point-list order for batch reads, or a scalar for one point.</li>
|
|
80
|
+
<li><b>Full metadata</b>: Full point descriptors and client diagnostics for troubleshooting.</li>
|
|
81
|
+
</ul>
|
|
82
|
+
<p>Large point lists are split according to Batch size and server OperationLimits. The node does not create one connection per point.</p>
|
|
83
|
+
</section>
|
|
49
84
|
</script>
|
|
50
85
|
<script type="text/javascript">
|
|
51
86
|
RED.nodes.registerType('tier0-opcua-read', {
|
|
@@ -55,7 +90,8 @@
|
|
|
55
90
|
nodeId: { value: '' }, pointList: { value: '' }, pointListFileName: { value: '' },
|
|
56
91
|
indexRange: { value: '' },
|
|
57
92
|
batchSize: { value: 500, validate: RED.validators.number() },
|
|
58
|
-
outputMode: { value: 'batch' },
|
|
93
|
+
outputMode: { value: 'batch' }, resultDetail: { value: 'compact' },
|
|
94
|
+
name: { value: '' }
|
|
59
95
|
},
|
|
60
96
|
oneditprepare: function () {
|
|
61
97
|
const points = new Map();
|
package/nodes/read.js
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
const { looksLikePointList, messagePoints } = require('../lib/points');
|
|
4
4
|
|
|
5
|
+
const COMPACT_RESULT_FIELDS = Object.freeze([
|
|
6
|
+
'nodeId',
|
|
7
|
+
'alias',
|
|
8
|
+
'value',
|
|
9
|
+
'statusCode',
|
|
10
|
+
'sourceTimestamp',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function compactResult(result) {
|
|
14
|
+
const output = {};
|
|
15
|
+
for (const field of COMPACT_RESULT_FIELDS) {
|
|
16
|
+
if (Object.prototype.hasOwnProperty.call(result, field)) output[field] = result[field];
|
|
17
|
+
}
|
|
18
|
+
return output;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function valueOnly(result) {
|
|
22
|
+
return Object.prototype.hasOwnProperty.call(result, 'value') ? result.value : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
5
25
|
module.exports = function registerReadNode(RED) {
|
|
6
26
|
function OpcUaReadNode(config) {
|
|
7
27
|
RED.nodes.createNode(this, config);
|
|
@@ -29,29 +49,46 @@ module.exports = function registerReadNode(RED) {
|
|
|
29
49
|
...value,
|
|
30
50
|
}));
|
|
31
51
|
|
|
52
|
+
// Undefined preserves the original output for Flow JSON created before
|
|
53
|
+
// resultDetail was introduced. New editor nodes explicitly save compact.
|
|
54
|
+
const resultDetail = config.resultDetail || 'full';
|
|
55
|
+
const outputResults = resultDetail === 'compact'
|
|
56
|
+
? results.map(compactResult)
|
|
57
|
+
: resultDetail === 'value'
|
|
58
|
+
? results.map(valueOnly)
|
|
59
|
+
: results;
|
|
60
|
+
|
|
32
61
|
if ((config.outputMode || 'batch') === 'split') {
|
|
33
|
-
results.forEach((result) =>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
62
|
+
results.forEach((result, index) => {
|
|
63
|
+
const output = {
|
|
64
|
+
...msg,
|
|
65
|
+
topic: result.nodeId,
|
|
66
|
+
payload: valueOnly(result),
|
|
67
|
+
};
|
|
68
|
+
if (resultDetail === 'value') delete output.opcua;
|
|
69
|
+
else output.opcua = outputResults[index];
|
|
70
|
+
send(output);
|
|
71
|
+
});
|
|
39
72
|
} else if (points.length === 1 && !looksLikePointList(msg.payload) &&
|
|
40
73
|
!Array.isArray(msg.items) && !config.pointList) {
|
|
41
74
|
const result = results[0];
|
|
42
75
|
msg.topic = result.nodeId;
|
|
43
|
-
msg.payload = result
|
|
44
|
-
msg.opcua
|
|
76
|
+
msg.payload = valueOnly(result);
|
|
77
|
+
if (resultDetail === 'value') delete msg.opcua;
|
|
78
|
+
else msg.opcua = outputResults[0];
|
|
45
79
|
send(msg);
|
|
46
80
|
} else {
|
|
47
|
-
msg.payload =
|
|
81
|
+
msg.payload = outputResults;
|
|
48
82
|
msg.opcua = {
|
|
49
|
-
...(msg.opcua || {}),
|
|
50
83
|
operation: 'read',
|
|
51
84
|
total: results.length,
|
|
52
85
|
good: results.filter((item) => item.statusCode === 'Good').length,
|
|
53
|
-
|
|
86
|
+
resultDetail,
|
|
54
87
|
};
|
|
88
|
+
if (resultDetail === 'full') {
|
|
89
|
+
msg.opcua.clientDiagnostics = node.connection.getDiagnostics?.() ||
|
|
90
|
+
node.connection.client.getDiagnostics();
|
|
91
|
+
}
|
|
55
92
|
send(msg);
|
|
56
93
|
}
|
|
57
94
|
|
package/nodes/subscribe.html
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<div class="form-row"><label for="node-input-connection">Connection</label><input type="text" id="node-input-connection"></div>
|
|
8
8
|
<div class="form-row"><label for="node-input-nodeId">NodeId</label><input type="text" id="node-input-nodeId" placeholder="Optional when input supplies points"></div>
|
|
9
9
|
<div class="form-row"><label for="node-input-samplingInterval">Sampling (ms)</label><input type="number" id="node-input-samplingInterval"></div>
|
|
10
|
-
<div class="opcua-standard-note">Standard mode shards large point sets
|
|
10
|
+
<div class="opcua-standard-note">Standard mode keeps the latest pending value per point, shards large point sets, batches changes, and automatically restores them after reconnect.</div>
|
|
11
11
|
<details class="opcua-advanced">
|
|
12
12
|
<summary>Advanced subscription and queue settings</summary>
|
|
13
13
|
<div class="form-row"><label for="node-input-publishingInterval">Publishing interval</label><input type="number" id="node-input-publishingInterval"></div>
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
<div class="form-row"><label for="node-input-batchSize">Create batch size</label><input type="number" id="node-input-batchSize"></div>
|
|
20
20
|
<div class="form-row"><label for="node-input-maxItemsPerSubscription">Items / subscription</label><input type="number" id="node-input-maxItemsPerSubscription"></div>
|
|
21
21
|
<div class="form-row"><label for="node-input-outputMode">Change output</label><select id="node-input-outputMode"><option value="batch">Native batch</option><option value="single">One message per change</option></select></div>
|
|
22
|
+
<div class="form-row"><label for="node-input-resultDetail">Result detail</label><select id="node-input-resultDetail"><option value="compact">Compact</option><option value="value">Value only</option><option value="full">Full metadata</option></select></div>
|
|
22
23
|
<div class="form-row"><label for="node-input-callbackBatchSize">Output batch size</label><input type="number" id="node-input-callbackBatchSize"></div>
|
|
23
24
|
<div class="form-row"><label for="node-input-callbackFlushIntervalMs">Batch flush (ms)</label><input type="number" id="node-input-callbackFlushIntervalMs"></div>
|
|
24
25
|
<div class="form-row"><label for="node-input-callbackQueueSize">Native queue capacity</label><input type="number" id="node-input-callbackQueueSize"></div>
|
|
@@ -27,21 +28,66 @@
|
|
|
27
28
|
</details>
|
|
28
29
|
<div class="form-row"><label for="node-input-name">Name</label><input type="text" id="node-input-name"></div>
|
|
29
30
|
</script>
|
|
30
|
-
<script type="text/html" data-help-name="tier0-opcua-subscribe"
|
|
31
|
+
<script type="text/html" data-help-name="tier0-opcua-subscribe">
|
|
32
|
+
<section lang="zh-CN">
|
|
33
|
+
<h3>订阅(Subscribe)</h3>
|
|
34
|
+
<p>创建 OPC UA Subscription 和 MonitoredItems,由服务器在值变化时推送通知;它不是客户端定时 Read。大量点位会按照服务器限制自动拆分到多个 Subscription。</p>
|
|
35
|
+
<h4>输入与命令</h4>
|
|
36
|
+
<p>点表可来自数组、JSON、CSV 或 Browse 输出。通过 <code>msg.opcua.command</code> 使用 <code>replace</code>、<code>add</code>、<code>remove</code>、<code>clear</code> 动态管理点位。</p>
|
|
37
|
+
<h4>时间与队列</h4>
|
|
38
|
+
<ul>
|
|
39
|
+
<li><b>Sampling</b>:服务器检查 MonitoredItem 的请求间隔。</li>
|
|
40
|
+
<li><b>Publishing interval</b>:服务器发布已排队通知的请求周期。</li>
|
|
41
|
+
<li><b>Batch flush</b>:Node-RED 合并通知的最大等待时间,不影响服务器采样。</li>
|
|
42
|
+
<li>服务器可以把请求间隔修订为它支持的值。</li>
|
|
43
|
+
</ul>
|
|
44
|
+
<p>新节点默认 Server queue size=1、Discard oldest=true、Queue mode=latest,适合实时状态采集。脉冲、报警转换或完整历史不能丢失时,应改为 all 并增大服务器队列。</p>
|
|
45
|
+
<h4>输出</h4>
|
|
46
|
+
<ul>
|
|
47
|
+
<li><b>Compact</b>:NodeId/alias、值、状态和源时间戳。</li>
|
|
48
|
+
<li><b>Value only</b>:值在 <code>msg.payload</code>,同位置标识在 <code>msg.opcua.identifiers</code>。</li>
|
|
49
|
+
<li><b>Full metadata</b>:保留完整诊断字段。</li>
|
|
50
|
+
</ul>
|
|
51
|
+
<p>队列深度、丢弃数和延迟位于 <code>msg.opcua.queue</code>;发生溢出时节点会显示警告。</p>
|
|
52
|
+
</section>
|
|
53
|
+
<hr>
|
|
54
|
+
<section lang="en">
|
|
55
|
+
<h3>Subscribe</h3>
|
|
56
|
+
<p>Creates one or more OPC UA subscriptions and MonitoredItems so the server sends data-change notifications. It is not a periodic client Read. Large point sets are automatically sharded across Subscriptions according to server limits.</p>
|
|
57
|
+
<h4>Input and commands</h4>
|
|
58
|
+
<p>Point lists may come from arrays, JSON, CSV, or Browse output. Use <code>replace</code>, <code>add</code>, <code>remove</code>, or <code>clear</code> in <code>msg.opcua.command</code> to manage points dynamically.</p>
|
|
59
|
+
<h4>Timing and queues</h4>
|
|
60
|
+
<ul>
|
|
61
|
+
<li><b>Sampling</b>: Requested interval at which the server checks a MonitoredItem.</li>
|
|
62
|
+
<li><b>Publishing interval</b>: Requested cycle at which the server publishes queued notifications.</li>
|
|
63
|
+
<li><b>Batch flush</b>: Maximum Node-RED aggregation delay; it does not control server sampling.</li>
|
|
64
|
+
<li>The server may revise requested intervals to supported values.</li>
|
|
65
|
+
</ul>
|
|
66
|
+
<p>New nodes default to server queue size 1, discard-oldest, and local latest coalescing for real-time state. Select all and increase the server queue for pulses, alarm transitions, or history where every change must be retained.</p>
|
|
67
|
+
<h4>Output</h4>
|
|
68
|
+
<ul>
|
|
69
|
+
<li><b>Compact</b>: NodeId/alias, value, status, and source timestamp.</li>
|
|
70
|
+
<li><b>Value only</b>: Values are in <code>msg.payload</code>; matching identities are in <code>msg.opcua.identifiers</code>.</li>
|
|
71
|
+
<li><b>Full metadata</b>: Preserves full diagnostic fields.</li>
|
|
72
|
+
</ul>
|
|
73
|
+
<p>Queue depth, drops, and lag are reported in <code>msg.opcua.queue</code>. Queue overflow also raises a node warning.</p>
|
|
74
|
+
</section>
|
|
75
|
+
</script>
|
|
31
76
|
<script type="text/javascript">
|
|
32
77
|
RED.nodes.registerType('tier0-opcua-subscribe', {
|
|
33
78
|
category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 1, icon: 'bridge.png',
|
|
34
79
|
defaults: {
|
|
35
80
|
connection: { value: '', type: 'tier0-opcua-connection', required: true }, nodeId: { value: '' },
|
|
36
81
|
publishingInterval: { value: 1000, validate: RED.validators.number() }, samplingInterval: { value: 1000, validate: RED.validators.number() },
|
|
37
|
-
queueSize: { value:
|
|
82
|
+
queueSize: { value: 1, validate: RED.validators.number() }, discardOldest: { value: true },
|
|
38
83
|
deadbandType: { value: 'none' }, deadbandValue: { value: 0, validate: RED.validators.number() },
|
|
39
84
|
timestampMode: { value: 'source' },
|
|
40
85
|
batchSize: { value: 500, validate: RED.validators.number() }, outputMode: { value: 'batch' },
|
|
86
|
+
resultDetail: { value: 'compact' },
|
|
41
87
|
maxItemsPerSubscription: { value: 5000, validate: RED.validators.number() },
|
|
42
88
|
callbackBatchSize: { value: 500, validate: RED.validators.number() }, callbackQueueSize: { value: 10000, validate: RED.validators.number() },
|
|
43
89
|
callbackFlushIntervalMs: { value: 50, validate: RED.validators.number() },
|
|
44
|
-
queueMode: { value: '
|
|
90
|
+
queueMode: { value: 'latest' }, overflowPolicy: { value: 'drop-oldest' },
|
|
45
91
|
lifetimeCount: { value: 120 }, maxKeepAliveCount: { value: 10 }, maxNotificationsPerPublish: { value: 0 },
|
|
46
92
|
reconnectInterval: { value: 5000 }, name: { value: '' }
|
|
47
93
|
},
|
package/nodes/subscribe.js
CHANGED
|
@@ -12,6 +12,44 @@ const NOTIFICATION_CONFIG_FIELDS = new Set([
|
|
|
12
12
|
'enabled',
|
|
13
13
|
]);
|
|
14
14
|
|
|
15
|
+
const COMPACT_NOTIFICATION_FIELDS = Object.freeze([
|
|
16
|
+
'nodeId',
|
|
17
|
+
'value',
|
|
18
|
+
'statusCode',
|
|
19
|
+
'sourceTimestamp',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function valueOnly(change) {
|
|
23
|
+
return Object.prototype.hasOwnProperty.call(change, 'value') ? change.value : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function compactChange(change, point) {
|
|
27
|
+
const output = {};
|
|
28
|
+
for (const field of COMPACT_NOTIFICATION_FIELDS) {
|
|
29
|
+
if (Object.prototype.hasOwnProperty.call(change, field)) output[field] = change[field];
|
|
30
|
+
}
|
|
31
|
+
if (point.alias != null && point.alias !== '') output.alias = point.alias;
|
|
32
|
+
return output;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function valueChange(change, point) {
|
|
36
|
+
return {
|
|
37
|
+
nodeId: change.nodeId,
|
|
38
|
+
...(point.alias != null && point.alias !== '' ? { alias: point.alias } : {}),
|
|
39
|
+
...(Object.prototype.hasOwnProperty.call(change, 'value') ? { value: change.value } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fullChange(change, point, subscriptionId) {
|
|
44
|
+
change.subscriptionId = subscriptionId;
|
|
45
|
+
for (const key in point) {
|
|
46
|
+
if (!NOTIFICATION_CONFIG_FIELDS.has(key) && !Object.hasOwn(change, key)) {
|
|
47
|
+
change[key] = point[key];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return change;
|
|
51
|
+
}
|
|
52
|
+
|
|
15
53
|
function positiveInteger(value, fallback) {
|
|
16
54
|
const number = Number(value);
|
|
17
55
|
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
@@ -40,6 +78,11 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
40
78
|
const callbackBatchSize = positiveInteger(config.callbackBatchSize, 500);
|
|
41
79
|
const callbackFlushIntervalMs = positiveInteger(config.callbackFlushIntervalMs, 50);
|
|
42
80
|
const configuredSubscriptionCapacity = positiveInteger(config.maxItemsPerSubscription, 5000);
|
|
81
|
+
// Undefined preserves the descriptor-rich output used by Flow JSON created
|
|
82
|
+
// before resultDetail existed. New editor nodes explicitly save compact.
|
|
83
|
+
const resultDetail = ['compact', 'value', 'full'].includes(config.resultDetail)
|
|
84
|
+
? config.resultDetail
|
|
85
|
+
: 'full';
|
|
43
86
|
|
|
44
87
|
function subscriptionIds() {
|
|
45
88
|
return [...node.subscriptions.keys()];
|
|
@@ -108,14 +151,23 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
108
151
|
if (!changes.length || node.closing) return;
|
|
109
152
|
const diagnostics = combinedDiagnostics();
|
|
110
153
|
updateQueueWarning(diagnostics);
|
|
111
|
-
|
|
112
|
-
payload: changes,
|
|
154
|
+
const output = {
|
|
155
|
+
payload: resultDetail === 'value' ? changes.map(valueOnly) : changes,
|
|
113
156
|
opcua: {
|
|
114
157
|
operation: 'change',
|
|
115
158
|
total: changes.length,
|
|
116
159
|
subscriptionCount: node.subscriptions.size,
|
|
160
|
+
resultDetail,
|
|
117
161
|
queue: diagnostics,
|
|
118
162
|
},
|
|
163
|
+
};
|
|
164
|
+
if (resultDetail === 'value') {
|
|
165
|
+
// Subscription batches are sparse and may repeat one NodeId. A parallel
|
|
166
|
+
// identifier array preserves every change without repeating objects.
|
|
167
|
+
output.opcua.identifiers = changes.map((change) => change.alias || change.nodeId);
|
|
168
|
+
}
|
|
169
|
+
node.send({
|
|
170
|
+
...output,
|
|
119
171
|
});
|
|
120
172
|
}
|
|
121
173
|
|
|
@@ -158,23 +210,26 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
158
210
|
const change = sourceChanges[index];
|
|
159
211
|
const point = node.desired.get(change.nodeId);
|
|
160
212
|
if (!point) continue;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
change
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
changes.push(change);
|
|
213
|
+
changes.push(resultDetail === 'compact'
|
|
214
|
+
? compactChange(change, point)
|
|
215
|
+
: resultDetail === 'value'
|
|
216
|
+
? valueChange(change, point)
|
|
217
|
+
: fullChange(change, point, record.id));
|
|
168
218
|
}
|
|
169
219
|
|
|
170
220
|
if ((config.outputMode || 'batch') === 'single') {
|
|
171
221
|
const queue = combinedDiagnostics();
|
|
172
222
|
updateQueueWarning(queue);
|
|
173
|
-
changes.forEach((change, index) =>
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
223
|
+
changes.forEach((change, index) => {
|
|
224
|
+
const output = {
|
|
225
|
+
topic: change.nodeId,
|
|
226
|
+
payload: valueOnly(change),
|
|
227
|
+
};
|
|
228
|
+
if (resultDetail === 'value') {
|
|
229
|
+
if (index === 0) output.opcua = { operation: 'change', resultDetail, queue };
|
|
230
|
+
} else output.opcua = index === 0 ? { ...change, queue } : change;
|
|
231
|
+
node.send(output);
|
|
232
|
+
});
|
|
178
233
|
} else if (changes.length) {
|
|
179
234
|
node.pendingChanges.push(...changes);
|
|
180
235
|
flushFullBatches();
|
|
@@ -358,6 +413,7 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
358
413
|
subscriptionCount: ids.length,
|
|
359
414
|
subscriptionId: ids[0] ?? null,
|
|
360
415
|
subscriptionIds: ids,
|
|
416
|
+
connectionGovernance: node.connection?.getConnectionGovernance?.(),
|
|
361
417
|
};
|
|
362
418
|
}
|
|
363
419
|
|
|
@@ -496,15 +552,23 @@ module.exports = function registerSubscribeNode(RED) {
|
|
|
496
552
|
}
|
|
497
553
|
});
|
|
498
554
|
|
|
499
|
-
|
|
555
|
+
async function cleanupSubscription() {
|
|
500
556
|
node.closing = true;
|
|
501
557
|
if (node.reconnectTimer) clearTimeout(node.reconnectTimer);
|
|
558
|
+
node.reconnectTimer = null;
|
|
502
559
|
discardPendingChanges();
|
|
503
560
|
node.connection?.client.off('fault', onFault);
|
|
504
561
|
node.connection?.client.off('state', onState);
|
|
505
562
|
node.desired.clear();
|
|
563
|
+
await runSerialized(() => clearSubscriptions());
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const runCleanup = node.connection?.registerDependentCleanup?.(cleanupSubscription) ||
|
|
567
|
+
cleanupSubscription;
|
|
568
|
+
|
|
569
|
+
node.on('close', async (done) => {
|
|
506
570
|
try {
|
|
507
|
-
await
|
|
571
|
+
await runCleanup();
|
|
508
572
|
} catch (error) {
|
|
509
573
|
node.warn(`OPC UA subscription cleanup failed: ${error.message}`);
|
|
510
574
|
}
|
package/nodes/write.html
CHANGED
|
@@ -46,9 +46,33 @@
|
|
|
46
46
|
<div class="form-row"><label for="node-input-name">Name</label><input type="text" id="node-input-name"></div>
|
|
47
47
|
</script>
|
|
48
48
|
<script type="text/html" data-help-name="tier0-opcua-write">
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
<section lang="zh-CN">
|
|
50
|
+
<h3>写入(Write)</h3>
|
|
51
|
+
<p>向一个或多个 OPC UA Variable 写值。服务器必须允许当前用户写入,值也必须能够转换为目标数据类型。</p>
|
|
52
|
+
<h4>输入</h4>
|
|
53
|
+
<ul>
|
|
54
|
+
<li>单点:NodeId 来自节点配置、<code>msg.nodeId</code> 或 <code>msg.topic</code>,写入值来自 <code>msg.payload</code>。</li>
|
|
55
|
+
<li>批量:通过编辑器、JSON 或 CSV 提供 <code>nodeId</code>、<code>value</code> 和可选 <code>dataType</code>。</li>
|
|
56
|
+
<li>CSV 必须包含 <code>nodeId</code> 和 <code>value</code>;未指定类型时使用节点默认类型。</li>
|
|
57
|
+
<li><code>indexRange</code> 可部分写入数组或矩阵。</li>
|
|
58
|
+
</ul>
|
|
59
|
+
<h4>输出</h4>
|
|
60
|
+
<p>Batch 模式在 <code>msg.payload</code> 中返回每个 NodeId 的 StatusCode;Split 模式每个写入结果发送一条消息。应检查是否为 <code>Good</code>,不要只依赖节点未报异常。</p>
|
|
61
|
+
</section>
|
|
62
|
+
<hr>
|
|
63
|
+
<section lang="en">
|
|
64
|
+
<h3>Write</h3>
|
|
65
|
+
<p>Writes values to one or more OPC UA Variables. The current user must have write access and each value must be convertible to the target data type.</p>
|
|
66
|
+
<h4>Input</h4>
|
|
67
|
+
<ul>
|
|
68
|
+
<li>Single point: NodeId comes from the node setting, <code>msg.nodeId</code>, or <code>msg.topic</code>; the value comes from <code>msg.payload</code>.</li>
|
|
69
|
+
<li>Batch: Supply <code>nodeId</code>, <code>value</code>, and optional <code>dataType</code> through the editor, JSON, or CSV.</li>
|
|
70
|
+
<li>CSV requires <code>nodeId</code> and <code>value</code>. The node default type is used when <code>dataType</code> is omitted.</li>
|
|
71
|
+
<li>Use <code>indexRange</code> for partial array or matrix writes.</li>
|
|
72
|
+
</ul>
|
|
73
|
+
<h4>Output</h4>
|
|
74
|
+
<p>Batch mode returns one StatusCode per NodeId in <code>msg.payload</code>. Split mode sends one message per write result. Check for <code>Good</code>; do not treat the absence of a node error as proof that every item succeeded.</p>
|
|
75
|
+
</section>
|
|
52
76
|
</script>
|
|
53
77
|
<script type="text/javascript">
|
|
54
78
|
RED.nodes.registerType('tier0-opcua-write', {
|
package/nodes/write.js
CHANGED
|
@@ -28,7 +28,8 @@ module.exports = function registerWriteNode(RED) {
|
|
|
28
28
|
operation: 'write',
|
|
29
29
|
total: results.length,
|
|
30
30
|
good: results.filter((item) => item.statusCode === 'Good').length,
|
|
31
|
-
clientDiagnostics: node.connection.
|
|
31
|
+
clientDiagnostics: node.connection.getDiagnostics?.() ||
|
|
32
|
+
node.connection.client.getDiagnostics(),
|
|
32
33
|
};
|
|
33
34
|
send(msg);
|
|
34
35
|
}
|