@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.
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
- const { messagePoints } = require('../lib/points');
3
+ const { looksLikePointList, messagePoints } = require('../lib/points');
4
+ const { SIEMENS_WINCC_DEFAULTS, isSiemensWinCC } = require('../lib/compatibility');
4
5
 
5
6
  const NOTIFICATION_CONFIG_FIELDS = new Set([
6
7
  'nodeId',
@@ -55,6 +56,43 @@ function positiveInteger(value, fallback) {
55
56
  return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
56
57
  }
57
58
 
59
+ function monitoringSettings(point, config, siemensWincc = false) {
60
+ return {
61
+ samplingInterval: Number(point.samplingInterval) || Number(config.samplingInterval) || 1000,
62
+ queueSize: positiveInteger(point.queueSize, positiveInteger(config.queueSize, 1)),
63
+ discardOldest: point.discardOldest !== false,
64
+ deadbandType: siemensWincc ? 'none' : point.deadbandType || config.deadbandType || 'none',
65
+ deadbandValue: siemensWincc ? 0 : Number(point.deadbandValue ?? config.deadbandValue) || 0,
66
+ };
67
+ }
68
+
69
+ function sameMonitoringSettings(left, right, config, siemensWincc = false) {
70
+ const leftSettings = monitoringSettings(left, config, siemensWincc);
71
+ const rightSettings = monitoringSettings(right, config, siemensWincc);
72
+ return Object.keys(leftSettings).every(
73
+ (key) => leftSettings[key] === rightSettings[key]
74
+ );
75
+ }
76
+
77
+ function numericRange(items, field) {
78
+ let count = 0;
79
+ let minimum = Infinity;
80
+ let maximum = -Infinity;
81
+ for (const item of items) {
82
+ if (item[field] == null) continue;
83
+ const value = Number(item[field]);
84
+ if (!Number.isFinite(value)) continue;
85
+ count += 1;
86
+ minimum = Math.min(minimum, value);
87
+ maximum = Math.max(maximum, value);
88
+ }
89
+ return {
90
+ count,
91
+ minimum: count ? minimum : null,
92
+ maximum: count ? maximum : null,
93
+ };
94
+ }
95
+
58
96
  module.exports = function registerSubscribeNode(RED) {
59
97
  function OpcUaSubscribeNode(config) {
60
98
  RED.nodes.createNode(this, config);
@@ -63,6 +101,7 @@ module.exports = function registerSubscribeNode(RED) {
63
101
  node.subscriptions = new Map();
64
102
  node.desired = new Map();
65
103
  node.monitored = new Map();
104
+ node.failed = new Map();
66
105
  node.subscriptionDiagnostics = new Map();
67
106
  node.pendingChanges = [];
68
107
  node.flushTimer = null;
@@ -73,11 +112,24 @@ module.exports = function registerSubscribeNode(RED) {
73
112
  node.closing = false;
74
113
  node.lastDropped = 0;
75
114
  node.lastStatusAt = 0;
115
+ node.compatibilityWarningIssued = false;
116
+ const consumer = node.connection?.registerConsumer?.('subscribe', {
117
+ nodeId: config.id, name: config.name,
118
+ });
76
119
 
77
120
  const batchSize = positiveInteger(config.batchSize, 500);
78
121
  const callbackBatchSize = positiveInteger(config.callbackBatchSize, 500);
79
122
  const callbackFlushIntervalMs = positiveInteger(config.callbackFlushIntervalMs, 50);
80
- const configuredSubscriptionCapacity = positiveInteger(config.maxItemsPerSubscription, 5000);
123
+ const siemensWincc = isSiemensWinCC(
124
+ node.connection?.client?.options?.compatibilityProfile
125
+ );
126
+ const requestedSubscriptionCapacity = positiveInteger(config.maxItemsPerSubscription, 5000);
127
+ const configuredSubscriptionCapacity = siemensWincc
128
+ ? Math.min(
129
+ requestedSubscriptionCapacity,
130
+ SIEMENS_WINCC_DEFAULTS.monitoredItemsPerSubscription
131
+ )
132
+ : requestedSubscriptionCapacity;
81
133
  // Undefined preserves the descriptor-rich output used by Flow JSON created
82
134
  // before resultDetail existed. New editor nodes explicitly save compact.
83
135
  const resultDetail = ['compact', 'value', 'full'].includes(config.resultDetail)
@@ -88,6 +140,24 @@ module.exports = function registerSubscribeNode(RED) {
88
140
  return [...node.subscriptions.keys()];
89
141
  }
90
142
 
143
+ function applyCompatibility(points) {
144
+ if (!siemensWincc) return points;
145
+ let removedDeadband = false;
146
+ const compatible = points.map((point) => {
147
+ const configuredType = point.deadbandType || config.deadbandType || 'none';
148
+ if (configuredType === 'none') return point;
149
+ removedDeadband = true;
150
+ return { ...point, deadbandType: 'none', deadbandValue: 0 };
151
+ });
152
+ if (removedDeadband && !node.compatibilityWarningIssued) {
153
+ node.compatibilityWarningIssued = true;
154
+ node.warn(
155
+ 'Siemens WinCC does not support OPC UA DataChange Deadband filters; Deadband was disabled.'
156
+ );
157
+ }
158
+ return compatible;
159
+ }
160
+
91
161
  function primarySubscriptionId() {
92
162
  return subscriptionIds()[0] ?? null;
93
163
  }
@@ -253,6 +323,9 @@ module.exports = function registerSubscribeNode(RED) {
253
323
  id: null,
254
324
  monitored: new Map(),
255
325
  limit: subscriptionCapacity(),
326
+ revisedPublishingInterval: null,
327
+ revisedLifetimeCount: null,
328
+ revisedMaxKeepAliveCount: null,
256
329
  };
257
330
  const result = await node.connection.client.createSubscription({
258
331
  publishingInterval: Number(config.publishingInterval) || 1000,
@@ -265,6 +338,9 @@ module.exports = function registerSubscribeNode(RED) {
265
338
  overflowPolicy: config.overflowPolicy || 'drop-oldest',
266
339
  }, (batch) => onNativeBatch(record, batch));
267
340
  record.id = result.subscriptionId;
341
+ record.revisedPublishingInterval = result.revisedPublishingInterval ?? null;
342
+ record.revisedLifetimeCount = result.revisedLifetimeCount ?? null;
343
+ record.revisedMaxKeepAliveCount = result.revisedMaxKeepAliveCount ?? null;
268
344
  node.subscriptions.set(record.id, record);
269
345
  return record;
270
346
  }
@@ -278,8 +354,8 @@ module.exports = function registerSubscribeNode(RED) {
278
354
  samplingInterval: Number(config.samplingInterval) || 1000,
279
355
  queueSize: Number(config.queueSize) || 10,
280
356
  discardOldest: config.discardOldest !== false,
281
- deadbandType: config.deadbandType || 'none',
282
- deadbandValue: Number(config.deadbandValue) || 0,
357
+ deadbandType: siemensWincc ? 'none' : config.deadbandType || 'none',
358
+ deadbandValue: siemensWincc ? 0 : Number(config.deadbandValue) || 0,
283
359
  timestampMode: config.timestampMode || 'source',
284
360
  },
285
361
  batchSize
@@ -291,6 +367,8 @@ module.exports = function registerSubscribeNode(RED) {
291
367
  subscriptionId: record.id,
292
368
  monitoredItemId: result.monitoredItemId,
293
369
  clientHandle: result.clientHandle,
370
+ revisedSamplingInterval: result.revisedSamplingInterval ?? null,
371
+ revisedQueueSize: result.revisedQueueSize ?? null,
294
372
  };
295
373
  record.monitored.set(result.nodeId, monitored);
296
374
  node.monitored.set(result.nodeId, monitored);
@@ -343,10 +421,10 @@ module.exports = function registerSubscribeNode(RED) {
343
421
  return failures;
344
422
  }
345
423
 
346
- async function removeUndesiredPoints() {
424
+ async function removeMonitoredPoints(nodeIds) {
347
425
  const grouped = new Map();
348
426
  for (const [nodeId, monitored] of node.monitored) {
349
- if (node.desired.has(nodeId)) continue;
427
+ if (!nodeIds.has(nodeId)) continue;
350
428
  if (!grouped.has(monitored.subscriptionId)) grouped.set(monitored.subscriptionId, []);
351
429
  grouped.get(monitored.subscriptionId).push({ nodeId, ...monitored });
352
430
  }
@@ -367,6 +445,13 @@ module.exports = function registerSubscribeNode(RED) {
367
445
  }
368
446
  }
369
447
 
448
+ async function removeUndesiredPoints() {
449
+ const nodeIds = new Set(
450
+ [...node.monitored.keys()].filter((nodeId) => !node.desired.has(nodeId))
451
+ );
452
+ await removeMonitoredPoints(nodeIds);
453
+ }
454
+
370
455
  async function pruneEmptySubscriptions() {
371
456
  const empty = [...node.subscriptions.values()].filter(
372
457
  (record) => record.monitored.size === 0
@@ -380,7 +465,10 @@ module.exports = function registerSubscribeNode(RED) {
380
465
  }
381
466
 
382
467
  async function clearSubscriptions(keepDesired = false, ignoreErrors = false) {
383
- if (!keepDesired) node.desired.clear();
468
+ if (!keepDesired) {
469
+ node.desired.clear();
470
+ node.failed.clear();
471
+ }
384
472
  discardPendingChanges();
385
473
  const records = [...node.subscriptions.values()];
386
474
  node.subscriptions.clear();
@@ -403,22 +491,54 @@ module.exports = function registerSubscribeNode(RED) {
403
491
  return true;
404
492
  }
405
493
 
406
- function statusPayload(command, failed = 0) {
494
+ function failurePayload() {
495
+ const failures = [...node.failed.values()];
496
+ return {
497
+ failed: failures.length,
498
+ failures: failures.slice(0, 100),
499
+ failuresTruncated: failures.length > 100,
500
+ };
501
+ }
502
+
503
+ function statusPayload(command) {
407
504
  const ids = subscriptionIds();
505
+ const monitoredValues = [...node.monitored.values()];
506
+ const revisedSampling = numericRange(monitoredValues, 'revisedSamplingInterval');
507
+ const revisedQueues = numericRange(monitoredValues, 'revisedQueueSize');
408
508
  return {
409
509
  command,
410
510
  desired: node.desired.size,
411
511
  monitored: node.monitored.size,
412
- failed,
512
+ ...failurePayload(),
413
513
  subscriptionCount: ids.length,
414
514
  subscriptionId: ids[0] ?? null,
415
515
  subscriptionIds: ids,
516
+ revisions: [...node.subscriptions.values()].map((record) => ({
517
+ subscriptionId: record.id,
518
+ publishingInterval: record.revisedPublishingInterval,
519
+ lifetimeCount: record.revisedLifetimeCount,
520
+ maxKeepAliveCount: record.revisedMaxKeepAliveCount,
521
+ })),
522
+ monitoredRevisions: {
523
+ count: Math.max(revisedSampling.count, revisedQueues.count),
524
+ samplingIntervalMin: revisedSampling.minimum,
525
+ samplingIntervalMax: revisedSampling.maximum,
526
+ queueSizeMin: revisedQueues.minimum,
527
+ queueSizeMax: revisedQueues.maximum,
528
+ },
529
+ compatibility: {
530
+ profile: siemensWincc ? 'siemens-wincc' : 'standard',
531
+ deadbandSupported: !siemensWincc,
532
+ monitoredItemsPerSubscription: configuredSubscriptionCapacity,
533
+ },
416
534
  connectionGovernance: node.connection?.getConnectionGovernance?.(),
535
+ connectionUsage: node.connection?.getConnectionUsage?.(),
417
536
  };
418
537
  }
419
538
 
420
539
  async function reconcileSubscriptions(reason = 'state') {
421
540
  if (node.closing) return;
541
+ consumer?.markUsed('subscribe-reconcile');
422
542
  if (!node.desired.size) {
423
543
  if (node.subscriptions.size) await clearSubscriptions(true);
424
544
  return;
@@ -439,15 +559,21 @@ module.exports = function registerSubscribeNode(RED) {
439
559
  (point) => !node.monitored.has(point.nodeId)
440
560
  );
441
561
  const failures = await addDesiredPoints(additions);
442
- await pruneEmptySubscriptions();
443
- if (failures.length) {
444
- throw new Error(`${failures.length} monitored item(s) failed to reconcile`);
562
+ for (const point of additions) node.failed.delete(point.nodeId);
563
+ for (const failure of failures) {
564
+ node.failed.set(failure.nodeId, {
565
+ nodeId: failure.nodeId,
566
+ statusCode: failure.statusCode || 'BadUnexpectedError',
567
+ });
445
568
  }
569
+ await pruneEmptySubscriptions();
446
570
 
447
571
  node.reconcileAttempt = 0;
448
572
  node.status({
449
- fill: 'green', shape: 'dot',
450
- text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub`,
573
+ fill: failures.length ? 'yellow' : 'green',
574
+ shape: failures.length ? 'ring' : 'dot',
575
+ text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub` +
576
+ (failures.length ? ` / ${failures.length} failed` : ''),
451
577
  });
452
578
  if (reason !== 'command') {
453
579
  node.send({
@@ -462,12 +588,25 @@ module.exports = function registerSubscribeNode(RED) {
462
588
  },
463
589
  });
464
590
  }
591
+ return { rebuilt, failures };
465
592
  }
466
593
 
467
594
  async function replacePoints(points) {
468
- node.desired = new Map(points.map((point) => [point.nodeId, point]));
469
- await clearSubscriptions(true);
470
- await reconcileSubscriptions('command');
595
+ const nextDesired = new Map(points.map((point) => [point.nodeId, point]));
596
+ const changed = new Set();
597
+ for (const [nodeId, monitored] of node.monitored) {
598
+ const previous = node.desired.get(nodeId);
599
+ const next = nextDesired.get(nodeId);
600
+ if (!next || !previous || !sameMonitoringSettings(previous, next, config, siemensWincc)) {
601
+ changed.add(nodeId);
602
+ }
603
+ }
604
+ node.desired = nextDesired;
605
+ for (const nodeId of [...node.failed.keys()]) {
606
+ if (!node.desired.has(nodeId)) node.failed.delete(nodeId);
607
+ }
608
+ await removeMonitoredPoints(changed);
609
+ return reconcileSubscriptions('command');
471
610
  }
472
611
 
473
612
  function runSerialized(task) {
@@ -517,28 +656,49 @@ module.exports = function registerSubscribeNode(RED) {
517
656
  if (!node.connection) return done(new Error('OPC UA connection not configured'));
518
657
  try {
519
658
  const command = msg.opcua?.command || 'replace';
520
- const points = command === 'clear' ? [] : messagePoints(msg, config.nodeId, {
659
+ const dynamicNodeId = msg.nodeId || msg.topic;
660
+ const configuredPayload = !looksLikePointList(msg.payload) && !Array.isArray(msg.items) &&
661
+ !dynamicNodeId ? config.pointList : '';
662
+ const staticNodeId = dynamicNodeId ? '' : config.nodeId;
663
+ const points = command === 'clear' ? [] : applyCompatibility(messagePoints(msg, staticNodeId, {
521
664
  samplingInterval: Number(config.samplingInterval) || 1000,
522
665
  queueSize: Number(config.queueSize) || 10,
523
666
  discardOldest: config.discardOldest !== false,
524
- });
667
+ }, {
668
+ configuredPayload,
669
+ format: 'auto',
670
+ }));
525
671
 
526
672
  node.status({ fill: 'blue', shape: 'dot', text: `${command} ${points.length}` });
527
673
  await runSerialized(async () => {
528
674
  if (command === 'replace') await replacePoints(points);
529
675
  else if (command === 'add') {
530
- points.forEach((point) => node.desired.set(point.nodeId, point));
676
+ const changed = new Set();
677
+ points.forEach((point) => {
678
+ const previous = node.desired.get(point.nodeId);
679
+ if (previous && !sameMonitoringSettings(previous, point, config, siemensWincc)) {
680
+ changed.add(point.nodeId);
681
+ }
682
+ node.desired.set(point.nodeId, point);
683
+ node.failed.delete(point.nodeId);
684
+ });
685
+ await removeMonitoredPoints(changed);
531
686
  await reconcileSubscriptions('command');
532
687
  } else if (command === 'remove') {
533
- points.forEach((point) => node.desired.delete(point.nodeId));
688
+ points.forEach((point) => {
689
+ node.desired.delete(point.nodeId);
690
+ node.failed.delete(point.nodeId);
691
+ });
534
692
  await reconcileSubscriptions('command');
535
693
  } else if (command === 'clear') await clearSubscriptions();
536
694
  else throw new Error(`Unsupported subscription command: ${command}`);
537
695
  });
538
696
 
697
+ const failed = node.failed.size;
539
698
  node.status({
540
- fill: 'green', shape: 'dot',
541
- text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub`,
699
+ fill: failed ? 'yellow' : 'green', shape: failed ? 'ring' : 'dot',
700
+ text: `${node.monitored.size} monitored / ${node.subscriptions.size} sub` +
701
+ (failed ? ` / ${failed} failed` : ''),
542
702
  });
543
703
  send({
544
704
  ...msg,
@@ -560,6 +720,8 @@ module.exports = function registerSubscribeNode(RED) {
560
720
  node.connection?.client.off('fault', onFault);
561
721
  node.connection?.client.off('state', onState);
562
722
  node.desired.clear();
723
+ node.failed.clear();
724
+ consumer?.release();
563
725
  await runSerialized(() => clearSubscriptions());
564
726
  }
565
727
 
@@ -574,6 +736,39 @@ module.exports = function registerSubscribeNode(RED) {
574
736
  }
575
737
  done();
576
738
  });
739
+
740
+ const configuredPoints = String(config.pointList || '').trim() ||
741
+ String(config.nodeId || '').trim();
742
+ if (configuredPoints) {
743
+ setImmediate(() => {
744
+ if (node.closing) return;
745
+ let points;
746
+ try {
747
+ points = applyCompatibility(messagePoints({}, config.nodeId, {
748
+ samplingInterval: Number(config.samplingInterval) || 1000,
749
+ queueSize: Number(config.queueSize) || 10,
750
+ discardOldest: config.discardOldest !== false,
751
+ }, {
752
+ configuredPayload: config.pointList,
753
+ format: 'auto',
754
+ }));
755
+ } catch (error) {
756
+ node.status({ fill: 'red', shape: 'ring', text: error.message });
757
+ node.warn(`OPC UA saved subscription point list is invalid: ${error.message}`);
758
+ return;
759
+ }
760
+ void runSerialized(() => replacePoints(points)).then(() => {
761
+ node.send({
762
+ payload: statusPayload('startup'),
763
+ opcua: { operation: 'subscription-status', startup: true },
764
+ });
765
+ }).catch((error) => {
766
+ node.status({ fill: 'yellow', shape: 'ring', text: 'waiting to subscribe' });
767
+ node.warn(`OPC UA startup subscription failed: ${error.message}`);
768
+ scheduleReconnect('startup');
769
+ });
770
+ });
771
+ }
577
772
  }
578
773
 
579
774
  RED.nodes.registerType('tier0-opcua-subscribe', OpcUaSubscribeNode);
package/nodes/write.html CHANGED
@@ -54,6 +54,7 @@
54
54
  <li>单点:NodeId 来自节点配置、<code>msg.nodeId</code> 或 <code>msg.topic</code>,写入值来自 <code>msg.payload</code>。</li>
55
55
  <li>批量:通过编辑器、JSON 或 CSV 提供 <code>nodeId</code>、<code>value</code> 和可选 <code>dataType</code>。</li>
56
56
  <li>CSV 必须包含 <code>nodeId</code> 和 <code>value</code>;未指定类型时使用节点默认类型。</li>
57
+ <li>Int64/UInt64 建议使用十进制字符串;超范围、小数和不安全的 Number 会在发送前被拒绝。</li>
57
58
  <li><code>indexRange</code> 可部分写入数组或矩阵。</li>
58
59
  </ul>
59
60
  <h4>输出</h4>
@@ -68,6 +69,7 @@
68
69
  <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
70
  <li>Batch: Supply <code>nodeId</code>, <code>value</code>, and optional <code>dataType</code> through the editor, JSON, or CSV.</li>
70
71
  <li>CSV requires <code>nodeId</code> and <code>value</code>. The node default type is used when <code>dataType</code> is omitted.</li>
72
+ <li>Use decimal strings for Int64/UInt64. Out-of-range, fractional, and unsafe Number values are rejected before transmission.</li>
71
73
  <li>Use <code>indexRange</code> for partial array or matrix writes.</li>
72
74
  </ul>
73
75
  <h4>Output</h4>
package/nodes/write.js CHANGED
@@ -7,11 +7,15 @@ module.exports = function registerWriteNode(RED) {
7
7
  RED.nodes.createNode(this, config);
8
8
  const node = this;
9
9
  node.connection = RED.nodes.getNode(config.connection);
10
+ const consumer = node.connection?.registerConsumer?.('write', {
11
+ nodeId: config.id, name: config.name,
12
+ });
10
13
 
11
14
  node.on('input', async (msg, send, done) => {
12
15
  if (!node.connection) return done(new Error('OPC UA connection not configured'));
13
16
 
14
17
  try {
18
+ consumer?.markUsed('write');
15
19
  const points = messageWritePoints(msg, config);
16
20
  node.status({ fill: 'blue', shape: 'dot', text: `writing ${points.length}` });
17
21
  const results = await node.connection.client.writeMany(
@@ -41,6 +45,12 @@ module.exports = function registerWriteNode(RED) {
41
45
  done(error);
42
46
  }
43
47
  });
48
+
49
+ node.on('close', (removed, done) => {
50
+ if (typeof removed === 'function') done = removed;
51
+ consumer?.release();
52
+ done?.();
53
+ });
44
54
  }
45
55
 
46
56
  RED.nodes.registerType('tier0-opcua-write', OpcUaWriteNode);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tier0/opcua-client",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "High-scale OPC UA client nodes for Node-RED powered by open62541",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -38,7 +38,8 @@
38
38
  "test:stress": "node test/stress.js",
39
39
  "test:nodered": "node test/nodered.integration.js",
40
40
  "test:nodered:reconnect": "OPCUA_NODERED_RECONNECT=1 node test/nodered.integration.js",
41
- "prepack": "node scripts/verify-offline-package.js"
41
+ "test:release": "npm test && npm run test:native && npm run test:offline-runtime && npm run test:nodered",
42
+ "prepack": "npm test && node scripts/verify-offline-package.js"
42
43
  },
43
44
  "keywords": [
44
45
  "node-red",
@@ -17,4 +17,24 @@ if (missing.length) {
17
17
  process.exit(1);
18
18
  }
19
19
 
20
- console.log('Offline package inputs verified');
20
+ const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
21
+ const prebuildSize = fs.statSync(
22
+ path.join(root, 'prebuilds/linux-x64-musl/opcua_open62541.node')
23
+ ).size;
24
+ const sourceSize = fs.statSync(
25
+ path.join(root, 'vendor/open62541-v1.4.16.tar.gz')
26
+ ).size;
27
+ if (!packageJson.files?.includes('prebuilds') || !packageJson.files?.includes('vendor')) {
28
+ throw new Error('package.json files must include prebuilds and vendor for offline installation');
29
+ }
30
+ if (!packageJson.bundledDependencies?.includes('node-addon-api')) {
31
+ throw new Error('node-addon-api must remain a bundled dependency');
32
+ }
33
+ if (prebuildSize < 100000 || sourceSize < 1000000) {
34
+ throw new Error('Bundled native addon or open62541 source archive is unexpectedly small');
35
+ }
36
+
37
+ console.log(
38
+ `Offline package inputs verified for ${packageJson.name}@${packageJson.version}: ` +
39
+ `prebuild=${prebuildSize} bytes, open62541=${sourceSize} bytes`
40
+ );