@yarkivaev/source-to-sink 1.2.7 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -11,8 +11,10 @@
11
11
  * - postgresSink: PostgreSQL sink (accepts URL)
12
12
  * - mqttSource: MQTT subscription source (accepts URL)
13
13
  * - stompSource: STOMP subscription source (accepts URL)
14
+ * - stompSend: one-shot STOMP publish (accepts URL)
14
15
  * - lokiSource: Loki polling source (accepts URL)
15
16
  * - modbusSource: Modbus TCP polling source (accepts host/port)
17
+ * - modbusRtuSource: Modbus RTU polling source (accepts serial path)
16
18
  * - mqttSink: MQTT publishing sink (accepts URL)
17
19
  *
18
20
  * @example
@@ -34,7 +36,8 @@ export { default as clickhouseSink } from './src/clickhouseSink.js';
34
36
  export { default as postgresSink } from './src/postgresSink.js';
35
37
  export { default as mqttSource } from './src/mqttSource.js';
36
38
  export { default as stompSource } from './src/stompSource.js';
39
+ export { default as stompSend } from './src/stompSend.js';
37
40
  export { default as lokiSource } from './src/lokiSource.js';
38
- export { default as modbusSource } from './src/modbusSource.js';
41
+ export { default as modbusSource, modbusRtuSource } from './src/modbusSource.js';
39
42
  export { default as mqttSink } from './src/mqttSink.js';
40
43
  export { default as sqliteSink } from './src/sqliteSink.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/source-to-sink",
3
- "version": "1.2.7",
3
+ "version": "1.3.1",
4
4
  "description": "Generic library for building data streaming pipelines",
5
5
  "repository": {
6
6
  "type": "git",
@@ -58,42 +58,23 @@ function connected(client) {
58
58
  }
59
59
 
60
60
  /**
61
- * Modbus TCP polling source for reading holding registers.
61
+ * Shared Modbus polling lifecycle for TCP and RTU transports.
62
62
  *
63
- * Connects to a Modbus TCP device and polls holding registers
64
- * at a specified interval. Forwards register arrays to the
65
- * collector via pollingSource.
66
- *
67
- * @example
68
- * const source = modbusSource('192.168.2.148', 502, 4000, 14, 5, collector, clock);
69
- * source.start();
70
- * // ... later
71
- * source.stop();
72
- *
73
- * @param {string} host - Modbus TCP host address
74
- * @param {number} port - Modbus TCP port
75
- * @param {number} address - Starting register address
76
- * @param {number} count - Number of registers to read
77
- * @param {number} interval - Polling interval in seconds
78
- * @param {object} collector - Collector with accept() method
79
- * @param {object} clk - Clock with millis() method
80
- * @param {object} [log] - Logger with error() method for connection failures
63
+ * @param {object} options - connect, target, address, count, interval, collector, clk, client, log
81
64
  * @returns {object} Source with start() and stop() methods
82
65
  */
83
- export default function modbusSource(host, port, address, count, interval, collector, clk, log = console) {
84
- if (typeof host !== 'string' || host.length === 0) {
85
- throw new Error('Host must be a non-empty string');
86
- }
87
- if (typeof port !== 'number' || port <= 0) {
88
- throw new Error(`Port must be a positive number, got: ${port}`);
89
- }
90
- if (typeof address !== 'number' || address < 0) {
91
- throw new Error(`Address must be a non-negative number, got: ${address}`);
92
- }
93
- if (typeof count !== 'number' || count <= 0) {
94
- throw new Error(`Count must be a positive number, got: ${count}`);
95
- }
96
- const client = new ModbusRTU();
66
+ function modbusPollingSource(options) {
67
+ const {
68
+ connect,
69
+ target,
70
+ address,
71
+ count,
72
+ interval,
73
+ collector,
74
+ clk,
75
+ client,
76
+ log
77
+ } = options;
97
78
  let state = idle();
98
79
  async function fetch() {
99
80
  const result = await client.readHoldingRegisters(address, count);
@@ -103,12 +84,12 @@ export default function modbusSource(host, port, address, count, interval, colle
103
84
  const source = pollingSource(fetch, interval, collector, clk);
104
85
  let delay = interval;
105
86
  function attempt() {
106
- client.connectTCP(host, { port }).then(() => {
87
+ connect(client).then(() => {
107
88
  delay = interval;
108
89
  state = connected(client);
109
90
  source.start();
110
91
  }).catch((err) => {
111
- log.error(`Connection to ${host}:${port} failed, retrying in ${delay}s: ${err.message}`);
92
+ log.error(`Connection to ${target} failed, retrying in ${delay}s: ${err.message}`);
112
93
  const handle = setTimeout(attempt, delay * 1000);
113
94
  state = retrying(handle);
114
95
  delay = Math.min(delay * 2, maxDelay);
@@ -140,3 +121,118 @@ export default function modbusSource(host, port, address, count, interval, colle
140
121
  }
141
122
  };
142
123
  }
124
+
125
+ /**
126
+ * Modbus TCP polling source for reading holding registers.
127
+ *
128
+ * Connects to a Modbus TCP device and polls holding registers
129
+ * at a specified interval. Forwards register arrays to the
130
+ * collector via pollingSource.
131
+ *
132
+ * @example
133
+ * const source = modbusSource('192.168.2.148', 502, 4000, 14, 5, collector, clock);
134
+ * source.start();
135
+ *
136
+ * @param {string} host - Modbus TCP host address
137
+ * @param {number} port - Modbus TCP port
138
+ * @param {number} address - Starting register address
139
+ * @param {number} count - Number of registers to read
140
+ * @param {number} interval - Polling interval in seconds
141
+ * @param {object} collector - Collector with accept() method
142
+ * @param {object} clk - Clock with millis() method
143
+ * @param {object} [log] - Logger with error() method for connection failures
144
+ * @returns {object} Source with start() and stop() methods
145
+ */
146
+ export default function modbusSource(host, port, address, count, interval, collector, clk, log = console) {
147
+ if (typeof host !== 'string' || host.length === 0) {
148
+ throw new Error('Host must be a non-empty string');
149
+ }
150
+ if (typeof port !== 'number' || port <= 0) {
151
+ throw new Error(`Port must be a positive number, got: ${port}`);
152
+ }
153
+ if (typeof address !== 'number' || address < 0) {
154
+ throw new Error(`Address must be a non-negative number, got: ${address}`);
155
+ }
156
+ if (typeof count !== 'number' || count <= 0) {
157
+ throw new Error(`Count must be a positive number, got: ${count}`);
158
+ }
159
+ const client = new ModbusRTU();
160
+ function connect(modbusClient) {
161
+ return modbusClient.connectTCP(host, { port });
162
+ }
163
+ return modbusPollingSource({
164
+ connect,
165
+ target: `${host}:${port}`,
166
+ address,
167
+ count,
168
+ interval,
169
+ collector,
170
+ clk,
171
+ client,
172
+ log
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Modbus RTU polling source for reading holding registers over serial.
178
+ *
179
+ * Connects to a Modbus RTU device on a serial port and polls holding registers
180
+ * at a specified interval. Forwards register arrays to the collector via pollingSource.
181
+ *
182
+ * @example
183
+ * const source = modbusRtuSource('/dev/ttyUSB0', { baudRate: 9600, slaveId: 1 }, 4000, 44, 5, collector, clock);
184
+ * source.start();
185
+ *
186
+ * @param {string} path - Serial device path (e.g. /dev/ttyUSB0)
187
+ * @param {object} serial - baudRate, dataBits, stopBits, parity, slaveId
188
+ * @param {number} address - Starting register address
189
+ * @param {number} count - Number of registers to read
190
+ * @param {number} interval - Polling interval in seconds
191
+ * @param {object} collector - Collector with accept() method
192
+ * @param {object} clk - Clock with millis() method
193
+ * @param {object} [log] - Logger with error() method for connection failures
194
+ * @returns {object} Source with start() and stop() methods
195
+ */
196
+ export function modbusRtuSource(path, serial, address, count, interval, collector, clk, log = console) {
197
+ if (typeof path !== 'string' || path.length === 0) {
198
+ throw new Error('Serial path must be a non-empty string');
199
+ }
200
+ if (!serial || typeof serial !== 'object') {
201
+ throw new Error('Serial config must be an object');
202
+ }
203
+ if (typeof serial.baudRate !== 'number' || serial.baudRate <= 0) {
204
+ throw new Error(`Baud rate must be a positive number, got: ${serial.baudRate}`);
205
+ }
206
+ if (typeof address !== 'number' || address < 0) {
207
+ throw new Error(`Address must be a non-negative number, got: ${address}`);
208
+ }
209
+ if (typeof count !== 'number' || count <= 0) {
210
+ throw new Error(`Count must be a positive number, got: ${count}`);
211
+ }
212
+ const client = new ModbusRTU();
213
+ const slaveId = serial.slaveId || 1;
214
+ const dataBits = serial.dataBits || 8;
215
+ const stopBits = serial.stopBits || 1;
216
+ const parity = serial.parity || 'none';
217
+ function connect(modbusClient) {
218
+ return modbusClient.connectRTUBuffered(path, {
219
+ baudRate: serial.baudRate,
220
+ dataBits,
221
+ stopBits,
222
+ parity
223
+ }).then(() => {
224
+ modbusClient.setID(slaveId);
225
+ });
226
+ }
227
+ return modbusPollingSource({
228
+ connect,
229
+ target: path,
230
+ address,
231
+ count,
232
+ interval,
233
+ collector,
234
+ clk,
235
+ client,
236
+ log
237
+ });
238
+ }
@@ -0,0 +1,72 @@
1
+ import stompit from 'stompit';
2
+
3
+ /**
4
+ * Builds STOMP connect options from a URL.
5
+ *
6
+ * @param {URL} parsed - Parsed URL object
7
+ * @param {object} options - Connection options
8
+ * @returns {object} stompit connect parameters
9
+ */
10
+ function connectParams(parsed, options) {
11
+ return {
12
+ host: parsed.hostname,
13
+ port: parseInt(parsed.port, 10) || 61613,
14
+ connectHeaders: {
15
+ host: options.host || parsed.hostname,
16
+ login: options.login || '',
17
+ passcode: options.passcode || ''
18
+ }
19
+ };
20
+ }
21
+
22
+ /**
23
+ * One-shot STOMP publish of a JSON payload to a destination.
24
+ *
25
+ * Connects, sends one MESSAGE frame, disconnects. For operator decisions
26
+ * and other low-volume outbound events.
27
+ *
28
+ * @example
29
+ * await stompSend('stomp://rabbitmq:61613', '/exchange/scada.user_decisions',
30
+ * { machine: 'icht2', start: 1700000000, user: 'hmi', tags: ['heating'] },
31
+ * { login: 'guest', passcode: 'guest', host: '/' });
32
+ *
33
+ * @param {string} url - STOMP broker URL
34
+ * @param {string} destination - STOMP destination
35
+ * @param {object|string} payload - JSON-serializable body or raw string
36
+ * @param {object} [options] - STOMP connection options
37
+ * @param {string} [options.login] - Login
38
+ * @param {string} [options.passcode] - Passcode
39
+ * @param {string} [options.host] - Virtual host header
40
+ * @returns {Promise<void>} Resolves when the frame is sent and client disconnects
41
+ */
42
+ export default function stompSend(url, destination, payload, options = {}) {
43
+ if (typeof url !== 'string' || url.length === 0) {
44
+ throw new Error('URL must be a non-empty string');
45
+ }
46
+ if (typeof destination !== 'string' || destination.length === 0) {
47
+ throw new Error('Destination must be a non-empty string');
48
+ }
49
+ const parsed = new URL(url);
50
+ const params = connectParams(parsed, options);
51
+ const body = typeof payload === 'string' ? payload : JSON.stringify(payload);
52
+ return new Promise((resolve, reject) => {
53
+ stompit.connect(params, (error, client) => {
54
+ if (error) {
55
+ reject(error);
56
+ return;
57
+ }
58
+ const frame = client.send({
59
+ destination,
60
+ 'content-type': 'application/json'
61
+ });
62
+ frame.end(body);
63
+ client.disconnect((disconnectError) => {
64
+ if (disconnectError) {
65
+ reject(disconnectError);
66
+ return;
67
+ }
68
+ resolve();
69
+ });
70
+ });
71
+ });
72
+ }
@@ -0,0 +1,45 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'mocha';
3
+ import { modbusRtuSource } from '../src/modbusSource.js';
4
+ import fakeClock from './fakeClock.js';
5
+
6
+ describe('modbusRtuSource', () => {
7
+ it('throws on empty serial path', () => {
8
+ const collector = { accept: () => {} };
9
+ const clk = fakeClock(Math.floor(Math.random() * 10000));
10
+ assert.throws(
11
+ () => {return modbusRtuSource('', { baudRate: 9600 }, 4000, 44, 5, collector, clk)},
12
+ /Serial path must be a non-empty string/u,
13
+ 'Should reject empty serial path'
14
+ );
15
+ });
16
+
17
+ it('throws on invalid baud rate', () => {
18
+ const collector = { accept: () => {} };
19
+ const clk = fakeClock(Math.floor(Math.random() * 10000));
20
+ assert.throws(
21
+ () => {return modbusRtuSource('/dev/ttyUSB0', { baudRate: 0 }, 4000, 44, 5, collector, clk)},
22
+ /Baud rate must be a positive number/u,
23
+ 'Should reject zero baud rate'
24
+ );
25
+ });
26
+
27
+ it('creates source with start method', () => {
28
+ const collector = { accept: () => {} };
29
+ const clk = fakeClock(Math.floor(Math.random() * 10000));
30
+ const source = modbusRtuSource(`/dev/ttyUSB${Math.floor(Math.random() * 9)}`, { baudRate: 9600 }, 4000, 44, 5, collector, clk);
31
+ assert.strictEqual(typeof source.start, 'function', 'Should expose start method for RTU source');
32
+ });
33
+
34
+ it('retries connection on failure', async () => {
35
+ let logged = false;
36
+ const log = { error: () => { logged = true; } };
37
+ const collector = { accept: () => {} };
38
+ const clk = fakeClock(Math.floor(Math.random() * 10000));
39
+ const source = modbusRtuSource(`/dev/tty\u044b${Math.random()}`, { baudRate: 9600 }, 4000, 44, 5, collector, clk, log);
40
+ source.start();
41
+ await new Promise(resolve => { setTimeout(resolve, 200); });
42
+ source.stop();
43
+ assert.strictEqual(logged, true, 'Should log RTU connection failure and retry');
44
+ });
45
+ });
@@ -99,7 +99,8 @@ describe('modbusSource', () => {
99
99
  const log = { error: () => { logged = true; } };
100
100
  const collector = { accept: () => {} };
101
101
  const clk = fakeClock(Math.floor(Math.random() * 10000));
102
- const source = modbusSource(`\u00df${Math.random()}`, 502, 4000, 14, 5, collector, clk, log);
102
+ const port = 65000 + Math.floor(Math.random() * 500);
103
+ const source = modbusSource('127.0.0.1', port, 4000, 14, 5, collector, clk, log);
103
104
  source.start();
104
105
  await new Promise(resolve => { setTimeout(resolve, 200); });
105
106
  source.stop();
@@ -110,7 +111,8 @@ describe('modbusSource', () => {
110
111
  const log = { error: () => {} };
111
112
  const collector = { accept: () => {} };
112
113
  const clk = fakeClock(Math.floor(Math.random() * 10000));
113
- const source = modbusSource(`\u0420${Math.random()}`, 502, 4000, 14, 5, collector, clk, log);
114
+ const port = 65000 + Math.floor(Math.random() * 500);
115
+ const source = modbusSource('127.0.0.1', port, 4000, 14, 5, collector, clk, log);
114
116
  source.start();
115
117
  await new Promise(resolve => { setTimeout(resolve, 200); });
116
118
  source.stop();
@@ -0,0 +1,35 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'mocha';
3
+ import stompSend from '../src/stompSend.js';
4
+
5
+ describe('stompSend', () => {
6
+ it('throws on missing url', () => {
7
+ assert.throws(
8
+ () => {
9
+ stompSend(null, '/exchange/test', {});
10
+ },
11
+ /URL must be a non-empty string/u,
12
+ 'Should reject missing url'
13
+ );
14
+ });
15
+
16
+ it('throws on empty destination', () => {
17
+ assert.throws(
18
+ () => {
19
+ stompSend('stomp://localhost:61613', '', {});
20
+ },
21
+ /Destination must be a non-empty string/u,
22
+ 'Should reject empty destination'
23
+ );
24
+ });
25
+
26
+ it('returns a promise', () => {
27
+ const pending = stompSend(
28
+ `stomp://\u00e9${Math.random()}:61613`,
29
+ '/exchange/scada.user_decisions',
30
+ { machine: 'x', start: 1, user: 'u', tags: [] }
31
+ );
32
+ assert.strictEqual(typeof pending.then, 'function', 'Should return a promise');
33
+ pending.catch(() => {});
34
+ });
35
+ });