@yarkivaev/scada 2.3.115 → 2.4.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.
Files changed (104) hide show
  1. package/README.md +158 -0
  2. package/charts/scada/templates/processes.yaml +22 -0
  3. package/charts/scada/values.schema.json +12 -0
  4. package/db/migrations/V0009__collector_task_state.sql +6 -0
  5. package/package.json +14 -5
  6. package/src/application/deploy/collectorProcesses.js +3 -2
  7. package/src/application/deploy/readDocument.js +4 -1
  8. package/src/application/plantServer.js +1 -1
  9. package/src/application/runtime/additionalAdapters.js +139 -0
  10. package/src/application/runtime/assignment.js +30 -0
  11. package/src/application/runtime/budgetedSink.js +17 -0
  12. package/src/application/runtime/collectorSignals.js +14 -0
  13. package/src/application/runtime/compositeLifecycle.js +49 -0
  14. package/src/application/runtime/createRegistry.js +20 -0
  15. package/src/application/runtime/deadline.js +8 -0
  16. package/src/application/runtime/index.js +10 -0
  17. package/src/application/runtime/manifest.js +38 -0
  18. package/src/application/runtime/pipelineAdapters.js +60 -0
  19. package/src/application/runtime/resolveSecrets.js +12 -0
  20. package/src/application/runtime/resourceClaims.js +21 -0
  21. package/src/application/runtime/runCollector.js +50 -0
  22. package/src/application/runtime/runtime.js +86 -0
  23. package/src/application/runtime/runtimeHealth.js +29 -0
  24. package/src/application/runtime/runtimeServices.js +15 -0
  25. package/src/application/runtime/stateDocument.js +22 -0
  26. package/src/application/runtime/streamPorts.js +42 -0
  27. package/src/application/runtime/streamTask.js +105 -0
  28. package/src/application/runtime/taskBudget.js +31 -0
  29. package/src/application/runtime/taskContext.js +18 -0
  30. package/src/application/runtime/taskQueue.js +21 -0
  31. package/src/application/runtime/taskSlot.js +46 -0
  32. package/src/application/runtime/taskState.js +73 -0
  33. package/src/application/siteCollection.js +14 -0
  34. package/src/application/siteServer.js +7 -6
  35. package/src/application/siteTelemetry.js +1 -1
  36. package/src/application/supervisorSink.js +9 -8
  37. package/src/application/sync/siteSyncBind.js +1 -1
  38. package/src/application/sync/siteSyncTargets.js +1 -1
  39. package/src/bin/collector.js +7 -0
  40. package/src/bin/modbus-to-mqtt.js +11 -9
  41. package/src/infrastructure/deploy/executeRelease.js +45 -3
  42. package/src/infrastructure/ingest/activity/activityTracking.js +15 -21
  43. package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
  44. package/src/infrastructure/ingest/durableConsume.js +2 -169
  45. package/src/infrastructure/ingest/ingestBatch.js +13 -0
  46. package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
  47. package/src/infrastructure/ingest/modbus/silentStreams.js +39 -16
  48. package/src/infrastructure/ingest/mqtt/metricsTransformer.js +4 -5
  49. package/src/infrastructure/ingest/mqtt/modbusMqtt.js +17 -19
  50. package/src/infrastructure/ingest/mqtt/modbusPublish.js +4 -0
  51. package/src/infrastructure/ingest/mqtt/mqttMetrics.js +17 -21
  52. package/src/infrastructure/ingest/mqtt/registerPublish.js +3 -3
  53. package/src/infrastructure/ingest/pipelines/alertPipeline.js +7 -6
  54. package/src/infrastructure/ingest/pipelines/decisionPipeline.js +7 -6
  55. package/src/infrastructure/ingest/pipelines/segmentPipeline.js +8 -17
  56. package/src/infrastructure/ingest/pipelines/stompOptions.js +7 -0
  57. package/src/infrastructure/ingest/silentOpenWatch.js +5 -42
  58. package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +16 -28
  59. package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +17 -22
  60. package/src/infrastructure/ingest/telemetry/foldingSink.js +5 -2
  61. package/src/infrastructure/messaging/stomp/segmentRetags.js +1 -1
  62. package/src/infrastructure/messaging/stomp/userDecisions.js +1 -1
  63. package/src/infrastructure/opcua/certificates.js +11 -0
  64. package/src/infrastructure/opcua/config.js +38 -0
  65. package/src/infrastructure/opcua/delivery.js +38 -0
  66. package/src/infrastructure/opcua/endpoint.js +90 -0
  67. package/src/infrastructure/opcua/index.js +10 -0
  68. package/src/infrastructure/opcua/session.js +70 -0
  69. package/src/infrastructure/opcua/source.js +38 -0
  70. package/src/infrastructure/opcua/subscriptionHealth.js +28 -0
  71. package/src/infrastructure/operators/operatorsSync.js +5 -29
  72. package/src/infrastructure/persistence/clickhouse/streamHub.js +21 -15
  73. package/src/infrastructure/persistence/metricsSensor.js +9 -6
  74. package/src/infrastructure/persistence/runtime/taskStatePg.js +24 -0
  75. package/src/infrastructure/streams/README.md +291 -0
  76. package/src/infrastructure/streams/abortable.js +14 -0
  77. package/src/infrastructure/streams/amqpSink/publication.js +46 -0
  78. package/src/infrastructure/streams/amqpSink/session.js +96 -0
  79. package/src/infrastructure/streams/amqpSink.js +47 -0
  80. package/src/infrastructure/streams/amqpSource/session.js +127 -0
  81. package/src/infrastructure/streams/amqpSource.js +97 -0
  82. package/src/infrastructure/streams/brokerClose.js +19 -0
  83. package/src/infrastructure/streams/budgetAccept.js +6 -0
  84. package/src/infrastructure/streams/clickhouseSink.js +48 -0
  85. package/src/infrastructure/streams/deadline.js +1 -0
  86. package/src/infrastructure/streams/follow.js +41 -0
  87. package/src/infrastructure/streams/index.js +15 -0
  88. package/src/infrastructure/streams/lokiSource.js +88 -0
  89. package/src/infrastructure/streams/modbusConnection.js +173 -0
  90. package/src/infrastructure/streams/modbusRtuBusSource.js +124 -0
  91. package/src/infrastructure/streams/modbusSource.js +185 -0
  92. package/src/infrastructure/streams/mqttSink/session.js +51 -0
  93. package/src/infrastructure/streams/mqttSink.js +54 -0
  94. package/src/infrastructure/streams/mqttSource.js +66 -0
  95. package/src/infrastructure/streams/pollingSource.js +1 -0
  96. package/src/infrastructure/streams/positive.js +5 -0
  97. package/src/infrastructure/streams/postgresSink.js +124 -0
  98. package/src/infrastructure/streams/serialReceive.js +80 -0
  99. package/src/infrastructure/streams/serialSource.js +87 -0
  100. package/src/infrastructure/streams/sqliteSink.js +55 -0
  101. package/src/infrastructure/streams/stompBody.js +46 -0
  102. package/src/infrastructure/streams/stompSend.js +89 -0
  103. package/src/infrastructure/streams/stompSource.js +201 -0
  104. package/src/infrastructure/sync/operationSyncIngest.js +4 -16
@@ -0,0 +1,97 @@
1
+ import amqpSession from './amqpSource/session.js';
2
+ import budgetAccept from './budgetAccept.js';
3
+
4
+ function capacity(options) {
5
+ const settings = { prefetch: 32, maxMessageBytes: 1048576, ...options };
6
+ settings.maxPendingBytes ??= settings.prefetch * settings.maxMessageBytes;
7
+ ['prefetch', 'maxMessageBytes', 'maxPendingBytes'].forEach((name) => {
8
+ if (!Number.isSafeInteger(settings[name]) || settings[name] < 1) { throw new TypeError(`${name} must be a positive safe integer`); }
9
+ });
10
+ return settings;
11
+ }
12
+
13
+ function deliveryFor(channel, message, options) {
14
+ let settled = false;
15
+ return {
16
+ content: message.content, fields: message.fields, properties: message.properties,
17
+ ack() {
18
+ if (!settled && options.active()) { channel.ack(message); settled = true; }
19
+ },
20
+ reject(requeue = true) {
21
+ if (!settled && options.active()) { channel.nack(message, false, requeue); settled = true; }
22
+ }
23
+ };
24
+ }
25
+ function reportFailure(options, error) {
26
+ const report = options.onError || options.report;
27
+ try {
28
+ if (report) { report(error); }
29
+ else { process.stderr.write(`amqp delivery failed ${error.message}\n`); }
30
+ } catch {
31
+ // Error reporting must not reject a broker callback.
32
+ }
33
+ }
34
+ function rejectDelivery(delivery, options, error) {
35
+ try { delivery.reject(true); }
36
+ catch (settlementError) { reportFailure(options, settlementError); }
37
+ reportFailure(options, error);
38
+ }
39
+ function consume(channel, collector, options) {
40
+ return async (message) => {
41
+ if (!message) { channel.emit('close'); return; }
42
+ const delivery = deliveryFor(channel, message, options);
43
+ try {
44
+ await budgetAccept(collector, delivery, message.content.length, options);
45
+ if (options.ackMode !== 'manual') { delivery.ack(); }
46
+ } catch (error) {
47
+ rejectDelivery(delivery, options, error);
48
+ }
49
+ };
50
+ }
51
+ function trackedConsume(channel, collector, options, pending) {
52
+ const accept = consume(channel, collector, options);
53
+ const state = { bytes: 0 };
54
+ return (message) => {
55
+ if (!options.active()) { return Promise.resolve(); }
56
+ const bytes = message?.content?.length ?? 0;
57
+ if (bytes > options.maxMessageBytes || state.bytes + bytes > options.maxPendingBytes) {
58
+ try { channel.nack(message, false, false); } catch (error) { reportFailure(options, error); }
59
+ reportFailure(options, new Error('AMQP payload capacity exceeded; rejected without requeue'));
60
+ return Promise.resolve();
61
+ }
62
+ state.bytes += bytes;
63
+ const delivery = accept(message);
64
+ pending.add(delivery);
65
+ return delivery.finally(() => { state.bytes -= bytes; pending.delete(delivery); });
66
+ };
67
+ }
68
+ async function topology(channel, queue, options) {
69
+ await Promise.all((options.exchanges || []).map((exchange) => {
70
+ return channel.assertExchange(exchange.name, exchange.type, exchange.options);
71
+ }));
72
+ const declared = await channel.assertQueue(queue, options.queueOptions || { durable: true });
73
+ const name = declared?.queue || queue;
74
+ await Promise.all((options.bindings || []).map((binding) => {
75
+ return channel.bindQueue(name, binding.exchange, binding.pattern, binding.args);
76
+ }));
77
+ await channel.prefetch(options.prefetch || 32);
78
+ return name;
79
+ }
80
+ /** Delivers AMQP records, acknowledging only after collector acceptance. */
81
+ export default function amqpSource(url, queue, collector, options = {}) {
82
+ if (!collector || typeof collector.accept !== 'function') { throw new Error('Collector must have an accept() method'); }
83
+ const settings = capacity(options);
84
+ const pending = new Set();
85
+ const state = {};
86
+ const session = amqpSession(url, async (channel) => {
87
+ const generation = state.session.generation();
88
+ const config = { ...settings, active() {
89
+ return state.session.active() && state.session.generation() === generation;
90
+ } };
91
+ const name = await topology(channel, queue, settings);
92
+ const result = await channel.consume(name, trackedConsume(channel, collector, config, pending), { noAck: false });
93
+ return result.consumerTag;
94
+ }, { ...settings, drain() { return Promise.all([...pending]); } });
95
+ state.session = session;
96
+ return session;
97
+ }
@@ -0,0 +1,19 @@
1
+ import deadline from './deadline.js';
2
+
3
+ /** Attempt every cleanup stage; an uncertain stage prevents reuse even if later cleanup succeeds. */
4
+ export default async function brokerClose(actions, timeoutMs, label, destroy) {
5
+ const failures = [];
6
+ for (const action of actions) {
7
+ try {
8
+ // Cleanup order matters, but one hung stage must not skip transport closure.
9
+ // eslint-disable-next-line no-await-in-loop
10
+ await deadline(Promise.resolve().then(action), timeoutMs, label, destroy);
11
+ } catch (error) {
12
+ if (error.name !== 'IllegalOperationError') { failures.push(error); }
13
+ }
14
+ }
15
+ if (failures.length) { throw failures[0]; }
16
+ }
17
+ export function destroyConnection(connection) {
18
+ connection?.connection?.stream?.destroy();
19
+ }
@@ -0,0 +1,6 @@
1
+ /** Budget leases belong to actual acceptance, never to a timeout wrapper. */
2
+ export default async function budgetAccept(collector, record, bytes, options, context) {
3
+ const release = options.budget?.reserve({ bytes, records: 1 });
4
+ try { return await collector.accept(record, context); }
5
+ finally { release?.(); }
6
+ }
@@ -0,0 +1,48 @@
1
+ import { createClient } from '@clickhouse/client';
2
+ import deadline from './deadline.js';
3
+ import positive from './positive.js';
4
+
5
+ /**
6
+ * ClickHouse sink for batch record insertion.
7
+ *
8
+ * Creates a ClickHouse client internally and implements the Sink
9
+ * interface for use with batch collectors.
10
+ *
11
+ * @example
12
+ * const sink = clickhouseSink('http://localhost:8123', 'metrics');
13
+ * await sink.write([{ ts: Date.now(), value: 42 }]);
14
+ *
15
+ * @param {string} url - ClickHouse URL (e.g., 'http://localhost:8123')
16
+ * @param {string} table - Target table name
17
+ * @returns {object} Sink with write(records) method
18
+ */
19
+ export default function clickhouseSink(url, table, options = {}) {
20
+ if (typeof url !== 'string' || url.length === 0) {
21
+ throw new Error('URL must be a non-empty string');
22
+ }
23
+ if (typeof table !== 'string' || table.length === 0) {
24
+ throw new Error('Table must be a non-empty string');
25
+ }
26
+ positive(options.stopTimeoutMs ?? 5000, 'stopTimeoutMs');
27
+ const client = (options.createClient || createClient)({ url });
28
+ let stopping;
29
+ return {
30
+ /**
31
+ * Writes records to ClickHouse table.
32
+ *
33
+ * @param {Array} records - Array of records to insert
34
+ * @returns {Promise} Promise resolving when insert completes
35
+ */
36
+ write(records, { signal } = {}) {
37
+ if (stopping) { return Promise.reject(new Error('ClickHouse sink stopped')); }
38
+ if (signal?.aborted) { return Promise.reject(signal.reason || new Error('ClickHouse write aborted')); }
39
+ // Native ClickHouse API uses snake_case for cancellation.
40
+
41
+ return client.insert({ table, values: records, format: 'JSONEachRow', abort_signal: signal });
42
+ },
43
+ stop() {
44
+ stopping ??= deadline(Promise.resolve().then(() => { return client.close(); }), options.stopTimeoutMs ?? 5000, 'ClickHouse close');
45
+ return stopping;
46
+ }
47
+ };
48
+ }
@@ -0,0 +1 @@
1
+ export { deadline as default } from '@yarkivaev/source-to-sink';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Continues a delivery chain after a rejected prior step.
3
+ *
4
+ * @param {Promise} pending - prior chain
5
+ * @returns {Promise} settled chain
6
+ */
7
+ function resume(pending) {
8
+ return pending.then((value) => {
9
+ return value;
10
+ }, (error) => {
11
+ return error;
12
+ });
13
+ }
14
+
15
+ /**
16
+ * Chains one arrival: read the body, then start deliver.
17
+ * Serial waits for deliver. Otherwise persist may overlap after enqueue.
18
+ *
19
+ * @param {Promise} pending - prior arrival
20
+ * @param {Function} read - returns a promise for the body
21
+ * @param {Function} deliver - starts persist for one body
22
+ * @param {boolean} serial - wait for persist before the next arrival
23
+ * @returns {Promise} chain
24
+ *
25
+ * @example
26
+ * let pending = Promise.resolve();
27
+ * pending = follow(pending, () => Promise.resolve('a'), write, false);
28
+ */
29
+ export default function follow(pending, read, deliver, serial) {
30
+ return resume(pending).then(async () => {
31
+ const body = await read();
32
+ const run = deliver(body);
33
+ if (serial) {
34
+ return run;
35
+ }
36
+ void run.catch((error) => {
37
+ return error;
38
+ });
39
+ return undefined;
40
+ });
41
+ }
@@ -0,0 +1,15 @@
1
+ /** Protocol and storage adapters composed with source-to-sink primitives. */
2
+ export { default as clickhouseSink } from './clickhouseSink.js';
3
+ export { default as postgresSink } from './postgresSink.js';
4
+ export { default as mqttSource } from './mqttSource.js';
5
+ export { default as stompSource } from './stompSource.js';
6
+ export { default as stompSend } from './stompSend.js';
7
+ export { default as lokiSource } from './lokiSource.js';
8
+ export { default as modbusSource, modbusRtuSource } from './modbusSource.js';
9
+ export { default as modbusRtuBusSource } from './modbusRtuBusSource.js';
10
+ export { default as mqttSink } from './mqttSink.js';
11
+ export { default as sqliteSink } from './sqliteSink.js';
12
+ export { default as amqpSink } from './amqpSink.js';
13
+ export { default as amqpSource } from './amqpSource.js';
14
+ export { default as amqpSession } from './amqpSource/session.js';
15
+ export { default as serialSource } from './serialSource.js';
@@ -0,0 +1,88 @@
1
+ import pollingSource from './pollingSource.js';
2
+ import positive from './positive.js';
3
+
4
+ function config(options) {
5
+ const settings = { requestTimeoutMs: 5000, maxResponseBytes: 1048576, pageLimit: 1000, queryLagMs: 1000, ...options };
6
+ ['requestTimeoutMs', 'maxResponseBytes', 'pageLimit'].forEach((key) => { positive(settings[key], key); });
7
+ if (!Number.isSafeInteger(settings.pageLimit) || settings.pageLimit > 10000 || !Number.isSafeInteger(settings.maxResponseBytes)) {
8
+ throw new TypeError('Invalid Loki response capacity');
9
+ }
10
+ if (!Number.isSafeInteger(settings.queryLagMs) || settings.queryLagMs < 0) { throw new TypeError('Invalid Loki queryLagMs'); }
11
+ return settings;
12
+ }
13
+ async function body(response, maxBytes) {
14
+ if (!response.ok) { throw new Error(`Loki HTTP status ${response.status}`); }
15
+ if (!response.body) { throw new Error('Loki response body missing'); }
16
+ const chunks = [];
17
+ let bytes = 0;
18
+ for await (const chunk of response.body) {
19
+ bytes += chunk.byteLength;
20
+ if (bytes > maxBytes) { throw new Error('Loki response capacity exceeded'); }
21
+ chunks.push(chunk);
22
+ }
23
+ return JSON.parse(Buffer.concat(chunks, bytes).toString());
24
+ }
25
+ function entries(data, limit) {
26
+ if (data.status !== 'success' || !Array.isArray(data.data?.result) || data.data.resultType !== 'streams') {
27
+ throw new Error('Invalid Loki stream response');
28
+ }
29
+ const rows = data.data.result.flatMap((stream) => {
30
+ if (!Array.isArray(stream.values)) { throw new Error('Invalid Loki stream values'); }
31
+ return stream.values;
32
+ });
33
+ // A full page may hide older/equal-timestamp rows. Do not deliver or advance a partial window.
34
+ if (rows.length >= limit) { throw new Error('Loki query page saturated; history incomplete, cursor unchanged'); }
35
+ return rows.map(([ts, line]) => {
36
+ if (typeof ts !== 'string' || !/^\d+$/u.test(ts) || typeof line !== 'string') { throw new Error('Invalid Loki log entry'); }
37
+ return { ts: Number(ts) / 1000000, line };
38
+ });
39
+ }
40
+ function requestUrl(url, query, range, limit) {
41
+ const params = new URLSearchParams({ query, start: (range.since * 1000000).toString(),
42
+ end: (range.until * 1000000).toString(), limit: String(limit), direction: 'forward' });
43
+ return `${url}/loki/api/v1/query_range?${params}`;
44
+ }
45
+ async function request(url, settings, signal) {
46
+ const controller = new AbortController();
47
+ function abort() { controller.abort(signal.reason); }
48
+ if (signal?.aborted) { abort(); }
49
+ signal?.addEventListener('abort', abort, { once: true });
50
+ const timer = setTimeout(() => { controller.abort(new Error('Loki request deadline exceeded')); }, settings.requestTimeoutMs);
51
+ try {
52
+ const response = await globalThis.fetch(url, { signal: controller.signal });
53
+ return entries(await body(response, settings.maxResponseBytes), settings.pageLimit);
54
+ } catch (error) {
55
+ controller.abort(error);
56
+ try { settings.onError?.(error); } catch {
57
+ // Diagnostics cannot suppress the failed read or change its cursor.
58
+ }
59
+ throw error;
60
+ } finally {
61
+ clearTimeout(timer);
62
+ signal?.removeEventListener('abort', abort);
63
+ }
64
+ }
65
+ /**
66
+ * Bounded Loki polling. Saturated pages fail without advancing the in-memory cursor.
67
+ * Windows lag wall-clock time by queryLagMs (default 1s) to allow ingestion visibility.
68
+ * Entries becoming visible later than this configured lag may still be missed.
69
+ * This live polling source does not expose portable history checkpoints or pagination.
70
+ *
71
+ * @param {string} url - Loki base URL
72
+ * @param {string} query - LogQL expression
73
+ * @param {number} interval - Polling interval in seconds
74
+ * @param {object} collector - Collector with accept()
75
+ * @param {object} clk - Clock with millis()
76
+ * @param {object} options - requestTimeoutMs, maxResponseBytes, pageLimit, queryLagMs, onError
77
+ * @returns {object} Source with async start/stop
78
+ */
79
+ // Sixth optional argument preserves the established five-argument source API.
80
+ // eslint-disable-next-line max-params
81
+ export default function lokiSource(url, query, interval, collector, clk, options = {}) {
82
+ if (typeof url !== 'string' || url.length === 0) { throw new Error('URL must be a non-empty string'); }
83
+ if (typeof query !== 'string' || query.length === 0) { throw new Error('Query must be a non-empty string'); }
84
+ const settings = config(options);
85
+ return pollingSource((since, until, { signal } = {}) => {
86
+ return request(requestUrl(url, query, { since: since - settings.queryLagMs, until: until - settings.queryLagMs }, settings.pageLimit), settings, signal);
87
+ }, interval, collector, clk);
88
+ }
@@ -0,0 +1,173 @@
1
+ import ModbusRTU from 'modbus-serial';
2
+ import deadline from './deadline.js';
3
+
4
+ /**
5
+ * Idle state for Modbus connection.
6
+ *
7
+ * @returns {object} State with no open attempt
8
+ */
9
+ export function idle() {
10
+ return {
11
+ connected() {
12
+ return false;
13
+ },
14
+ retrying() {
15
+ return false;
16
+ },
17
+ opening() {
18
+ return false;
19
+ }
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Retrying state for Modbus connection.
25
+ *
26
+ * @param {object} handle - Timer handle from setTimeout
27
+ * @returns {object} State with retrying() returning true
28
+ */
29
+ function retrying(handle) {
30
+ return {
31
+ connected() {
32
+ return false;
33
+ },
34
+ retrying() {
35
+ return true;
36
+ },
37
+ opening() {
38
+ return false;
39
+ },
40
+ cancel() {
41
+ clearTimeout(handle);
42
+ }
43
+ };
44
+ }
45
+
46
+ /**
47
+ * In-flight TCP or RTU connect state.
48
+ *
49
+ * @returns {object} State with opening() returning true
50
+ */
51
+ export function opening() {
52
+ return {
53
+ connected() {
54
+ return false;
55
+ },
56
+ retrying() {
57
+ return false;
58
+ },
59
+ opening() {
60
+ return true;
61
+ }
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Connected state for Modbus connection.
67
+ *
68
+ * @param {object} client - ModbusRTU client
69
+ * @returns {object} State with connected() returning true
70
+ */
71
+ function connected(client) {
72
+ return {
73
+ connected() {
74
+ return true;
75
+ },
76
+ retrying() {
77
+ return false;
78
+ },
79
+ opening() {
80
+ return false;
81
+ },
82
+ disconnect() {
83
+ client.close();
84
+ }
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Destroys a Modbus client without throwing if already closed.
90
+ *
91
+ * @param {object} client - ModbusRTU client
92
+ */
93
+ export function drop(client) {
94
+ if (client && typeof client.destroy === 'function') {
95
+ client.destroy(() => {
96
+ return undefined;
97
+ });
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Builds a ModbusRTU client for one connect attempt.
103
+ *
104
+ * @returns {object} Fresh ModbusRTU client
105
+ */
106
+ export function create() {
107
+ return new ModbusRTU();
108
+ }
109
+
110
+ /**
111
+ * Starts one connect attempt and ignores results after stop().
112
+ *
113
+ * @param {object} bag - Mutable lifecycle bag
114
+ */
115
+ export function attempt(bag) {
116
+ const token = bag.epoch;
117
+ bag.state = opening();
118
+ drop(bag.client);
119
+ const client = bag.create();
120
+ bag.client = client;
121
+ const connecting = bag.connect(client);
122
+ void connecting.then(() => { if (token !== bag.epoch) { drop(client); } }).catch(() => { return undefined; });
123
+ bag.opened = deadline(connecting, 5000, 'Modbus connect', (error) => {
124
+ bag.fatal = error; bag.epoch += 1; drop(client);
125
+ }).then(() => {
126
+ if (token !== bag.epoch) {
127
+ drop(client);
128
+ return;
129
+ }
130
+ bag.delay = bag.interval;
131
+ bag.state = connected(client);
132
+ client.setTimeout(bag.interval * 1000);
133
+ client.once('close', () => {
134
+ if (token === bag.epoch && bag.client === client) { bag.lost(); }
135
+ });
136
+ void bag.source.start().catch((error) => { bag.fatal = error; drop(client); });
137
+ }).catch((err) => {
138
+ if (token !== bag.epoch) {
139
+ return;
140
+ }
141
+ drop(bag.client);
142
+ bag.log.error(`Connection to ${bag.target} failed, retrying in ${bag.delay}s: ${err.message}`);
143
+ const handle = setTimeout(() => {
144
+ attempt(bag);
145
+ }, bag.delay * 1000);
146
+ bag.state = retrying(handle);
147
+ bag.delay = Math.min(bag.delay * 2, bag.maxDelay);
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Resets a live session and retries through attempt() backoff.
153
+ *
154
+ * @param {object} bag - Mutable lifecycle bag
155
+ */
156
+ export function resume(bag) {
157
+ if (!bag.state.connected()) {
158
+ return;
159
+ }
160
+ const drained = bag.source.stop();
161
+ const { client } = bag;
162
+ bag.client = undefined;
163
+ bag.state = idle();
164
+ drop(client);
165
+ bag.log.error(`Connection to ${bag.target} lost, retrying in ${bag.delay}s`);
166
+ const token = bag.epoch;
167
+ const handle = setTimeout(() => {
168
+ void drained.then(() => { if (token === bag.epoch) { attempt(bag); } }).catch((error) => { bag.fatal = error; });
169
+ }, bag.delay * 1000);
170
+ bag.state = retrying(handle);
171
+ bag.delay = Math.min(bag.delay * 2, bag.maxDelay);
172
+ }
173
+
@@ -0,0 +1,124 @@
1
+ import ModbusRTU from 'modbus-serial';
2
+ import positive from './positive.js';
3
+ import deadline from './deadline.js';
4
+
5
+ function validate(path, serial, slaves, poll) {
6
+ if (typeof path !== 'string' || !path) { throw new Error('Serial path must be a non-empty string'); }
7
+ if (!serial || !Number.isFinite(serial.baudRate) || serial.baudRate <= 0) { throw new Error('Serial baudRate must be a positive number'); }
8
+ if (!Array.isArray(slaves) || !slaves.length) { throw new Error('Slaves must be a non-empty array'); }
9
+ positive(poll.interval, 'interval');
10
+ slaves.forEach((slave) => {
11
+ if (!Number.isInteger(slave.slaveId) || slave.slaveId < 1 || slave.slaveId > 247 || typeof slave.collector?.accept !== 'function') {
12
+ throw new TypeError('Each RTU slave requires an ID from 1 to 247 and a collector');
13
+ }
14
+ });
15
+ }
16
+ function report(ctx, error) {
17
+ try { ctx.poll.log?.error(`RTU bus on ${ctx.path}: ${error.message}`); } catch {
18
+ // Diagnostics cannot stop recovery.
19
+ }
20
+ }
21
+ function close(ctx) {
22
+ ctx.closing ??= deadline(new Promise((resolve, reject) => {
23
+ ctx.client.close((error) => { if (error) { reject(error); } else { resolve(); } });
24
+ }), ctx.poll.stopTimeoutMs, 'RTU close');
25
+ return ctx.closing;
26
+ }
27
+ function pause(ctx) {
28
+ return new Promise((resolve) => { ctx.wake = resolve; ctx.timer = setTimeout(resolve, ctx.poll.interval * 1000); })
29
+ .finally(() => { clearTimeout(ctx.timer); ctx.wake = null; });
30
+ }
31
+ async function read(ctx, slave) {
32
+ ctx.client.setID(slave.slaveId);
33
+ const result = await deadline(ctx.client.readHoldingRegisters(ctx.poll.address, ctx.poll.count), ctx.poll.readTimeoutMs, 'RTU read');
34
+ if (ctx.active) {
35
+ await deadline(Promise.resolve().then(() => {
36
+ return slave.collector.accept(result.data, { signal: ctx.controller.signal });
37
+ }), ctx.poll.acceptTimeoutMs, 'RTU acceptance', (error) => { ctx.controller.abort(error); });
38
+ }
39
+ }
40
+ async function cycle(ctx) {
41
+ for (const slave of ctx.slaves) {
42
+ if (!ctx.active) { return; }
43
+ try {
44
+ // Shared setID requires every read and acceptance to finish before the next slave.
45
+ // eslint-disable-next-line no-await-in-loop
46
+ await read(ctx, slave);
47
+ } catch (error) {
48
+ if (error.code === 'OPERATION_TIMEOUT') { throw error; }
49
+ report(ctx, error);
50
+ }
51
+ }
52
+ }
53
+ async function session(ctx) {
54
+ ctx.opening = true;
55
+ const opening = ctx.client.connectRTUBuffered(ctx.path, ctx.serial);
56
+ void Promise.resolve(opening).then(() => {
57
+ ctx.opening = false;
58
+ if (!ctx.active) { return close(ctx); }
59
+ return undefined;
60
+ }, () => { ctx.opening = false; }).catch((error) => { report(ctx, error); });
61
+ await deadline(opening, ctx.poll.connectTimeoutMs, 'RTU connect');
62
+ while (ctx.active) {
63
+ // eslint-disable-next-line no-await-in-loop
64
+ await cycle(ctx);
65
+ // eslint-disable-next-line no-await-in-loop
66
+ if (ctx.active) { await pause(ctx); }
67
+ }
68
+ }
69
+ async function run(ctx) {
70
+ while (ctx.active) {
71
+ ctx.client = (ctx.poll.createClient || (() => { return new ModbusRTU(); }))();
72
+ ctx.client.setTimeout(ctx.poll.readTimeoutMs);
73
+ ctx.closing = null;
74
+ try {
75
+ // eslint-disable-next-line no-await-in-loop
76
+ await session(ctx);
77
+ } catch (error) {
78
+ report(ctx, error);
79
+ if (error.code === 'OPERATION_TIMEOUT') { Object.assign(ctx, { fatal: error, active: false }); }
80
+ } finally {
81
+ // Never treat closing a still-opening handle as proof its eventual port is closed.
82
+ // eslint-disable-next-line no-await-in-loop
83
+ if (!ctx.opening) { await close(ctx); }
84
+ }
85
+ // eslint-disable-next-line no-await-in-loop
86
+ if (ctx.active) { await pause(ctx); }
87
+ }
88
+ }
89
+ function launch(path, serial, slaves, poll) {
90
+ const ctx = { path, serial, slaves, poll, active: true, controller: new AbortController() };
91
+ ctx.task = run(ctx);
92
+ void ctx.task.catch((error) => { ctx.fatal = error; ctx.active = false; report(ctx, error); });
93
+ return ctx;
94
+ }
95
+ /** One serialized RTU bus. stop drains/closes; uncertain deadlines quarantine this instance. */
96
+ export default function modbusRtuBusSource(path, serial, slaves, poll) {
97
+ validate(path, serial, slaves, poll);
98
+ const settings = { readTimeoutMs: 5000, connectTimeoutMs: 5000, acceptTimeoutMs: 10000, stopTimeoutMs: 5000, ...poll };
99
+ ['readTimeoutMs', 'connectTimeoutMs', 'acceptTimeoutMs', 'stopTimeoutMs'].forEach((key) => { positive(settings[key], key); });
100
+ let ctx;
101
+ let revision = 0;
102
+ return {
103
+ async start() {
104
+ if (ctx?.active) { return; }
105
+ revision += 1;
106
+ const requested = revision;
107
+ if (ctx) {
108
+ await ctx.task;
109
+ if (ctx.fatal) { throw ctx.fatal; }
110
+ }
111
+ if (requested === revision) { ctx = launch(path, serial, slaves, settings); }
112
+ },
113
+ stop() {
114
+ revision += 1;
115
+ if (!ctx) { return Promise.resolve(); }
116
+ ctx.active = false;
117
+ ctx.controller.abort(new Error('RTU source stopped'));
118
+ ctx.wake?.();
119
+ return deadline(ctx.task, settings.stopTimeoutMs, 'RTU stop', (error) => { ctx.fatal = error; if (!ctx.opening) { void close(ctx).catch(() => { return undefined; }); } }).then(() => {
120
+ if (ctx.fatal) { throw ctx.fatal; }
121
+ });
122
+ }
123
+ };
124
+ }