@yarkivaev/scada 2.3.116 → 2.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.
Files changed (95) hide show
  1. package/README.md +90 -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 +11 -5
  6. package/src/application/deploy/readDocument.js +4 -1
  7. package/src/application/plantServer.js +1 -1
  8. package/src/application/runtime/additionalAdapters.js +139 -0
  9. package/src/application/runtime/assignment.js +30 -0
  10. package/src/application/runtime/budgetedSink.js +17 -0
  11. package/src/application/runtime/collectorSignals.js +14 -0
  12. package/src/application/runtime/compositeLifecycle.js +49 -0
  13. package/src/application/runtime/createRegistry.js +20 -0
  14. package/src/application/runtime/deadline.js +8 -0
  15. package/src/application/runtime/index.js +10 -0
  16. package/src/application/runtime/manifest.js +38 -0
  17. package/src/application/runtime/pipelineAdapters.js +60 -0
  18. package/src/application/runtime/resolveSecrets.js +12 -0
  19. package/src/application/runtime/resourceClaims.js +21 -0
  20. package/src/application/runtime/runCollector.js +50 -0
  21. package/src/application/runtime/runtime.js +86 -0
  22. package/src/application/runtime/runtimeHealth.js +29 -0
  23. package/src/application/runtime/runtimeServices.js +15 -0
  24. package/src/application/runtime/stateDocument.js +22 -0
  25. package/src/application/runtime/streamPorts.js +42 -0
  26. package/src/application/runtime/streamTask.js +105 -0
  27. package/src/application/runtime/taskBudget.js +31 -0
  28. package/src/application/runtime/taskContext.js +18 -0
  29. package/src/application/runtime/taskQueue.js +21 -0
  30. package/src/application/runtime/taskSlot.js +46 -0
  31. package/src/application/runtime/taskState.js +73 -0
  32. package/src/application/siteCollection.js +14 -0
  33. package/src/application/siteServer.js +7 -6
  34. package/src/application/siteTelemetry.js +1 -1
  35. package/src/application/supervisorSink.js +9 -8
  36. package/src/application/sync/siteSyncBind.js +1 -1
  37. package/src/application/sync/siteSyncTargets.js +1 -1
  38. package/src/bin/collector.js +7 -0
  39. package/src/bin/modbus-to-mqtt.js +11 -9
  40. package/src/infrastructure/ingest/activity/activityTracking.js +15 -21
  41. package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
  42. package/src/infrastructure/ingest/durableConsume.js +2 -2
  43. package/src/infrastructure/ingest/ingestBatch.js +13 -0
  44. package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
  45. package/src/infrastructure/ingest/modbus/silentStreams.js +33 -8
  46. package/src/infrastructure/ingest/mqtt/metricsTransformer.js +4 -5
  47. package/src/infrastructure/ingest/mqtt/modbusMqtt.js +17 -19
  48. package/src/infrastructure/ingest/mqtt/modbusPublish.js +4 -0
  49. package/src/infrastructure/ingest/mqtt/mqttMetrics.js +17 -21
  50. package/src/infrastructure/ingest/mqtt/registerPublish.js +3 -3
  51. package/src/infrastructure/ingest/pipelines/alertPipeline.js +7 -6
  52. package/src/infrastructure/ingest/pipelines/decisionPipeline.js +7 -6
  53. package/src/infrastructure/ingest/pipelines/segmentPipeline.js +8 -17
  54. package/src/infrastructure/ingest/pipelines/stompOptions.js +7 -0
  55. package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +10 -11
  56. package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +10 -6
  57. package/src/infrastructure/ingest/telemetry/foldingSink.js +5 -2
  58. package/src/infrastructure/messaging/stomp/segmentRetags.js +1 -1
  59. package/src/infrastructure/messaging/stomp/userDecisions.js +1 -1
  60. package/src/infrastructure/opcua/certificates.js +11 -0
  61. package/src/infrastructure/opcua/delivery.js +38 -0
  62. package/src/infrastructure/opcua/endpoint.js +12 -2
  63. package/src/infrastructure/opcua/session.js +20 -10
  64. package/src/infrastructure/opcua/source.js +6 -3
  65. package/src/infrastructure/persistence/runtime/taskStatePg.js +24 -0
  66. package/src/infrastructure/streams/README.md +291 -0
  67. package/src/infrastructure/streams/abortable.js +14 -0
  68. package/src/infrastructure/streams/amqpSink/publication.js +46 -0
  69. package/src/infrastructure/streams/amqpSink/session.js +105 -0
  70. package/src/infrastructure/streams/amqpSink.js +47 -0
  71. package/src/infrastructure/streams/amqpSource/session.js +127 -0
  72. package/src/infrastructure/streams/amqpSource.js +97 -0
  73. package/src/infrastructure/streams/brokerClose.js +19 -0
  74. package/src/infrastructure/streams/budgetAccept.js +6 -0
  75. package/src/infrastructure/streams/clickhouseSink.js +48 -0
  76. package/src/infrastructure/streams/deadline.js +1 -0
  77. package/src/infrastructure/streams/follow.js +41 -0
  78. package/src/infrastructure/streams/index.js +15 -0
  79. package/src/infrastructure/streams/lokiSource.js +88 -0
  80. package/src/infrastructure/streams/modbusConnection.js +173 -0
  81. package/src/infrastructure/streams/modbusRtuBusSource.js +124 -0
  82. package/src/infrastructure/streams/modbusSource.js +185 -0
  83. package/src/infrastructure/streams/mqttSink/session.js +51 -0
  84. package/src/infrastructure/streams/mqttSink.js +54 -0
  85. package/src/infrastructure/streams/mqttSource.js +66 -0
  86. package/src/infrastructure/streams/pollingSource.js +1 -0
  87. package/src/infrastructure/streams/positive.js +5 -0
  88. package/src/infrastructure/streams/postgresSink.js +124 -0
  89. package/src/infrastructure/streams/serialReceive.js +80 -0
  90. package/src/infrastructure/streams/serialSource.js +87 -0
  91. package/src/infrastructure/streams/sqliteSink.js +55 -0
  92. package/src/infrastructure/streams/stompBody.js +46 -0
  93. package/src/infrastructure/streams/stompSend.js +89 -0
  94. package/src/infrastructure/streams/stompSource.js +201 -0
  95. package/src/infrastructure/sync/operationSyncIngest.js +1 -1
@@ -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
+ }
@@ -0,0 +1,185 @@
1
+ import pollingSource from './pollingSource.js';
2
+ import deadline from './deadline.js';
3
+ import abortable from './abortable.js';
4
+ import { attempt, create, drop, idle, opening, resume } from './modbusConnection.js';
5
+
6
+ function read(bag, address, count) {
7
+ return async function fetch(since, until, { signal } = {}) {
8
+ const { client } = bag;
9
+ try {
10
+ const result = await abortable(client.readHoldingRegisters(address, count), signal, () => { drop(client); });
11
+ return [result.data];
12
+ } catch (err) {
13
+ if (bag.client === client) { bag.lost(); }
14
+ throw err;
15
+ }
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Shared Modbus polling lifecycle for TCP and RTU transports.
21
+ *
22
+ * @param {object} options - connect, target, address, count, interval, collector, clk, client, log
23
+ * @returns {object} Source with start() and stop() methods
24
+ */
25
+ function modbusPollingSource(options) {
26
+ const { address, count, interval, collector, clk } = options;
27
+ const bag = {
28
+ ...options,
29
+ client: undefined,
30
+ state: idle(),
31
+ epoch: 0,
32
+ maxDelay: 300,
33
+ delay: interval
34
+ };
35
+ bag.lost = function lost() {
36
+ resume(bag);
37
+ };
38
+ bag.source = pollingSource(read(bag, address, count), interval, collector, clk);
39
+ return {
40
+ /**
41
+ * Connects to the Modbus device and starts polling.
42
+ */
43
+ start() {
44
+ if (bag.fatal) { return Promise.reject(bag.fatal); }
45
+ if (bag.state.connected() || bag.state.retrying() || bag.state.opening()) {
46
+ return Promise.resolve();
47
+ }
48
+ const token = bag.epoch;
49
+ bag.state = opening();
50
+ return Promise.resolve(bag.stopping).then(() => { if (token === bag.epoch) { attempt(bag); } });
51
+ },
52
+ /**
53
+ * Stops polling and disconnects from the Modbus device.
54
+ */
55
+ stop() {
56
+ bag.epoch += 1;
57
+ const drained = bag.source.stop();
58
+ if (bag.state.retrying()) {
59
+ bag.state.cancel();
60
+ }
61
+ const { client } = bag;
62
+ bag.client = undefined;
63
+ bag.state = idle();
64
+ drop(client);
65
+ bag.stopping = deadline(Promise.all([drained, bag.opened]), 5000, 'Modbus stop', (error) => { bag.fatal = error; }).then(() => {
66
+ if (bag.fatal) { throw bag.fatal; }
67
+ });
68
+ return bag.stopping;
69
+ }
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Modbus TCP polling source for reading holding registers.
75
+ *
76
+ * Connects to a Modbus TCP device and polls holding registers
77
+ * at a specified interval. Forwards register arrays to the
78
+ * collector via pollingSource.
79
+ *
80
+ * @example
81
+ * const source = modbusSource('192.168.2.148', 502, 4000, 14, 5, collector, clock);
82
+ * source.start();
83
+ *
84
+ * @param {string} host - Modbus TCP host address
85
+ * @param {number} port - Modbus TCP port
86
+ * @param {number} address - Starting register address
87
+ * @param {number} count - Number of registers to read
88
+ * @param {number} interval - Polling interval in seconds
89
+ * @param {object} collector - Collector with accept() method
90
+ * @param {object} clk - Clock with millis() method
91
+ * @param {object} [log] - Logger with error() method for connection failures
92
+ * @returns {object} Source with start() and stop() methods
93
+ */
94
+ // Preserve the established positional adapter API.
95
+ // eslint-disable-next-line max-params
96
+ export default function modbusSource(host, port, address, count, interval, collector, clk, log = console) {
97
+ if (typeof host !== 'string' || host.length === 0) {
98
+ throw new Error('Host must be a non-empty string');
99
+ }
100
+ if (typeof port !== 'number' || port <= 0) {
101
+ throw new Error(`Port must be a positive number, got: ${port}`);
102
+ }
103
+ if (typeof address !== 'number' || address < 0) {
104
+ throw new Error(`Address must be a non-negative number, got: ${address}`);
105
+ }
106
+ if (typeof count !== 'number' || count <= 0) {
107
+ throw new Error(`Count must be a positive number, got: ${count}`);
108
+ }
109
+ function connect(modbusClient) {
110
+ return modbusClient.connectTCP(host, { port });
111
+ }
112
+ return modbusPollingSource({
113
+ create,
114
+ connect,
115
+ target: `${host}:${port}`,
116
+ address,
117
+ count,
118
+ interval,
119
+ collector,
120
+ clk,
121
+ log
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Modbus RTU polling source for reading holding registers over serial.
127
+ *
128
+ * Connects to a Modbus RTU device on a serial port and polls holding registers
129
+ * at a specified interval. Forwards register arrays to the collector via pollingSource.
130
+ *
131
+ * @example
132
+ * const source = modbusRtuSource('/dev/ttyUSB0', { baudRate: 9600, slaveId: 1 }, 4000, 44, 5, collector, clock);
133
+ * source.start();
134
+ *
135
+ * @param {string} path - Serial device path (e.g. /dev/ttyUSB0)
136
+ * @param {object} serial - baudRate, dataBits, stopBits, parity, slaveId
137
+ * @param {number} address - Starting register address
138
+ * @param {number} count - Number of registers to read
139
+ * @param {number} interval - Polling interval in seconds
140
+ * @param {object} collector - Collector with accept() method
141
+ * @param {object} clk - Clock with millis() method
142
+ * @param {object} [log] - Logger with error() method for connection failures
143
+ * @returns {object} Source with start() and stop() methods
144
+ */
145
+ // Preserve the established positional adapter API.
146
+ // eslint-disable-next-line max-params
147
+ export function modbusRtuSource(path, serial, address, count, interval, collector, clk, log = console) {
148
+ if (typeof path !== 'string' || path.length === 0) {
149
+ throw new Error('Serial path must be a non-empty string');
150
+ }
151
+ if (!serial || typeof serial !== 'object') {
152
+ throw new Error('Serial config must be an object');
153
+ }
154
+ if (typeof serial.baudRate !== 'number' || serial.baudRate <= 0) {
155
+ throw new Error(`Baud rate must be a positive number, got: ${serial.baudRate}`);
156
+ }
157
+ if (typeof address !== 'number' || address < 0) {
158
+ throw new Error(`Address must be a non-negative number, got: ${address}`);
159
+ }
160
+ if (typeof count !== 'number' || count <= 0) {
161
+ throw new Error(`Count must be a positive number, got: ${count}`);
162
+ }
163
+ const slaveId = serial.slaveId || 1;
164
+ function connect(modbusClient) {
165
+ return modbusClient.connectRTUBuffered(path, {
166
+ baudRate: serial.baudRate,
167
+ dataBits: serial.dataBits || 8,
168
+ stopBits: serial.stopBits || 1,
169
+ parity: serial.parity || 'none'
170
+ }).then(() => {
171
+ modbusClient.setID(slaveId);
172
+ });
173
+ }
174
+ return modbusPollingSource({
175
+ create,
176
+ connect,
177
+ target: path,
178
+ address,
179
+ count,
180
+ interval,
181
+ collector,
182
+ clk,
183
+ log
184
+ });
185
+ }
@@ -0,0 +1,51 @@
1
+ import deadline from '../deadline.js';
2
+
3
+ export function terminate(live, error) {
4
+ if (live.dead) { return live.closing; }
5
+ live.dead = true;
6
+ live.ready = false;
7
+ live.pending.forEach((settle) => { settle(error || new Error('MQTT connection closed')); });
8
+ live.closing = deadline(new Promise((resolve) => {
9
+ live.client.end(true, {}, resolve);
10
+ }), live.options.stopTimeoutMs, 'MQTT disconnect');
11
+ // An event callback cannot surface a rejected shutdown. stop() still returns it.
12
+ void live.closing.catch(() => { return undefined; });
13
+ return live.closing;
14
+ }
15
+ export function connect(live) {
16
+ return deadline(new Promise((resolve, reject) => {
17
+ function fail(error) { reject(error); void terminate(live, error); }
18
+ live.client.on('error', fail);
19
+ live.client.on('close', () => { fail(new Error('MQTT connection closed')); });
20
+ live.client.on('offline', () => { fail(new Error('MQTT connection offline')); });
21
+ live.client.on('connect', () => {
22
+ if (!live.dead) { live.ready = true; resolve(); }
23
+ });
24
+ live.pending.add(reject);
25
+ }), live.options.connectTimeoutMs, 'MQTT connect', (error) => { void terminate(live, error); });
26
+ }
27
+ export function publish(live, record, signal) {
28
+ return new Promise((resolve, reject) => {
29
+ let done = false;
30
+ const timer = { handle: null };
31
+ function abort() { void terminate(live, signal.reason || new Error('MQTT publish aborted')); }
32
+ function settle(error) {
33
+ if (done) { return; }
34
+ done = true;
35
+ clearTimeout(timer.handle);
36
+ signal?.removeEventListener('abort', abort);
37
+ live.pending.delete(settle);
38
+ live.count -= 1;
39
+ if (error) { reject(error); } else { resolve(); }
40
+ }
41
+ timer.handle = setTimeout(() => { void terminate(live, new Error('MQTT confirmation timed out; outcome uncertain')); }, live.options.confirmTimeoutMs);
42
+ live.pending.add(settle);
43
+ signal?.addEventListener('abort', abort, { once: true });
44
+ if (signal?.aborted) { abort(); return; }
45
+ try { live.client.publish(record.topic, record.payload, { qos: live.options.qos }, (error) => {
46
+ settle(error);
47
+ if (error) { void terminate(live, error); }
48
+ }); }
49
+ catch (error) { void terminate(live, error); }
50
+ });
51
+ }
@@ -0,0 +1,54 @@
1
+ import mqtt from 'mqtt';
2
+ import positive from './positive.js';
3
+ import { connect, publish, terminate } from './mqttSink/session.js';
4
+
5
+ function settings(options) {
6
+ const config = { qos: 0, maxPending: 1024, maxPendingBytes: 1048576, connectTimeoutMs: 5000, confirmTimeoutMs: 10000, stopTimeoutMs: 5000, ...options };
7
+ ['maxPending', 'maxPendingBytes', 'connectTimeoutMs', 'confirmTimeoutMs', 'stopTimeoutMs'].forEach((key) => { positive(config[key], key); });
8
+ if (!Number.isSafeInteger(config.maxPending)) { throw new TypeError('maxPending must be a safe integer'); }
9
+ if (![0, 1, 2].includes(config.qos)) { throw new TypeError('qos must be 0, 1, or 2'); }
10
+ return config;
11
+ }
12
+ function write(live, records, signal) {
13
+ if (!live?.ready || live.dead) { throw new Error('Sink is not connected'); }
14
+ if (!Array.isArray(records) || records.some((record) => {
15
+ return !record || typeof record.topic !== 'string' || !record.topic || !(typeof record.payload === 'string' || Buffer.isBuffer(record.payload));
16
+ })) { return Promise.reject(new TypeError('MQTT records require a topic and string/Buffer payload')); }
17
+ if (signal?.aborted) { return Promise.reject(signal.reason || new Error('MQTT publish aborted')); }
18
+ const bytes = records.reduce((total, record) => { return total + Buffer.byteLength(record.payload) + Buffer.byteLength(record.topic); }, 0);
19
+ if (live.bytes + bytes > live.options.maxPendingBytes || live.count + records.length > live.options.maxPending) { return Promise.reject(new Error('MQTT pending capacity exceeded')); }
20
+ live.count += records.length;
21
+ live.bytes += bytes;
22
+ return Promise.all(records.map((record) => {
23
+ if (live.dead) { live.count -= 1; return Promise.reject(new Error('MQTT connection closed')); }
24
+ return publish(live, record, signal);
25
+ })).finally(() => { live.bytes -= bytes; });
26
+ }
27
+ /** start awaits CONNACK; write awaits publish completion (broker confirmation at QoS 1/2).
28
+ * QoS 0 only confirms local handoff. No automatic replay of uncertain writes.
29
+ */
30
+ export default function mqttSink(url, options = {}) {
31
+ if (typeof url !== 'string' || !url) { throw new Error('URL must be a non-empty string'); }
32
+ const config = settings(options);
33
+ let live;
34
+ return {
35
+ start() {
36
+ if (live && !live.dead) { return live.opening; }
37
+ if (live?.dead) { return Promise.reject(new Error('MQTT session retired; create a new sink after stop')); }
38
+ const client = (options.connect || mqtt.connect)(url, {
39
+ ...options.connectionOptions, clientId: options.clientId, protocolVersion: 5,
40
+ reconnectPeriod: 0, queueQoSZero: false, connectTimeout: config.connectTimeoutMs
41
+ });
42
+ live = { client, options: config, pending: new Set(), count: 0, bytes: 0, ready: false, dead: false };
43
+ live.opening = connect(live);
44
+ return live.opening;
45
+ },
46
+ write(records, { signal } = {}) { return write(live, records, signal); },
47
+ stop() {
48
+ const stopped = live;
49
+ return (stopped ? terminate(stopped, new Error('MQTT sink stopped')) : Promise.resolve()).then(() => {
50
+ if (live === stopped) { live = undefined; }
51
+ });
52
+ }
53
+ };
54
+ }
@@ -0,0 +1,66 @@
1
+ import mqtt from 'mqtt';
2
+ import positive from './positive.js';
3
+ import deadline from './deadline.js';
4
+ import budgetAccept from './budgetAccept.js';
5
+
6
+ function report(options, error) {
7
+ try { options.onError?.(error); } catch {
8
+ // Diagnostics cannot break transport callbacks.
9
+ }
10
+ }
11
+ function bind(live, collector, options) {
12
+ live.client.on('error', (error) => { report(options, error); });
13
+ // MQTT.js serializes handleMessage and sends PUBACK/PUBCOMP only on successful completion.
14
+ live.client.handleMessage = (packet, callback) => {
15
+ const accepted = Promise.resolve().then(() => {
16
+ if (!live.active) { throw new Error('MQTT source stopped'); }
17
+ return budgetAccept(collector, { topic: packet.topic, payload: packet.payload.toString() }, packet.payload.length, options, { signal: live.controller.signal });
18
+ });
19
+ live.pending.add(accepted);
20
+ void accepted.finally(() => { live.pending.delete(accepted); }).catch(() => { return undefined; });
21
+ deadline(accepted, options.acceptTimeoutMs, 'MQTT acceptance', (error) => {
22
+ live.active = false; live.controller.abort(error); live.client.end(true);
23
+ }).then(() => {
24
+ callback(live.active ? undefined : new Error('MQTT source stopped'));
25
+ }, (error) => { callback(error); report(options, error); });
26
+ };
27
+ }
28
+ function open(url, list, collector, options) {
29
+ const client = (options.connect || mqtt.connect)(url, {
30
+ ...options.connectionOptions, clientId: options.clientId, clean: !options.clientId, protocolVersion: 5,
31
+ properties: { sessionExpiryInterval: options.clientId ? options.sessionExpiryInterval ?? 3600 : 0,
32
+ receiveMaximum: options.prefetch ?? 32, maximumPacketSize: options.maxPacketBytes ?? 1048576 }
33
+ });
34
+ const live = { client, active: true, pending: new Set(), controller: new AbortController() };
35
+ bind(live, collector, options);
36
+ client.on('connect', () => {
37
+ if (live.active) { client.subscribe(list, { qos: options.qos ?? (options.clientId ? 1 : 0) }); }
38
+ });
39
+ return live;
40
+ }
41
+ /** ACK follows asynchronous acceptance, not merely receipt. Acceptance is not necessarily durable. */
42
+ export default function mqttSource(url, topics, collector, options = {}) {
43
+ if (typeof url !== 'string' || !url) { throw new Error('URL must be a non-empty string'); }
44
+ if (typeof topics !== 'string' || !topics.trim()) { throw new Error('Topic must be a non-empty string'); }
45
+ if (typeof collector?.accept !== 'function') { throw new Error('Collector must have an accept() method'); }
46
+ const config = { acceptTimeoutMs: 10000, stopTimeoutMs: 5000, ...options };
47
+ positive(config.acceptTimeoutMs, 'acceptTimeoutMs');
48
+ positive(config.stopTimeoutMs, 'stopTimeoutMs');
49
+ const list = topics.split(',').map((topic) => { return topic.trim(); }).filter(Boolean);
50
+ let live;
51
+ return {
52
+ start() {
53
+ if (!live) { live = open(url, list, collector, config); }
54
+ },
55
+ stop() {
56
+ if (!live) { return Promise.resolve(); }
57
+ const stopped = live;
58
+ stopped.active = false;
59
+ stopped.controller.abort(new Error('MQTT source stopped'));
60
+ const closing = new Promise((resolve) => { stopped.client.end(true, {}, resolve); });
61
+ return deadline(Promise.all([closing, ...stopped.pending]), config.stopTimeoutMs, 'MQTT stop').then(() => {
62
+ if (live === stopped) { live = undefined; }
63
+ });
64
+ }
65
+ };
66
+ }
@@ -0,0 +1 @@
1
+ export { pollingSource as default } from '@yarkivaev/source-to-sink';
@@ -0,0 +1,5 @@
1
+ export default function positive(value, name) {
2
+ if (!Number.isFinite(value) || value <= 0) {
3
+ throw new TypeError(`${name} must be a positive number`);
4
+ }
5
+ }