@tier0/opcua-client 0.4.1 → 0.6.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/BENCHMARK.md +27 -0
- package/README.md +48 -6
- package/lib/client.js +61 -10
- package/lib/compatibility.js +28 -0
- package/lib/points.js +40 -5
- package/lib/security.js +182 -2
- package/nodes/browse.html +13 -2
- package/nodes/browse.js +29 -2
- package/nodes/connection.html +19 -9
- package/nodes/connection.js +71 -0
- package/nodes/read.html +2 -0
- package/nodes/read.js +10 -0
- package/nodes/subscribe.html +165 -5
- package/nodes/subscribe.js +218 -23
- package/nodes/write.html +2 -0
- package/nodes/write.js +10 -0
- package/package.json +3 -2
- package/prebuilds/linux-x64-musl/opcua_open62541.node +0 -0
- package/scripts/verify-offline-package.js +21 -1
- package/src/addon.cc +343 -49
package/nodes/browse.js
CHANGED
|
@@ -36,16 +36,22 @@ module.exports = function registerBrowseNode(RED) {
|
|
|
36
36
|
|
|
37
37
|
activeEditorBrowses.add(connectionId);
|
|
38
38
|
try {
|
|
39
|
+
connection.markUsed?.('editor-browse');
|
|
39
40
|
const items = await connection.client.browse({
|
|
40
41
|
rootNodeId,
|
|
41
42
|
recursive: true,
|
|
42
43
|
variablesOnly: false,
|
|
43
44
|
maxDepth: positiveInteger(body.maxDepth, 10, 50),
|
|
44
45
|
maxNodes: positiveInteger(body.maxNodes, 10000, 100000),
|
|
46
|
+
browseBatchSize: positiveInteger(body.browseBatchSize, 100, 5000),
|
|
47
|
+
referencesPerNode: positiveInteger(body.referencesPerNode, 1000, 10000),
|
|
45
48
|
attributeBatchSize: positiveInteger(body.attributeBatchSize, 200, 5000),
|
|
46
49
|
});
|
|
47
50
|
const tree = buildBrowseTree(items, rootNodeId);
|
|
48
|
-
res.send({
|
|
51
|
+
res.send({
|
|
52
|
+
tree, total: items.length, rootNodeId,
|
|
53
|
+
...(items.diagnostics || {}),
|
|
54
|
+
});
|
|
49
55
|
} catch (error) {
|
|
50
56
|
const status = error?.code === 'OPCUA_RECONNECTING' ? 503 : 500;
|
|
51
57
|
res.status(status).send({ error: error.message || String(error) });
|
|
@@ -59,11 +65,15 @@ module.exports = function registerBrowseNode(RED) {
|
|
|
59
65
|
RED.nodes.createNode(this, config);
|
|
60
66
|
const node = this;
|
|
61
67
|
node.connection = RED.nodes.getNode(config.connection);
|
|
68
|
+
const consumer = node.connection?.registerConsumer?.('browse', {
|
|
69
|
+
nodeId: config.id, name: config.name,
|
|
70
|
+
});
|
|
62
71
|
|
|
63
72
|
node.on('input', async (msg, send, done) => {
|
|
64
73
|
if (!node.connection) return done(new Error('OPC UA connection not configured'));
|
|
65
74
|
|
|
66
75
|
try {
|
|
76
|
+
consumer?.markUsed('browse');
|
|
67
77
|
const rootNodeId = msg.nodeId || msg.topic || config.rootNodeId || 'ns=0;i=85';
|
|
68
78
|
node.status({ fill: 'blue', shape: 'dot', text: 'browsing' });
|
|
69
79
|
const configuredSelection = selectedVariables(config.selectedNodes);
|
|
@@ -75,8 +85,11 @@ module.exports = function registerBrowseNode(RED) {
|
|
|
75
85
|
maxNodes: Number(config.maxNodes) || 10000,
|
|
76
86
|
variablesOnly: config.variablesOnly !== false,
|
|
77
87
|
namespaceIndexes: msg.opcua?.namespaceIndexes || [],
|
|
88
|
+
browseBatchSize: Number(config.browseBatchSize) || 100,
|
|
89
|
+
referencesPerNode: Number(config.referencesPerNode) || 1000,
|
|
78
90
|
attributeBatchSize: Number(config.attributeBatchSize) || 200,
|
|
79
91
|
});
|
|
92
|
+
const browseDiagnostics = useSelection ? null : points.diagnostics || null;
|
|
80
93
|
|
|
81
94
|
const outputMode = config.outputMode || 'all';
|
|
82
95
|
if (outputMode === 'paged') {
|
|
@@ -93,6 +106,7 @@ module.exports = function registerBrowseNode(RED) {
|
|
|
93
106
|
pages: pages.length,
|
|
94
107
|
done: index === pages.length - 1,
|
|
95
108
|
total: points.length,
|
|
109
|
+
...(browseDiagnostics ? { browse: browseDiagnostics } : {}),
|
|
96
110
|
},
|
|
97
111
|
}));
|
|
98
112
|
} else {
|
|
@@ -104,17 +118,30 @@ module.exports = function registerBrowseNode(RED) {
|
|
|
104
118
|
rootNodeId,
|
|
105
119
|
done: true,
|
|
106
120
|
total: points.length,
|
|
121
|
+
...(browseDiagnostics ? { browse: browseDiagnostics } : {}),
|
|
107
122
|
};
|
|
108
123
|
send(msg);
|
|
109
124
|
}
|
|
110
125
|
|
|
111
|
-
|
|
126
|
+
const partial = browseDiagnostics?.truncated || browseDiagnostics?.failures?.length;
|
|
127
|
+
node.status({
|
|
128
|
+
fill: partial ? 'yellow' : 'green', shape: partial ? 'ring' : 'dot',
|
|
129
|
+
text: `${points.length} points` +
|
|
130
|
+
(browseDiagnostics?.truncated
|
|
131
|
+
? ` / truncated: ${browseDiagnostics.truncationReason}` : ''),
|
|
132
|
+
});
|
|
112
133
|
done();
|
|
113
134
|
} catch (error) {
|
|
114
135
|
node.status({ fill: 'red', shape: 'ring', text: error.message });
|
|
115
136
|
done(error);
|
|
116
137
|
}
|
|
117
138
|
});
|
|
139
|
+
|
|
140
|
+
node.on('close', (removed, done) => {
|
|
141
|
+
if (typeof removed === 'function') done = removed;
|
|
142
|
+
consumer?.release();
|
|
143
|
+
done?.();
|
|
144
|
+
});
|
|
118
145
|
}
|
|
119
146
|
|
|
120
147
|
RED.nodes.registerType('tier0-opcua-browse', OpcUaBrowseNode);
|
package/nodes/connection.html
CHANGED
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
<div class="form-row"><label></label><button type="button" class="red-ui-button" id="node-config-input-discoverEndpoints">Discover endpoints</button> <button type="button" class="red-ui-button" id="node-config-input-testConnection">Test connection</button></div>
|
|
16
16
|
<div class="opcua-discovery-result" id="node-config-input-discoveryResult" style="display:none"><select id="node-config-input-discoveredEndpoint"></select></div>
|
|
17
17
|
<div class="opcua-standard-note" id="node-config-input-testResult"></div>
|
|
18
|
+
<div class="form-row"><label for="node-config-input-compatibilityProfile">Server profile</label><select id="node-config-input-compatibilityProfile"><option value="standard">Standard OPC UA</option><option value="siemens-wincc">Siemens WinCC</option></select></div>
|
|
19
|
+
<div class="opcua-standard-note">Siemens WinCC uses conservative 200-item service batches and disables unsupported Subscription Deadband filters when the Server does not advertise usable limits.</div>
|
|
18
20
|
<div class="form-row"><label for="node-config-input-securityMode">Security</label><select id="node-config-input-securityMode"><option value="None">None</option><option value="Sign">Sign</option><option value="SignAndEncrypt">SignAndEncrypt</option></select></div>
|
|
19
21
|
<div class="opcua-standard-note">Standard defaults include automatic reconnect, connectivity checks, and bounded command/NodeId queues.</div>
|
|
20
22
|
<details class="opcua-advanced">
|
|
21
23
|
<summary>Security and advanced connection settings</summary>
|
|
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>
|
|
24
|
+
<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><option value="Aes128_Sha256_RsaOaep">Aes128_Sha256_RsaOaep</option><option value="Aes256_Sha256_RsaPss">Aes256_Sha256_RsaPss</option></select></div>
|
|
23
25
|
<div class="form-row"><label for="node-config-input-user">User</label><input type="text" id="node-config-input-user"></div>
|
|
24
26
|
<div class="form-row"><label for="node-config-input-password">Password</label><input type="password" id="node-config-input-password"></div>
|
|
25
27
|
<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>
|
|
@@ -50,8 +52,9 @@
|
|
|
50
52
|
<li><b>Endpoint</b>:服务器地址,例如 <code>opc.tcp://192.168.1.10:4840</code>。</li>
|
|
51
53
|
<li><b>Discover endpoints</b>:读取服务器公开的安全模式、安全策略和用户认证方式。</li>
|
|
52
54
|
<li><b>Test connection</b>:使用当前配置建立临时连接并验证认证。</li>
|
|
53
|
-
<li><b>
|
|
54
|
-
<li><b
|
|
55
|
+
<li><b>Server profile</b>:默认使用标准 OPC UA 行为;Siemens WinCC 模式在服务器未公布有效 OperationLimits 时按每批 200 点请求,并在订阅中禁用 WinCC 不支持的 Deadband。</li>
|
|
56
|
+
<li><b>Security</b>:支持 None、Sign、SignAndEncrypt,以及 Basic256Sha256、Aes128_Sha256_RsaOaep、Aes256_Sha256_RsaPss;安全模式、策略和认证方式必须与服务器 Endpoint 匹配。</li>
|
|
57
|
+
<li><b>证书</b>:安全连接可上传或指定客户端证书、私钥、信任证书和 CRL。连接前会检查有效期、证书和私钥是否匹配、RSA 密钥强度及 Application URI。若证书只包含一个 URI,可留空并自动采用。</li>
|
|
55
58
|
</ul>
|
|
56
59
|
<h4>建议</h4>
|
|
57
60
|
<p>一个 Connection 配置对应一条客户端 Session。多个业务节点应复用同一个 Connection,避免为相同 Endpoint 重复创建 Session。节点内置自动重连、连通性检查以及有界命令队列。</p>
|
|
@@ -65,8 +68,9 @@
|
|
|
65
68
|
<li><b>Endpoint</b>: Server URL, for example <code>opc.tcp://192.168.1.10:4840</code>.</li>
|
|
66
69
|
<li><b>Discover endpoints</b>: Reads advertised security modes, policies, and user token types.</li>
|
|
67
70
|
<li><b>Test connection</b>: Opens a temporary connection with the current settings and validates authentication.</li>
|
|
68
|
-
<li><b>
|
|
69
|
-
<li><b>
|
|
71
|
+
<li><b>Server profile</b>: Standard uses normal OPC UA behavior. Siemens WinCC falls back to 200-item service batches when usable OperationLimits are not advertised and removes unsupported Subscription Deadband filters.</li>
|
|
72
|
+
<li><b>Security</b>: Supports None, Sign, and SignAndEncrypt with Basic256Sha256, Aes128_Sha256_RsaOaep, or Aes256_Sha256_RsaPss. Mode, policy, and identity must match an advertised endpoint.</li>
|
|
73
|
+
<li><b>Certificates</b>: Secure connections can upload or reference a client certificate, private key, trust list, and CRLs. Validity, key matching, RSA strength, and Application URI are checked before connecting. An empty URI is filled automatically when the certificate contains exactly one URI.</li>
|
|
70
74
|
</ul>
|
|
71
75
|
<h4>Recommendation</h4>
|
|
72
76
|
<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>
|
|
@@ -78,6 +82,7 @@
|
|
|
78
82
|
defaults: {
|
|
79
83
|
endpoint: { value: '', required: true },
|
|
80
84
|
securityMode: { value: 'None' }, securityPolicy: { value: 'None' },
|
|
85
|
+
compatibilityProfile: { value: 'standard' },
|
|
81
86
|
user: { value: '' }, certificate: { value: '' }, privateKey: { value: '' },
|
|
82
87
|
applicationUri: { value: '' }, trustedCertificates: { value: '' }, revocationList: { value: '' },
|
|
83
88
|
requestedSessionTimeout: { value: 60000, validate: RED.validators.number() },
|
|
@@ -207,6 +212,7 @@
|
|
|
207
212
|
endpoint: String($('#node-config-input-endpoint').val() || '').trim(),
|
|
208
213
|
securityMode: mode.val(),
|
|
209
214
|
securityPolicy: policy.val(),
|
|
215
|
+
compatibilityProfile: $('#node-config-input-compatibilityProfile').val(),
|
|
210
216
|
user: $('#node-config-input-user').val(),
|
|
211
217
|
password: $('#node-config-input-password').val(),
|
|
212
218
|
certificate: $('#node-config-input-certificate').val(),
|
|
@@ -245,9 +251,12 @@
|
|
|
245
251
|
const allEndpoints = Array.isArray(response.endpoints) ? response.endpoints : [];
|
|
246
252
|
const identityType = String($('#node-config-input-user').val() || '').trim()
|
|
247
253
|
? 'Username' : 'Anonymous';
|
|
254
|
+
const supportedPolicies = new Set([
|
|
255
|
+
'None', 'Basic256Sha256',
|
|
256
|
+
'Aes128_Sha256_RsaOaep', 'Aes256_Sha256_RsaPss',
|
|
257
|
+
]);
|
|
248
258
|
const supported = allEndpoints.filter(function (item) {
|
|
249
|
-
|
|
250
|
-
return itemPolicy === 'None' || itemPolicy === 'Basic256Sha256';
|
|
259
|
+
return supportedPolicies.has(policyName(item.securityPolicyUri));
|
|
251
260
|
});
|
|
252
261
|
const matchingIdentity = supported.filter(function (item) {
|
|
253
262
|
return (item.userIdentityTokens || []).some(function (token) {
|
|
@@ -271,7 +280,7 @@
|
|
|
271
280
|
.appendTo(discovered);
|
|
272
281
|
});
|
|
273
282
|
if (!endpoints.length) {
|
|
274
|
-
RED.notify('The server returned no supported
|
|
283
|
+
RED.notify('The server returned no endpoint supported by this client', 'warning');
|
|
275
284
|
result.hide();
|
|
276
285
|
return;
|
|
277
286
|
}
|
|
@@ -293,7 +302,8 @@
|
|
|
293
302
|
if (!selected) return;
|
|
294
303
|
const selectedPolicy = policyName(selected.securityPolicyUri);
|
|
295
304
|
mode.val(selected.securityMode);
|
|
296
|
-
if (
|
|
305
|
+
if (['None', 'Basic256Sha256', 'Aes128_Sha256_RsaOaep',
|
|
306
|
+
'Aes256_Sha256_RsaPss'].includes(selectedPolicy)) {
|
|
297
307
|
policy.val(selectedPolicy);
|
|
298
308
|
}
|
|
299
309
|
updateSecurityFields();
|
package/nodes/connection.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { OpcUaClient, discoverEndpoints } = require('../lib/client');
|
|
4
4
|
const { ConnectionRegistry } = require('../lib/connection-registry');
|
|
5
5
|
const { SecurityFileStore } = require('../lib/security-file-store');
|
|
6
|
+
const { normalizeCompatibilityProfile } = require('../lib/compatibility');
|
|
6
7
|
|
|
7
8
|
function pathList(value) {
|
|
8
9
|
const source = Array.isArray(value) ? value : String(value || '').split(/\r?\n|;/);
|
|
@@ -14,6 +15,7 @@ function clientOptions(config, credentials = {}) {
|
|
|
14
15
|
endpoint: config.endpoint,
|
|
15
16
|
securityMode: config.securityMode || 'None',
|
|
16
17
|
securityPolicy: config.securityPolicy || 'None',
|
|
18
|
+
compatibilityProfile: normalizeCompatibilityProfile(config.compatibilityProfile),
|
|
17
19
|
user: config.user || '',
|
|
18
20
|
password: credentials.password || '',
|
|
19
21
|
certificate: config.certificate || '',
|
|
@@ -127,6 +129,48 @@ module.exports = function registerConnectionNode(RED) {
|
|
|
127
129
|
RED.nodes.createNode(this, config);
|
|
128
130
|
const node = this;
|
|
129
131
|
const dependentCleanups = new Set();
|
|
132
|
+
const consumers = new Map();
|
|
133
|
+
const operations = new Map();
|
|
134
|
+
let nextConsumerId = 1;
|
|
135
|
+
let lastUsedAt = null;
|
|
136
|
+
let totalOperations = 0;
|
|
137
|
+
|
|
138
|
+
function markUsed(operation = 'unknown', consumer) {
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const name = String(operation || 'unknown');
|
|
141
|
+
lastUsedAt = now;
|
|
142
|
+
totalOperations += 1;
|
|
143
|
+
operations.set(name, (operations.get(name) || 0) + 1);
|
|
144
|
+
if (consumer && consumers.has(consumer.id)) {
|
|
145
|
+
consumer.lastUsedAt = now;
|
|
146
|
+
consumer.operations += 1;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function connectionUsage() {
|
|
151
|
+
const consumerTypes = {};
|
|
152
|
+
for (const consumer of consumers.values()) {
|
|
153
|
+
consumerTypes[consumer.type] = (consumerTypes[consumer.type] || 0) + 1;
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
consumerCount: consumers.size,
|
|
157
|
+
consumerTypes,
|
|
158
|
+
consumers: [...consumers.values()].map((consumer) => ({
|
|
159
|
+
id: consumer.nodeId || consumer.id,
|
|
160
|
+
type: consumer.type,
|
|
161
|
+
name: consumer.name || '',
|
|
162
|
+
registeredAt: new Date(consumer.registeredAt).toISOString(),
|
|
163
|
+
lastUsedAt: consumer.lastUsedAt == null
|
|
164
|
+
? null : new Date(consumer.lastUsedAt).toISOString(),
|
|
165
|
+
operations: consumer.operations,
|
|
166
|
+
})),
|
|
167
|
+
lastUsedAt: lastUsedAt == null ? null : new Date(lastUsedAt).toISOString(),
|
|
168
|
+
idleMs: lastUsedAt == null ? null : Math.max(0, Date.now() - lastUsedAt),
|
|
169
|
+
totalOperations,
|
|
170
|
+
operations: Object.fromEntries(operations),
|
|
171
|
+
automaticIdleDisconnect: false,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
130
174
|
|
|
131
175
|
function runDependentCleanup(record) {
|
|
132
176
|
if (!record.promise) {
|
|
@@ -147,7 +191,33 @@ module.exports = function registerConnectionNode(RED) {
|
|
|
147
191
|
node.getDiagnostics = () => ({
|
|
148
192
|
...node.client.getDiagnostics(),
|
|
149
193
|
connectionGovernance: node.getConnectionGovernance(),
|
|
194
|
+
connectionUsage: connectionUsage(),
|
|
150
195
|
});
|
|
196
|
+
node.getConnectionUsage = connectionUsage;
|
|
197
|
+
node.markUsed = (operation) => markUsed(operation);
|
|
198
|
+
node.registerConsumer = (type, details = {}) => {
|
|
199
|
+
const consumer = {
|
|
200
|
+
id: nextConsumerId++,
|
|
201
|
+
nodeId: String(details.nodeId || ''),
|
|
202
|
+
name: String(details.name || ''),
|
|
203
|
+
type: String(type || 'unknown'),
|
|
204
|
+
registeredAt: Date.now(),
|
|
205
|
+
lastUsedAt: null,
|
|
206
|
+
operations: 0,
|
|
207
|
+
};
|
|
208
|
+
consumers.set(consumer.id, consumer);
|
|
209
|
+
let released = false;
|
|
210
|
+
return {
|
|
211
|
+
markUsed(operation) {
|
|
212
|
+
if (!released) markUsed(operation || consumer.type, consumer);
|
|
213
|
+
},
|
|
214
|
+
release() {
|
|
215
|
+
if (released) return;
|
|
216
|
+
released = true;
|
|
217
|
+
consumers.delete(consumer.id);
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
};
|
|
151
221
|
node.registerDependentCleanup = (cleanup) => {
|
|
152
222
|
if (typeof cleanup !== 'function') {
|
|
153
223
|
throw new TypeError('OPC UA dependent cleanup must be a function');
|
|
@@ -187,6 +257,7 @@ module.exports = function registerConnectionNode(RED) {
|
|
|
187
257
|
node.warn(`OPC UA dependent cleanup failed: ${result.reason?.message || result.reason}`);
|
|
188
258
|
}
|
|
189
259
|
}
|
|
260
|
+
consumers.clear();
|
|
190
261
|
try {
|
|
191
262
|
await node.client.disconnect();
|
|
192
263
|
} catch (error) {
|
package/nodes/read.html
CHANGED
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
<li><b>Value only</b>:批量时按点表顺序输出值数组,单点时输出标量。</li>
|
|
61
61
|
<li><b>Full metadata</b>:同时输出完整点表描述和客户端诊断,适合排障。</li>
|
|
62
62
|
</ul>
|
|
63
|
+
<p>Int64/UInt64 超出 JavaScript 安全整数范围时以十进制字符串输出,避免累计量和计数器精度丢失。</p>
|
|
63
64
|
<p>大点表会按照 Batch size 和服务器 OperationLimits 自动拆分为批量服务请求,不会逐点建立连接。</p>
|
|
64
65
|
</section>
|
|
65
66
|
<hr>
|
|
@@ -79,6 +80,7 @@
|
|
|
79
80
|
<li><b>Value only</b>: A value array in point-list order for batch reads, or a scalar for one point.</li>
|
|
80
81
|
<li><b>Full metadata</b>: Full point descriptors and client diagnostics for troubleshooting.</li>
|
|
81
82
|
</ul>
|
|
83
|
+
<p>Int64/UInt64 values outside the JavaScript safe-integer range are emitted as decimal strings to preserve exact counters and totals.</p>
|
|
82
84
|
<p>Large point lists are split according to Batch size and server OperationLimits. The node does not create one connection per point.</p>
|
|
83
85
|
</section>
|
|
84
86
|
</script>
|
package/nodes/read.js
CHANGED
|
@@ -27,11 +27,15 @@ module.exports = function registerReadNode(RED) {
|
|
|
27
27
|
RED.nodes.createNode(this, config);
|
|
28
28
|
const node = this;
|
|
29
29
|
node.connection = RED.nodes.getNode(config.connection);
|
|
30
|
+
const consumer = node.connection?.registerConsumer?.('read', {
|
|
31
|
+
nodeId: config.id, name: config.name,
|
|
32
|
+
});
|
|
30
33
|
|
|
31
34
|
node.on('input', async (msg, send, done) => {
|
|
32
35
|
if (!node.connection) return done(new Error('OPC UA connection not configured'));
|
|
33
36
|
|
|
34
37
|
try {
|
|
38
|
+
consumer?.markUsed('read');
|
|
35
39
|
const points = messagePoints(msg, config.nodeId, {
|
|
36
40
|
indexRange: config.indexRange,
|
|
37
41
|
}, {
|
|
@@ -99,6 +103,12 @@ module.exports = function registerReadNode(RED) {
|
|
|
99
103
|
done(error);
|
|
100
104
|
}
|
|
101
105
|
});
|
|
106
|
+
|
|
107
|
+
node.on('close', (removed, done) => {
|
|
108
|
+
if (typeof removed === 'function') done = removed;
|
|
109
|
+
consumer?.release();
|
|
110
|
+
done?.();
|
|
111
|
+
});
|
|
102
112
|
}
|
|
103
113
|
|
|
104
114
|
RED.nodes.registerType('tier0-opcua-read', OpcUaReadNode);
|
package/nodes/subscribe.html
CHANGED
|
@@ -2,12 +2,41 @@
|
|
|
2
2
|
.opcua-advanced { margin: 8px 0 12px; }
|
|
3
3
|
.opcua-advanced summary { cursor: pointer; font-weight: 600; padding: 6px 0; }
|
|
4
4
|
.opcua-standard-note { margin: 0 0 10px 105px; color: var(--red-ui-form-text-color, #666); font-size: 0.9em; }
|
|
5
|
+
.opcua-point-editor { box-sizing: border-box; display: inline-block; vertical-align: top; width: calc(100% - 110px); }
|
|
6
|
+
.opcua-point-toolbar { display: flex; gap: 6px; margin-bottom: 8px; }
|
|
7
|
+
.opcua-point-add { display: flex; gap: 6px; margin-bottom: 8px; }
|
|
8
|
+
.opcua-point-add input { flex: 1; min-width: 0; }
|
|
9
|
+
.opcua-point-list { border: 1px solid var(--red-ui-form-input-border-color, #ccc); border-radius: 2px; box-sizing: border-box; height: 180px; list-style: none; margin: 0 !important; overflow: auto; padding: 0 !important; width: 100%; }
|
|
10
|
+
.opcua-point-list li { align-items: center; border-bottom: 1px solid var(--red-ui-secondary-border-color, #eee); display: flex; gap: 8px; min-height: 34px; padding: 4px 8px; }
|
|
11
|
+
.opcua-point-list li:last-child { border-bottom: 0; }
|
|
12
|
+
.opcua-point-list .opcua-point-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
13
|
+
.opcua-point-list .opcua-point-empty { color: var(--red-ui-form-placeholder-color, #888); justify-content: center; }
|
|
14
|
+
.opcua-point-list .opcua-point-remove { border: 0; color: var(--red-ui-text-color, #555); flex: 0 0 auto; }
|
|
15
|
+
.opcua-point-status { color: var(--red-ui-form-text-color, #666); font-size: 0.9em; margin-top: 5px; }
|
|
5
16
|
</style>
|
|
6
17
|
<script type="text/html" data-template-name="tier0-opcua-subscribe">
|
|
7
18
|
<div class="form-row"><label for="node-input-connection">Connection</label><input type="text" id="node-input-connection"></div>
|
|
8
|
-
<
|
|
19
|
+
<input type="hidden" id="node-input-nodeId">
|
|
20
|
+
<input type="hidden" id="node-input-pointList">
|
|
21
|
+
<input type="hidden" id="node-input-pointListFileName">
|
|
22
|
+
<input type="file" id="node-input-pointFile" accept=".json,.csv,application/json,text/csv" style="display:none">
|
|
23
|
+
<div class="form-row">
|
|
24
|
+
<label>Points</label>
|
|
25
|
+
<div class="opcua-point-editor">
|
|
26
|
+
<div class="opcua-point-toolbar">
|
|
27
|
+
<button type="button" class="red-ui-button" id="node-input-importPointList"><i class="fa fa-upload"></i> Import</button>
|
|
28
|
+
<button type="button" class="red-ui-button" id="node-input-clearPointList"><i class="fa fa-trash"></i> Clear all</button>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="opcua-point-add">
|
|
31
|
+
<input type="text" id="node-input-newPointNodeId" placeholder="NodeId, for example ns=2;s=Machine.Speed">
|
|
32
|
+
<button type="button" class="red-ui-button" id="node-input-addPoint"><i class="fa fa-plus"></i> Add</button>
|
|
33
|
+
</div>
|
|
34
|
+
<ol class="opcua-point-list" id="node-input-pointListItems"></ol>
|
|
35
|
+
<div class="opcua-point-status" id="node-input-pointListStatus">0 points</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
9
38
|
<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">
|
|
39
|
+
<div class="opcua-standard-note">Saved points start automatically after deploy. Repeated identical point lists are reconciled without rebuilding server subscriptions.</div>
|
|
11
40
|
<details class="opcua-advanced">
|
|
12
41
|
<summary>Advanced subscription and queue settings</summary>
|
|
13
42
|
<div class="form-row"><label for="node-input-publishingInterval">Publishing interval</label><input type="number" id="node-input-publishingInterval"></div>
|
|
@@ -33,7 +62,7 @@
|
|
|
33
62
|
<h3>订阅(Subscribe)</h3>
|
|
34
63
|
<p>创建 OPC UA Subscription 和 MonitoredItems,由服务器在值变化时推送通知;它不是客户端定时 Read。大量点位会按照服务器限制自动拆分到多个 Subscription。</p>
|
|
35
64
|
<h4>输入与命令</h4>
|
|
36
|
-
<p
|
|
65
|
+
<p>可直接保存或导入 JSON/CSV 点表;部署后自动建立订阅。点表也可来自消息或 Browse 输出,通过 <code>msg.opcua.command</code> 使用 <code>replace</code>、<code>add</code>、<code>remove</code>、<code>clear</code> 动态管理点位。重复的相同点表不会重建 Subscription。</p>
|
|
37
66
|
<h4>时间与队列</h4>
|
|
38
67
|
<ul>
|
|
39
68
|
<li><b>Sampling</b>:服务器检查 MonitoredItem 的请求间隔。</li>
|
|
@@ -42,6 +71,7 @@
|
|
|
42
71
|
<li>服务器可以把请求间隔修订为它支持的值。</li>
|
|
43
72
|
</ul>
|
|
44
73
|
<p>新节点默认 Server queue size=1、Discard oldest=true、Queue mode=latest,适合实时状态采集。脉冲、报警转换或完整历史不能丢失时,应改为 all 并增大服务器队列。</p>
|
|
74
|
+
<p>Connection 选择 Siemens WinCC 后,如果服务器没有公布有效限制,每个 Subscription 最多建立 1000 个 MonitoredItem,并自动移除 WinCC 不支持的 Deadband 配置;节点状态消息会报告实际兼容策略。</p>
|
|
45
75
|
<h4>输出</h4>
|
|
46
76
|
<ul>
|
|
47
77
|
<li><b>Compact</b>:NodeId/alias、值、状态和源时间戳。</li>
|
|
@@ -55,7 +85,7 @@
|
|
|
55
85
|
<h3>Subscribe</h3>
|
|
56
86
|
<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
87
|
<h4>Input and commands</h4>
|
|
58
|
-
<p>Point lists may come from
|
|
88
|
+
<p>Save or import a JSON/CSV point list to start automatically after deploy. Point lists may also come from messages 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. Repeating an unchanged list does not rebuild the server Subscription.</p>
|
|
59
89
|
<h4>Timing and queues</h4>
|
|
60
90
|
<ul>
|
|
61
91
|
<li><b>Sampling</b>: Requested interval at which the server checks a MonitoredItem.</li>
|
|
@@ -64,6 +94,7 @@
|
|
|
64
94
|
<li>The server may revise requested intervals to supported values.</li>
|
|
65
95
|
</ul>
|
|
66
96
|
<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>
|
|
97
|
+
<p>With the Siemens WinCC Connection profile, each Subscription is conservatively capped at 1000 MonitoredItems when the server advertises no usable limit, and unsupported Deadband filters are removed automatically. Status output reports the active compatibility behavior.</p>
|
|
67
98
|
<h4>Output</h4>
|
|
68
99
|
<ul>
|
|
69
100
|
<li><b>Compact</b>: NodeId/alias, value, status, and source timestamp.</li>
|
|
@@ -77,7 +108,8 @@
|
|
|
77
108
|
RED.nodes.registerType('tier0-opcua-subscribe', {
|
|
78
109
|
category: 'OPC UA', color: '#c7e9f1', inputs: 1, outputs: 1, icon: 'bridge.png',
|
|
79
110
|
defaults: {
|
|
80
|
-
connection: { value: '', type: 'tier0-opcua-connection', required: true },
|
|
111
|
+
connection: { value: '', type: 'tier0-opcua-connection', required: true },
|
|
112
|
+
nodeId: { value: '' }, pointList: { value: '' }, pointListFileName: { value: '' },
|
|
81
113
|
publishingInterval: { value: 1000, validate: RED.validators.number() }, samplingInterval: { value: 1000, validate: RED.validators.number() },
|
|
82
114
|
queueSize: { value: 1, validate: RED.validators.number() }, discardOldest: { value: true },
|
|
83
115
|
deadbandType: { value: 'none' }, deadbandValue: { value: 0, validate: RED.validators.number() },
|
|
@@ -91,6 +123,134 @@
|
|
|
91
123
|
lifetimeCount: { value: 120 }, maxKeepAliveCount: { value: 10 }, maxNotificationsPerPublish: { value: 0 },
|
|
92
124
|
reconnectInterval: { value: 5000 }, name: { value: '' }
|
|
93
125
|
},
|
|
126
|
+
oneditprepare: function () {
|
|
127
|
+
const points = new Map();
|
|
128
|
+
|
|
129
|
+
const csvLine = (line) => {
|
|
130
|
+
const fields = [];
|
|
131
|
+
let value = '';
|
|
132
|
+
let quoted = false;
|
|
133
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
134
|
+
const character = line[index];
|
|
135
|
+
if (character === '"') {
|
|
136
|
+
if (quoted && line[index + 1] === '"') { value += '"'; index += 1; }
|
|
137
|
+
else quoted = !quoted;
|
|
138
|
+
} else if (character === ',' && !quoted) {
|
|
139
|
+
fields.push(value.trim()); value = '';
|
|
140
|
+
} else value += character;
|
|
141
|
+
}
|
|
142
|
+
fields.push(value.trim());
|
|
143
|
+
return fields;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const parse = (content) => {
|
|
147
|
+
const text = String(content || '').replace(/^\uFEFF/, '').trim();
|
|
148
|
+
if (!text) return [];
|
|
149
|
+
if (text.startsWith('[') || text.startsWith('{')) {
|
|
150
|
+
const parsed = JSON.parse(text);
|
|
151
|
+
return Array.isArray(parsed) ? parsed : (Array.isArray(parsed.items) ? parsed.items : [parsed]);
|
|
152
|
+
}
|
|
153
|
+
const lines = text.split(/\r?\n/).filter((line) => line.trim() && !line.trimStart().startsWith('#'));
|
|
154
|
+
if (!lines.length) return [];
|
|
155
|
+
const headers = csvLine(lines.shift());
|
|
156
|
+
if (!headers.includes('nodeId')) throw new Error('CSV header must contain nodeId');
|
|
157
|
+
return lines.map((line) => {
|
|
158
|
+
const values = csvLine(line);
|
|
159
|
+
return Object.fromEntries(headers.map((header, index) => [header, values[index] || '']));
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const normalize = (item) => {
|
|
164
|
+
const source = typeof item === 'string' ? { nodeId: item } : { ...(item || {}) };
|
|
165
|
+
source.nodeId = String(source.nodeId || source.topic || '').trim();
|
|
166
|
+
if (!source.nodeId) throw new Error('Point is missing nodeId');
|
|
167
|
+
return source;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const persist = () => {
|
|
171
|
+
const items = [...points.values()];
|
|
172
|
+
$('#node-input-pointList').val(items.length ? JSON.stringify(items) : '');
|
|
173
|
+
if (items.length) $('#node-input-nodeId').val('');
|
|
174
|
+
$('#node-input-pointListStatus').text(`${items.length} point${items.length === 1 ? '' : 's'}`);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const render = () => {
|
|
178
|
+
const list = document.getElementById('node-input-pointListItems');
|
|
179
|
+
list.textContent = '';
|
|
180
|
+
if (!points.size) {
|
|
181
|
+
const empty = document.createElement('li');
|
|
182
|
+
empty.className = 'opcua-point-empty';
|
|
183
|
+
empty.textContent = 'No saved points. Add a NodeId, import a file, or supply points by message.';
|
|
184
|
+
list.appendChild(empty);
|
|
185
|
+
} else {
|
|
186
|
+
const fragment = document.createDocumentFragment();
|
|
187
|
+
points.forEach((point, nodeId) => {
|
|
188
|
+
const row = document.createElement('li');
|
|
189
|
+
const text = document.createElement('span');
|
|
190
|
+
text.className = 'opcua-point-text';
|
|
191
|
+
text.textContent = point.alias ? `${point.alias} — ${nodeId}` : nodeId;
|
|
192
|
+
text.title = text.textContent;
|
|
193
|
+
const remove = document.createElement('button');
|
|
194
|
+
remove.type = 'button';
|
|
195
|
+
remove.className = 'red-ui-button opcua-point-remove';
|
|
196
|
+
remove.title = `Remove ${nodeId}`;
|
|
197
|
+
remove.innerHTML = '<i class="fa fa-times"></i>';
|
|
198
|
+
remove.addEventListener('click', () => { points.delete(nodeId); render(); });
|
|
199
|
+
row.append(text, remove);
|
|
200
|
+
fragment.appendChild(row);
|
|
201
|
+
});
|
|
202
|
+
list.appendChild(fragment);
|
|
203
|
+
}
|
|
204
|
+
persist();
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const merge = (items) => {
|
|
208
|
+
items.forEach((item) => { const point = normalize(item); points.set(point.nodeId, point); });
|
|
209
|
+
render();
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const stored = $('#node-input-pointList').val();
|
|
214
|
+
if (stored) merge(parse(stored));
|
|
215
|
+
else if ($('#node-input-nodeId').val()) merge([{ nodeId: $('#node-input-nodeId').val() }]);
|
|
216
|
+
else render();
|
|
217
|
+
} catch (error) {
|
|
218
|
+
RED.notify(`Cannot load point list: ${error.message}`, 'error');
|
|
219
|
+
render();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
$('#node-input-addPoint').on('click', () => {
|
|
223
|
+
try {
|
|
224
|
+
merge([{ nodeId: $('#node-input-newPointNodeId').val() }]);
|
|
225
|
+
$('#node-input-newPointNodeId').val('').focus();
|
|
226
|
+
} catch (error) { RED.notify(error.message, 'error'); }
|
|
227
|
+
});
|
|
228
|
+
$('#node-input-newPointNodeId').on('keydown', function (event) {
|
|
229
|
+
if (event.key === 'Enter') { event.preventDefault(); $('#node-input-addPoint').click(); }
|
|
230
|
+
});
|
|
231
|
+
$('#node-input-importPointList').on('click', () => $('#node-input-pointFile').click());
|
|
232
|
+
$('#node-input-pointFile').on('change', function () {
|
|
233
|
+
const file = this.files && this.files[0];
|
|
234
|
+
if (!file) return;
|
|
235
|
+
const reader = new FileReader();
|
|
236
|
+
reader.onload = () => {
|
|
237
|
+
try {
|
|
238
|
+
const imported = parse(reader.result);
|
|
239
|
+
merge(imported);
|
|
240
|
+
$('#node-input-pointListFileName').val(file.name);
|
|
241
|
+
RED.notify(`${imported.length} point(s) imported`, 'success');
|
|
242
|
+
} catch (error) { RED.notify(`Import failed: ${error.message}`, 'error'); }
|
|
243
|
+
$('#node-input-pointFile').val('');
|
|
244
|
+
};
|
|
245
|
+
reader.readAsText(file);
|
|
246
|
+
});
|
|
247
|
+
$('#node-input-clearPointList').on('click', () => {
|
|
248
|
+
points.clear();
|
|
249
|
+
$('#node-input-nodeId').val('');
|
|
250
|
+
$('#node-input-pointListFileName').val('');
|
|
251
|
+
render();
|
|
252
|
+
});
|
|
253
|
+
},
|
|
94
254
|
label: function () { return this.name || 'tier0-opcua-subscribe'; }
|
|
95
255
|
});
|
|
96
256
|
</script>
|