@yarkivaev/source-to-sink 1.3.1 → 1.3.3

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
@@ -15,6 +15,7 @@
15
15
  * - lokiSource: Loki polling source (accepts URL)
16
16
  * - modbusSource: Modbus TCP polling source (accepts host/port)
17
17
  * - modbusRtuSource: Modbus RTU polling source (accepts serial path)
18
+ * - modbusRtuBusSource: Modbus RTU multi-slave bus on one serial path
18
19
  * - mqttSink: MQTT publishing sink (accepts URL)
19
20
  *
20
21
  * @example
@@ -39,5 +40,6 @@ export { default as stompSource } from './src/stompSource.js';
39
40
  export { default as stompSend } from './src/stompSend.js';
40
41
  export { default as lokiSource } from './src/lokiSource.js';
41
42
  export { default as modbusSource, modbusRtuSource } from './src/modbusSource.js';
43
+ export { default as modbusRtuBusSource } from './src/modbusRtuBusSource.js';
42
44
  export { default as mqttSink } from './src/mqttSink.js';
43
45
  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.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "Generic library for building data streaming pipelines",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,118 @@
1
+ import ModbusRTU from 'modbus-serial';
2
+
3
+ /**
4
+ * Validates RTU bus source arguments.
5
+ *
6
+ * @param {string} path - Serial device path
7
+ * @param {object} serial - Serial options with baudRate
8
+ * @param {Array} slaves - Slave descriptors
9
+ */
10
+ function assertBusArgs(path, serial, slaves) {
11
+ if (typeof path !== 'string' || path.length === 0) {
12
+ throw new Error('Serial path must be a non-empty string');
13
+ }
14
+ if (!serial || typeof serial.baudRate !== 'number' || serial.baudRate <= 0) {
15
+ throw new Error('Serial baudRate must be a positive number');
16
+ }
17
+ if (!Array.isArray(slaves) || slaves.length === 0) {
18
+ throw new Error('Slaves must be a non-empty array');
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Reads one slave holding-register block and forwards it to its collector.
24
+ *
25
+ * @param {object} client - ModbusRTU client
26
+ * @param {object} slave - slaveId and collector
27
+ * @param {number} address - PDU start address
28
+ * @param {number} count - Register count
29
+ * @returns {Promise<void>}
30
+ */
31
+ function readSlave(client, slave, address, count) {
32
+ client.setID(slave.slaveId);
33
+ return client.readHoldingRegisters(address, count).then((result) => {
34
+ slave.collector.accept(result.data);
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Polls all slaves on the bus sequentially.
40
+ *
41
+ * @param {object} client - ModbusRTU client
42
+ * @param {Array} slaves - Slave descriptors
43
+ * @param {number} address - PDU start address
44
+ * @param {number} count - Register count
45
+ * @returns {Promise<void>}
46
+ */
47
+ function pollSlaves(client, slaves, address, count) {
48
+ return slaves.reduce((chain, slave) => {
49
+ return chain.then(() => {
50
+ return readSlave(client, slave, address, count);
51
+ });
52
+ }, Promise.resolve());
53
+ }
54
+
55
+ /**
56
+ * Modbus RTU bus source: one serial port, several slave IDs polled in sequence.
57
+ *
58
+ * @example
59
+ * const source = modbusRtuBusSource(
60
+ * '/dev/ttyUSB0',
61
+ * { baudRate: 9600, stopBits: 2, parity: 'none' },
62
+ * [{ slaveId: 1, collector: t1 }, { slaveId: 2, collector: t2 }],
63
+ * { address: 1280, count: 30, interval: 5 }
64
+ * );
65
+ * source.start();
66
+ *
67
+ * @param {string} path - Serial device path
68
+ * @param {object} serial - baudRate, dataBits, stopBits, parity
69
+ * @param {Array<{slaveId: number, collector: object}>} slaves - Slave poll targets
70
+ * @param {object} poll - address, count, interval, optional log
71
+ * @returns {object} Source with start() and stop()
72
+ */
73
+ export default function modbusRtuBusSource(path, serial, slaves, poll) {
74
+ assertBusArgs(path, serial, slaves);
75
+ const log = poll.log || console;
76
+ const client = new ModbusRTU();
77
+ const line = {
78
+ baudRate: serial.baudRate,
79
+ dataBits: serial.dataBits || 8,
80
+ stopBits: serial.stopBits || 1,
81
+ parity: serial.parity || 'none'
82
+ };
83
+ let handle = null;
84
+ let opened = false;
85
+ function runPoll() {
86
+ pollSlaves(client, slaves, poll.address, poll.count).catch((err) => {
87
+ log.error(`RTU bus poll on ${path} failed: ${err.message}`);
88
+ });
89
+ }
90
+ function attempt() {
91
+ client.connectRTUBuffered(path, line).then(() => {
92
+ opened = true;
93
+ handle = setInterval(runPoll, poll.interval * 1000);
94
+ runPoll();
95
+ }).catch((err) => {
96
+ log.error(`Connection to ${path} failed, retrying: ${err.message}`);
97
+ handle = setTimeout(attempt, poll.interval * 1000);
98
+ });
99
+ }
100
+ return {
101
+ start() {
102
+ if (!(opened || handle)) {
103
+ attempt();
104
+ }
105
+ },
106
+ stop() {
107
+ if (handle) {
108
+ clearInterval(handle);
109
+ clearTimeout(handle);
110
+ handle = null;
111
+ }
112
+ if (opened) {
113
+ client.close();
114
+ opened = false;
115
+ }
116
+ }
117
+ };
118
+ }
@@ -4,7 +4,7 @@ import pollingSource from './pollingSource.js';
4
4
  /**
5
5
  * Idle state for Modbus connection.
6
6
  *
7
- * @returns {object} State with connected() returning false and no retry handle
7
+ * @returns {object} State with no open attempt
8
8
  */
9
9
  function idle() {
10
10
  return {
@@ -13,6 +13,9 @@ function idle() {
13
13
  },
14
14
  retrying() {
15
15
  return false;
16
+ },
17
+ opening() {
18
+ return false;
16
19
  }
17
20
  };
18
21
  }
@@ -31,12 +34,34 @@ function retrying(handle) {
31
34
  retrying() {
32
35
  return true;
33
36
  },
37
+ opening() {
38
+ return false;
39
+ },
34
40
  cancel() {
35
41
  clearTimeout(handle);
36
42
  }
37
43
  };
38
44
  }
39
45
 
46
+ /**
47
+ * In-flight TCP or RTU connect state.
48
+ *
49
+ * @returns {object} State with opening() returning true
50
+ */
51
+ 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
+
40
65
  /**
41
66
  * Connected state for Modbus connection.
42
67
  *
@@ -51,12 +76,44 @@ function connected(client) {
51
76
  retrying() {
52
77
  return false;
53
78
  },
79
+ opening() {
80
+ return false;
81
+ },
54
82
  disconnect() {
55
83
  client.close();
56
84
  }
57
85
  };
58
86
  }
59
87
 
88
+ /**
89
+ * Starts one connect attempt and ignores results after stop().
90
+ *
91
+ * @param {object} bag - Mutable lifecycle bag
92
+ */
93
+ function attempt(bag) {
94
+ const token = bag.epoch;
95
+ bag.state = opening();
96
+ bag.connect(bag.client).then(() => {
97
+ if (token !== bag.epoch) {
98
+ bag.client.close();
99
+ return;
100
+ }
101
+ bag.delay = bag.interval;
102
+ bag.state = connected(bag.client);
103
+ bag.source.start();
104
+ }).catch((err) => {
105
+ if (token !== bag.epoch) {
106
+ return;
107
+ }
108
+ bag.log.error(`Connection to ${bag.target} failed, retrying in ${bag.delay}s: ${err.message}`);
109
+ const handle = setTimeout(() => {
110
+ attempt(bag);
111
+ }, bag.delay * 1000);
112
+ bag.state = retrying(handle);
113
+ bag.delay = Math.min(bag.delay * 2, bag.maxDelay);
114
+ });
115
+ }
116
+
60
117
  /**
61
118
  * Shared Modbus polling lifecycle for TCP and RTU transports.
62
119
  *
@@ -64,60 +121,42 @@ function connected(client) {
64
121
  * @returns {object} Source with start() and stop() methods
65
122
  */
66
123
  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;
78
- let state = idle();
124
+ const { address, count, interval, collector, clk, client } = options;
79
125
  async function fetch() {
80
126
  const result = await client.readHoldingRegisters(address, count);
81
127
  return [result.data];
82
128
  }
83
- const maxDelay = 300;
84
- const source = pollingSource(fetch, interval, collector, clk);
85
- let delay = interval;
86
- function attempt() {
87
- connect(client).then(() => {
88
- delay = interval;
89
- state = connected(client);
90
- source.start();
91
- }).catch((err) => {
92
- log.error(`Connection to ${target} failed, retrying in ${delay}s: ${err.message}`);
93
- const handle = setTimeout(attempt, delay * 1000);
94
- state = retrying(handle);
95
- delay = Math.min(delay * 2, maxDelay);
96
- });
97
- }
129
+ const bag = {
130
+ ...options,
131
+ state: idle(),
132
+ epoch: 0,
133
+ maxDelay: 300,
134
+ source: pollingSource(fetch, interval, collector, clk),
135
+ delay: interval
136
+ };
98
137
  return {
99
138
  /**
100
139
  * Connects to the Modbus device and starts polling.
101
140
  */
102
141
  start() {
103
- if (state.connected() || state.retrying()) {
142
+ if (bag.state.connected() || bag.state.retrying() || bag.state.opening()) {
104
143
  return;
105
144
  }
106
- attempt();
145
+ attempt(bag);
107
146
  },
108
147
  /**
109
148
  * Stops polling and disconnects from the Modbus device.
110
149
  */
111
150
  stop() {
112
- source.stop();
113
- if (state.retrying()) {
114
- state.cancel();
115
- state = idle();
151
+ bag.epoch += 1;
152
+ bag.source.stop();
153
+ if (bag.state.retrying()) {
154
+ bag.state.cancel();
116
155
  }
117
- if (state.connected()) {
118
- state.disconnect();
119
- state = idle();
156
+ if (bag.state.connected() || bag.state.opening()) {
157
+ bag.client.close();
120
158
  }
159
+ bag.state = idle();
121
160
  }
122
161
  };
123
162
  }
@@ -0,0 +1,47 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'mocha';
3
+ import modbusRtuBusSource from '../src/modbusRtuBusSource.js';
4
+
5
+ describe('modbusRtuBusSource', () => {
6
+ it('throws on empty serial path', () => {
7
+ assert.throws(
8
+ () => {
9
+ return modbusRtuBusSource('', { baudRate: 9600 }, [{ slaveId: 1, collector: { accept() {} } }], {
10
+ address: 1280,
11
+ count: 30,
12
+ interval: 5
13
+ });
14
+ },
15
+ /Serial path must be a non-empty string/u,
16
+ 'Should reject empty serial path'
17
+ );
18
+ });
19
+
20
+ it('throws on empty slaves list', () => {
21
+ assert.throws(
22
+ () => {
23
+ return modbusRtuBusSource(`/dev/ttyUSB${Math.floor(Math.random() * 9)}`, { baudRate: 9600 }, [], {
24
+ address: 1280,
25
+ count: 30,
26
+ interval: 5
27
+ });
28
+ },
29
+ /Slaves must be a non-empty array/u,
30
+ 'Should reject empty slaves array'
31
+ );
32
+ });
33
+
34
+ it('creates source with start method for multiple slaves', () => {
35
+ const path = `/dev/ttyUSB${Math.floor(Math.random() * 9)}`;
36
+ const source = modbusRtuBusSource(
37
+ path,
38
+ { baudRate: 9600, stopBits: 2 },
39
+ [
40
+ { slaveId: 1, collector: { accept() {} } },
41
+ { slaveId: 2, collector: { accept() {} } }
42
+ ],
43
+ { address: 1280, count: 30, interval: 5 }
44
+ );
45
+ assert.strictEqual(typeof source.start, 'function', 'Should expose start for RTU bus source');
46
+ });
47
+ });
@@ -1,8 +1,89 @@
1
1
  import assert from 'node:assert';
2
+ import net from 'node:net';
2
3
  import { describe, it } from 'mocha';
3
4
  import modbusSource from '../src/modbusSource.js';
4
5
  import fakeClock from './fakeClock.js';
5
6
 
7
+ /**
8
+ * TCP listener that counts concurrent sockets on an ephemeral port.
9
+ *
10
+ * @returns {Promise<object>} Gate with port, live, peak, close
11
+ */
12
+ function countingListen() {
13
+ let live = 0;
14
+ let peak = 0;
15
+ const sockets = new Set();
16
+ const server = net.createServer((sock) => {
17
+ live += 1;
18
+ if (live > peak) {
19
+ peak = live;
20
+ }
21
+ sockets.add(sock);
22
+ sock.on('close', () => {
23
+ sockets.delete(sock);
24
+ live -= 1;
25
+ });
26
+ });
27
+ return new Promise((resolve) => {
28
+ server.listen(0, '127.0.0.1', () => {
29
+ resolve({
30
+ port: server.address().port,
31
+ live() {
32
+ return live;
33
+ },
34
+ peak() {
35
+ return peak;
36
+ },
37
+ close() {
38
+ for (const sock of sockets) {
39
+ sock.destroy();
40
+ }
41
+ return new Promise((done) => {
42
+ server.close(done);
43
+ });
44
+ }
45
+ });
46
+ });
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Resolves after the given delay.
52
+ *
53
+ * @param {number} ms - Delay in milliseconds
54
+ * @returns {Promise<void>} Timer promise
55
+ */
56
+ function settle(ms) {
57
+ return new Promise((resolve) => {
58
+ setTimeout(resolve, ms);
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Waits until probe is true or the timeout elapses.
64
+ *
65
+ * @param {Function} probe - Condition
66
+ * @param {number} limit - Timeout in milliseconds
67
+ * @returns {Promise<void>} Resolves when probe holds
68
+ */
69
+ function until(probe, limit) {
70
+ const deadline = Date.now() + limit;
71
+ function tick(resolve, reject) {
72
+ if (probe()) {
73
+ resolve();
74
+ return;
75
+ }
76
+ if (Date.now() >= deadline) {
77
+ reject(new Error('condition did not hold before timeout'));
78
+ return;
79
+ }
80
+ setTimeout(() => {
81
+ tick(resolve, reject);
82
+ }, 20);
83
+ }
84
+ return new Promise(tick);
85
+ }
86
+
6
87
  describe('modbusSource', () => {
7
88
  it('throws on empty host', () => {
8
89
  const collector = { accept: () => {} };
@@ -118,4 +199,73 @@ describe('modbusSource', () => {
118
199
  source.stop();
119
200
  assert.strictEqual(true, true, 'Should not crash on unreachable host');
120
201
  });
202
+
203
+ it('does not open a second TCP socket when start is called twice', async function() {
204
+ const gate = await countingListen();
205
+ const source = modbusSource(
206
+ '127.0.0.1',
207
+ gate.port,
208
+ 4000,
209
+ 14,
210
+ 5,
211
+ { accept() {} },
212
+ fakeClock(1 + Math.floor(Math.random() * 100)),
213
+ { error() {} }
214
+ );
215
+ source.start();
216
+ await until(() => {
217
+ return gate.live() >= 1;
218
+ }, 2000);
219
+ source.start();
220
+ await settle(150);
221
+ const peak = gate.peak();
222
+ source.stop();
223
+ await gate.close();
224
+ assert.strictEqual(peak, 1, 'second start opened another Modbus TCP socket');
225
+ });
226
+
227
+ it('closes the TCP socket when stop is called after connect', async function() {
228
+ const gate = await countingListen();
229
+ const source = modbusSource(
230
+ '127.0.0.1',
231
+ gate.port,
232
+ 4000,
233
+ 14,
234
+ 5,
235
+ { accept() {} },
236
+ fakeClock(3 + Math.floor(Math.random() * 100)),
237
+ { error() {} }
238
+ );
239
+ source.start();
240
+ await until(() => {
241
+ return gate.live() >= 1;
242
+ }, 2000);
243
+ source.stop();
244
+ await until(() => {
245
+ return gate.live() === 0;
246
+ }, 2000);
247
+ const live = gate.live();
248
+ await gate.close();
249
+ assert.strictEqual(live, 0, 'stop after connect left a Modbus TCP socket open');
250
+ });
251
+
252
+ it('does not keep a TCP socket when stop is called during connect', async function() {
253
+ const gate = await countingListen();
254
+ const source = modbusSource(
255
+ '127.0.0.1',
256
+ gate.port,
257
+ 4000,
258
+ 14,
259
+ 5,
260
+ { accept() {} },
261
+ fakeClock(7 + Math.floor(Math.random() * 100)),
262
+ { error() {} }
263
+ );
264
+ source.start();
265
+ source.stop();
266
+ await settle(400);
267
+ const live = gate.live();
268
+ await gate.close();
269
+ assert.strictEqual(live, 0, 'stop during connect left a Modbus TCP socket open');
270
+ });
121
271
  });