@yarkivaev/source-to-sink 1.3.6 → 1.3.8
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/eslint.config.js +1 -0
- package/package.json +1 -1
- package/src/modbusSource.js +72 -14
- package/src/pollingSource.js +8 -5
- package/test/test_modbusSource.js +211 -0
- package/test/test_pollingSource.js +20 -0
package/eslint.config.js
CHANGED
|
@@ -150,6 +150,7 @@ export default [
|
|
|
150
150
|
files: ['src/modbusSource.js'],
|
|
151
151
|
rules: {
|
|
152
152
|
'max-params': ['error', 8],
|
|
153
|
+
'max-lines': ['error', { 'max': 250, 'skipBlankLines': true, 'skipComments': true }],
|
|
153
154
|
'max-lines-per-function': ['error', { 'max': 60, 'skipBlankLines': true, 'skipComments': true }],
|
|
154
155
|
'max-statements': ['error', 20]
|
|
155
156
|
}
|
package/package.json
CHANGED
package/src/modbusSource.js
CHANGED
|
@@ -85,6 +85,28 @@ function connected(client) {
|
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Destroys a Modbus client without throwing if already closed.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} client - ModbusRTU client
|
|
92
|
+
*/
|
|
93
|
+
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
|
+
function create() {
|
|
107
|
+
return new ModbusRTU();
|
|
108
|
+
}
|
|
109
|
+
|
|
88
110
|
/**
|
|
89
111
|
* Starts one connect attempt and ignores results after stop().
|
|
90
112
|
*
|
|
@@ -93,18 +115,25 @@ function connected(client) {
|
|
|
93
115
|
function attempt(bag) {
|
|
94
116
|
const token = bag.epoch;
|
|
95
117
|
bag.state = opening();
|
|
118
|
+
drop(bag.client);
|
|
119
|
+
bag.client = bag.create();
|
|
96
120
|
bag.connect(bag.client).then(() => {
|
|
97
121
|
if (token !== bag.epoch) {
|
|
98
|
-
bag.client
|
|
122
|
+
drop(bag.client);
|
|
99
123
|
return;
|
|
100
124
|
}
|
|
101
125
|
bag.delay = bag.interval;
|
|
102
126
|
bag.state = connected(bag.client);
|
|
127
|
+
bag.client.setTimeout(bag.interval * 1000);
|
|
128
|
+
bag.client.once('close', () => {
|
|
129
|
+
bag.lost();
|
|
130
|
+
});
|
|
103
131
|
bag.source.start();
|
|
104
132
|
}).catch((err) => {
|
|
105
133
|
if (token !== bag.epoch) {
|
|
106
134
|
return;
|
|
107
135
|
}
|
|
136
|
+
drop(bag.client);
|
|
108
137
|
bag.log.error(`Connection to ${bag.target} failed, retrying in ${bag.delay}s: ${err.message}`);
|
|
109
138
|
const handle = setTimeout(() => {
|
|
110
139
|
attempt(bag);
|
|
@@ -114,6 +143,28 @@ function attempt(bag) {
|
|
|
114
143
|
});
|
|
115
144
|
}
|
|
116
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Resets a live session and retries through attempt() backoff.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} bag - Mutable lifecycle bag
|
|
150
|
+
*/
|
|
151
|
+
function resume(bag) {
|
|
152
|
+
if (!bag.state.connected()) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
bag.source.stop();
|
|
156
|
+
const { client } = bag;
|
|
157
|
+
bag.client = undefined;
|
|
158
|
+
bag.state = idle();
|
|
159
|
+
drop(client);
|
|
160
|
+
bag.log.error(`Connection to ${bag.target} lost, retrying in ${bag.delay}s`);
|
|
161
|
+
const handle = setTimeout(() => {
|
|
162
|
+
attempt(bag);
|
|
163
|
+
}, bag.delay * 1000);
|
|
164
|
+
bag.state = retrying(handle);
|
|
165
|
+
bag.delay = Math.min(bag.delay * 2, bag.maxDelay);
|
|
166
|
+
}
|
|
167
|
+
|
|
117
168
|
/**
|
|
118
169
|
* Shared Modbus polling lifecycle for TCP and RTU transports.
|
|
119
170
|
*
|
|
@@ -121,19 +172,28 @@ function attempt(bag) {
|
|
|
121
172
|
* @returns {object} Source with start() and stop() methods
|
|
122
173
|
*/
|
|
123
174
|
function modbusPollingSource(options) {
|
|
124
|
-
const { address, count, interval, collector, clk
|
|
125
|
-
async function fetch() {
|
|
126
|
-
const result = await client.readHoldingRegisters(address, count);
|
|
127
|
-
return [result.data];
|
|
128
|
-
}
|
|
175
|
+
const { address, count, interval, collector, clk } = options;
|
|
129
176
|
const bag = {
|
|
130
177
|
...options,
|
|
178
|
+
client: undefined,
|
|
131
179
|
state: idle(),
|
|
132
180
|
epoch: 0,
|
|
133
181
|
maxDelay: 300,
|
|
134
|
-
source: pollingSource(fetch, interval, collector, clk),
|
|
135
182
|
delay: interval
|
|
136
183
|
};
|
|
184
|
+
bag.lost = function lost() {
|
|
185
|
+
resume(bag);
|
|
186
|
+
};
|
|
187
|
+
async function fetch() {
|
|
188
|
+
try {
|
|
189
|
+
const result = await bag.client.readHoldingRegisters(address, count);
|
|
190
|
+
return [result.data];
|
|
191
|
+
} catch (err) {
|
|
192
|
+
bag.lost();
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
bag.source = pollingSource(fetch, interval, collector, clk);
|
|
137
197
|
return {
|
|
138
198
|
/**
|
|
139
199
|
* Connects to the Modbus device and starts polling.
|
|
@@ -153,10 +213,10 @@ function modbusPollingSource(options) {
|
|
|
153
213
|
if (bag.state.retrying()) {
|
|
154
214
|
bag.state.cancel();
|
|
155
215
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
216
|
+
const { client } = bag;
|
|
217
|
+
bag.client = undefined;
|
|
159
218
|
bag.state = idle();
|
|
219
|
+
drop(client);
|
|
160
220
|
}
|
|
161
221
|
};
|
|
162
222
|
}
|
|
@@ -195,11 +255,11 @@ export default function modbusSource(host, port, address, count, interval, colle
|
|
|
195
255
|
if (typeof count !== 'number' || count <= 0) {
|
|
196
256
|
throw new Error(`Count must be a positive number, got: ${count}`);
|
|
197
257
|
}
|
|
198
|
-
const client = new ModbusRTU();
|
|
199
258
|
function connect(modbusClient) {
|
|
200
259
|
return modbusClient.connectTCP(host, { port });
|
|
201
260
|
}
|
|
202
261
|
return modbusPollingSource({
|
|
262
|
+
create,
|
|
203
263
|
connect,
|
|
204
264
|
target: `${host}:${port}`,
|
|
205
265
|
address,
|
|
@@ -207,7 +267,6 @@ export default function modbusSource(host, port, address, count, interval, colle
|
|
|
207
267
|
interval,
|
|
208
268
|
collector,
|
|
209
269
|
clk,
|
|
210
|
-
client,
|
|
211
270
|
log
|
|
212
271
|
});
|
|
213
272
|
}
|
|
@@ -248,7 +307,6 @@ export function modbusRtuSource(path, serial, address, count, interval, collecto
|
|
|
248
307
|
if (typeof count !== 'number' || count <= 0) {
|
|
249
308
|
throw new Error(`Count must be a positive number, got: ${count}`);
|
|
250
309
|
}
|
|
251
|
-
const client = new ModbusRTU();
|
|
252
310
|
const slaveId = serial.slaveId || 1;
|
|
253
311
|
const dataBits = serial.dataBits || 8;
|
|
254
312
|
const stopBits = serial.stopBits || 1;
|
|
@@ -264,6 +322,7 @@ export function modbusRtuSource(path, serial, address, count, interval, collecto
|
|
|
264
322
|
});
|
|
265
323
|
}
|
|
266
324
|
return modbusPollingSource({
|
|
325
|
+
create,
|
|
267
326
|
connect,
|
|
268
327
|
target: path,
|
|
269
328
|
address,
|
|
@@ -271,7 +330,6 @@ export function modbusRtuSource(path, serial, address, count, interval, collecto
|
|
|
271
330
|
interval,
|
|
272
331
|
collector,
|
|
273
332
|
clk,
|
|
274
|
-
client,
|
|
275
333
|
log
|
|
276
334
|
});
|
|
277
335
|
}
|
package/src/pollingSource.js
CHANGED
|
@@ -64,14 +64,17 @@ export default function pollingSource(fetch, interval, collector, clk) {
|
|
|
64
64
|
}
|
|
65
65
|
let state = idle();
|
|
66
66
|
let since = clk.millis();
|
|
67
|
-
|
|
67
|
+
function poll() {
|
|
68
68
|
const from = since;
|
|
69
69
|
const until = clk.millis();
|
|
70
70
|
since = until;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
Promise.resolve(fetch(from, until)).then((result) => {
|
|
72
|
+
for (const entry of result) {
|
|
73
|
+
collector.accept(entry);
|
|
74
|
+
}
|
|
75
|
+
}).catch(() => {
|
|
76
|
+
return undefined;
|
|
77
|
+
});
|
|
75
78
|
}
|
|
76
79
|
return {
|
|
77
80
|
/**
|
|
@@ -59,6 +59,27 @@ function settle(ms) {
|
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Collects unhandledRejection events until stop().
|
|
64
|
+
*
|
|
65
|
+
* @returns {object} Collector with seen() and stop()
|
|
66
|
+
*/
|
|
67
|
+
function watchRejects() {
|
|
68
|
+
const seen = [];
|
|
69
|
+
function onReject(err) {
|
|
70
|
+
seen.push(err);
|
|
71
|
+
}
|
|
72
|
+
process.on('unhandledRejection', onReject);
|
|
73
|
+
return {
|
|
74
|
+
seen() {
|
|
75
|
+
return seen.length;
|
|
76
|
+
},
|
|
77
|
+
stop() {
|
|
78
|
+
process.off('unhandledRejection', onReject);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
62
83
|
/**
|
|
63
84
|
* Waits until probe is true or the timeout elapses.
|
|
64
85
|
*
|
|
@@ -84,6 +105,114 @@ function until(probe, limit) {
|
|
|
84
105
|
return new Promise(tick);
|
|
85
106
|
}
|
|
86
107
|
|
|
108
|
+
/**
|
|
109
|
+
* TCP listener that counts accepted sockets and can drop them.
|
|
110
|
+
*
|
|
111
|
+
* @returns {Promise<object>} Gate with port, accepted, drop, close
|
|
112
|
+
*/
|
|
113
|
+
function sessionListen() {
|
|
114
|
+
let accepted = 0;
|
|
115
|
+
const sockets = new Set();
|
|
116
|
+
const server = net.createServer((sock) => {
|
|
117
|
+
accepted += 1;
|
|
118
|
+
sockets.add(sock);
|
|
119
|
+
sock.on('close', () => {
|
|
120
|
+
sockets.delete(sock);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
server.listen(0, '127.0.0.1', () => {
|
|
125
|
+
resolve({
|
|
126
|
+
port: server.address().port,
|
|
127
|
+
accepted() {
|
|
128
|
+
return accepted;
|
|
129
|
+
},
|
|
130
|
+
drop() {
|
|
131
|
+
for (const sock of sockets) {
|
|
132
|
+
sock.destroy();
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
close() {
|
|
136
|
+
for (const sock of sockets) {
|
|
137
|
+
sock.destroy();
|
|
138
|
+
}
|
|
139
|
+
return new Promise((done) => {
|
|
140
|
+
server.close(done);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Builds a Modbus TCP Read Holding Registers response.
|
|
150
|
+
*
|
|
151
|
+
* @param {Buffer} req - Request frame
|
|
152
|
+
* @param {number[]} values - Register values
|
|
153
|
+
* @returns {Buffer} Response frame
|
|
154
|
+
*/
|
|
155
|
+
function holding(req, values) {
|
|
156
|
+
const tid = req.readUInt16BE(0);
|
|
157
|
+
const uid = req.readUInt8(6);
|
|
158
|
+
const bytes = values.length * 2;
|
|
159
|
+
const out = Buffer.alloc(9 + bytes);
|
|
160
|
+
out.writeUInt16BE(tid, 0);
|
|
161
|
+
out.writeUInt16BE(0, 2);
|
|
162
|
+
out.writeUInt16BE(3 + bytes, 4);
|
|
163
|
+
out.writeUInt8(uid, 6);
|
|
164
|
+
out.writeUInt8(3, 7);
|
|
165
|
+
out.writeUInt8(bytes, 8);
|
|
166
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
167
|
+
out.writeUInt16BE(values[i], 9 + i * 2);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* TCP listener that stays silent on the first socket then answers later ones.
|
|
174
|
+
*
|
|
175
|
+
* @param {number[]} values - Holding register values
|
|
176
|
+
* @returns {Promise<object>} Gate with port, accepted, close
|
|
177
|
+
*/
|
|
178
|
+
function recoverListen(values) {
|
|
179
|
+
let accepted = 0;
|
|
180
|
+
const sockets = new Set();
|
|
181
|
+
const server = net.createServer((sock) => {
|
|
182
|
+
accepted += 1;
|
|
183
|
+
sockets.add(sock);
|
|
184
|
+
sock.on('close', () => {
|
|
185
|
+
sockets.delete(sock);
|
|
186
|
+
});
|
|
187
|
+
if (accepted === 1) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
sock.on('data', (req) => {
|
|
191
|
+
if (req.length >= 12 && req.readUInt8(7) === 3) {
|
|
192
|
+
sock.write(holding(req, values));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
return new Promise((resolve) => {
|
|
197
|
+
server.listen(0, '127.0.0.1', () => {
|
|
198
|
+
resolve({
|
|
199
|
+
port: server.address().port,
|
|
200
|
+
accepted() {
|
|
201
|
+
return accepted;
|
|
202
|
+
},
|
|
203
|
+
close() {
|
|
204
|
+
for (const sock of sockets) {
|
|
205
|
+
sock.destroy();
|
|
206
|
+
}
|
|
207
|
+
return new Promise((done) => {
|
|
208
|
+
server.close(done);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
87
216
|
describe('modbusSource', () => {
|
|
88
217
|
it('throws on empty host', () => {
|
|
89
218
|
const collector = { accept: () => {} };
|
|
@@ -200,6 +329,28 @@ describe('modbusSource', () => {
|
|
|
200
329
|
assert.strictEqual(true, true, 'Should not crash on unreachable host');
|
|
201
330
|
});
|
|
202
331
|
|
|
332
|
+
it('does not emit unhandled rejection after connection retry', async function() {
|
|
333
|
+
this.timeout(4000);
|
|
334
|
+
const watch = watchRejects();
|
|
335
|
+
const log = { error() {} };
|
|
336
|
+
const port = 65000 + Math.floor(Math.random() * 500);
|
|
337
|
+
const source = modbusSource(
|
|
338
|
+
'127.0.0.1',
|
|
339
|
+
port,
|
|
340
|
+
4000,
|
|
341
|
+
14,
|
|
342
|
+
0.05,
|
|
343
|
+
{ accept() {} },
|
|
344
|
+
fakeClock(1 + Math.floor(Math.random() * 100)),
|
|
345
|
+
log
|
|
346
|
+
);
|
|
347
|
+
source.start();
|
|
348
|
+
await settle(400);
|
|
349
|
+
source.stop();
|
|
350
|
+
watch.stop();
|
|
351
|
+
assert.strictEqual(watch.seen(), 0, 'retry leaked an unhandled rejection');
|
|
352
|
+
});
|
|
353
|
+
|
|
203
354
|
it('does not open a second TCP socket when start is called twice', async function() {
|
|
204
355
|
const gate = await countingListen();
|
|
205
356
|
const source = modbusSource(
|
|
@@ -268,4 +419,64 @@ describe('modbusSource', () => {
|
|
|
268
419
|
await gate.close();
|
|
269
420
|
assert.strictEqual(live, 0, 'stop during connect left a Modbus TCP socket open');
|
|
270
421
|
});
|
|
422
|
+
|
|
423
|
+
it('opens a new TCP socket after the device stays silent or drops the session', async function() {
|
|
424
|
+
this.timeout(6000);
|
|
425
|
+
const gate = await sessionListen();
|
|
426
|
+
const source = modbusSource(
|
|
427
|
+
'127.0.0.1',
|
|
428
|
+
gate.port,
|
|
429
|
+
4000,
|
|
430
|
+
3 + Math.floor(Math.random() * 8),
|
|
431
|
+
0.05,
|
|
432
|
+
{ accept() {} },
|
|
433
|
+
fakeClock(11 + Math.floor(Math.random() * 100)),
|
|
434
|
+
{ error() {} }
|
|
435
|
+
);
|
|
436
|
+
source.start();
|
|
437
|
+
await until(() => {
|
|
438
|
+
return gate.accepted() >= 1;
|
|
439
|
+
}, 2000);
|
|
440
|
+
await settle(80);
|
|
441
|
+
gate.drop();
|
|
442
|
+
await until(() => {
|
|
443
|
+
return gate.accepted() >= 2;
|
|
444
|
+
}, 3000);
|
|
445
|
+
const accepted = gate.accepted();
|
|
446
|
+
source.stop();
|
|
447
|
+
await gate.close();
|
|
448
|
+
assert.ok(accepted >= 2, 'silent or dropped session did not open a new Modbus TCP socket');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('forwards a new sample after the device answers again', async function() {
|
|
452
|
+
this.timeout(6000);
|
|
453
|
+
const count = 3 + Math.floor(Math.random() * 6);
|
|
454
|
+
const values = Array.from({ length: count }, () => {
|
|
455
|
+
return 1000 + Math.floor(Math.random() * 500);
|
|
456
|
+
});
|
|
457
|
+
const samples = [];
|
|
458
|
+
const gate = await recoverListen(values);
|
|
459
|
+
const source = modbusSource(
|
|
460
|
+
'127.0.0.1',
|
|
461
|
+
gate.port,
|
|
462
|
+
4000,
|
|
463
|
+
count,
|
|
464
|
+
0.05,
|
|
465
|
+
{
|
|
466
|
+
accept(entry) {
|
|
467
|
+
samples.push(entry);
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
fakeClock(13 + Math.floor(Math.random() * 100)),
|
|
471
|
+
{ error() {} }
|
|
472
|
+
);
|
|
473
|
+
source.start();
|
|
474
|
+
await until(() => {
|
|
475
|
+
return samples.length >= 1;
|
|
476
|
+
}, 4000);
|
|
477
|
+
const sample = samples[0];
|
|
478
|
+
source.stop();
|
|
479
|
+
await gate.close();
|
|
480
|
+
assert.deepStrictEqual(sample, values, 'restored device did not deliver a new sample');
|
|
481
|
+
});
|
|
271
482
|
});
|
|
@@ -91,6 +91,26 @@ describe('pollingSource', () => {
|
|
|
91
91
|
assert.strictEqual(count, before, 'Should stop polling after stop');
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
it('does not emit unhandled rejection when fetch rejects', async () => {
|
|
95
|
+
const seen = [];
|
|
96
|
+
function onReject(err) {
|
|
97
|
+
seen.push(err);
|
|
98
|
+
}
|
|
99
|
+
process.on('unhandledRejection', onReject);
|
|
100
|
+
const label = `\u00e9${Math.random()}`;
|
|
101
|
+
const fetch = () => {
|
|
102
|
+
return Promise.reject(new Error(label));
|
|
103
|
+
};
|
|
104
|
+
const source = pollingSource(fetch, 0.03, { accept() {} }, fakeClock(1 + Math.floor(Math.random() * 100)));
|
|
105
|
+
source.start();
|
|
106
|
+
await new Promise((resolve) => {
|
|
107
|
+
setTimeout(resolve, 80);
|
|
108
|
+
});
|
|
109
|
+
source.stop();
|
|
110
|
+
process.off('unhandledRejection', onReject);
|
|
111
|
+
assert.strictEqual(seen.length, 0, 'poll rejection leaked as unhandled');
|
|
112
|
+
});
|
|
113
|
+
|
|
94
114
|
it('throws on missing fetch', () => {
|
|
95
115
|
const collector = { accept: () => {} };
|
|
96
116
|
const clk = fakeClock(0);
|