njs-modbus 3.2.0 → 3.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.
package/dist/index.mjs CHANGED
@@ -164,40 +164,75 @@ function bitsToMs(baudRate, bits) {
164
164
  * open / close / destroy callbacks.
165
165
  */
166
166
  function drainCbs(cbs, err) {
167
- if (!cbs)
167
+ if (!cbs) {
168
168
  return;
169
+ }
169
170
  for (const cb of cbs) {
170
171
  cb?.(err);
171
172
  }
172
173
  }
173
174
 
174
- function inRange(n, [min, max]) {
175
- return n >= min && n <= max;
176
- }
177
- function isRangeArray(range) {
178
- return Array.isArray(range[0]);
179
- }
180
175
  function checkRange(value, range) {
181
176
  if (!range || range.length === 0) {
182
177
  return true;
183
178
  }
184
- const values = Array.isArray(value) ? value : [value];
185
- if (isRangeArray(range)) {
186
- for (const r of range) {
187
- const [min, max] = r;
188
- const [lo, hi] = min <= max ? [min, max] : [max, min];
189
- if (values.every((n) => inRange(n, [lo, hi]))) {
179
+ const isMultiRange = Array.isArray(range[0]);
180
+ const isValueArray = Array.isArray(value);
181
+ if (!isValueArray && !isMultiRange) {
182
+ const r = range;
183
+ const min = r[0], max = r[1];
184
+ const v = value;
185
+ return min <= max ? v >= min && v <= max : v >= max && v <= min;
186
+ }
187
+ if (!isValueArray && isMultiRange) {
188
+ const ranges = range;
189
+ const v = value;
190
+ for (let i = 0; i < ranges.length; i++) {
191
+ const min = ranges[i][0], max = ranges[i][1];
192
+ const lo = min <= max ? min : max;
193
+ const hi = min <= max ? max : min;
194
+ if (v >= lo && v <= hi) {
190
195
  return true;
191
196
  }
192
197
  }
193
198
  return false;
194
199
  }
195
- const [min, max] = range;
196
- const [lo, hi] = min <= max ? [min, max] : [max, min];
197
- return values.every((n) => inRange(n, [lo, hi]));
200
+ const values = value;
201
+ if (values.length === 0) {
202
+ return true;
203
+ }
204
+ if (!isMultiRange) {
205
+ const r = range;
206
+ const min = r[0], max = r[1];
207
+ const lo = min <= max ? min : max;
208
+ const hi = min <= max ? max : min;
209
+ for (let i = 0; i < values.length; i++) {
210
+ if (values[i] < lo || values[i] > hi) {
211
+ return false;
212
+ }
213
+ }
214
+ return true;
215
+ }
216
+ const ranges = range;
217
+ for (let i = 0; i < ranges.length; i++) {
218
+ const min = ranges[i][0], max = ranges[i][1];
219
+ const lo = min <= max ? min : max;
220
+ const hi = min <= max ? max : min;
221
+ let allInRange = true;
222
+ for (let j = 0; j < values.length; j++) {
223
+ if (values[j] < lo || values[j] > hi) {
224
+ allInRange = false;
225
+ break;
226
+ }
227
+ }
228
+ if (allInRange) {
229
+ return true;
230
+ }
231
+ }
232
+ return false;
198
233
  }
199
234
 
200
- const TABLE = [
235
+ const CRC_TABLE = new Uint16Array([
201
236
  0x0000, 0xc0c1, 0xc181, 0x0140, 0xc301, 0x03c0, 0x0280, 0xc241, 0xc601, 0x06c0, 0x0780, 0xc741, 0x0500, 0xc5c1, 0xc481, 0x0440, 0xcc01,
202
237
  0x0cc0, 0x0d80, 0xcd41, 0x0f00, 0xcfc1, 0xce81, 0x0e40, 0x0a00, 0xcac1, 0xcb81, 0x0b40, 0xc901, 0x09c0, 0x0880, 0xc841, 0xd801, 0x18c0,
203
238
  0x1980, 0xd941, 0x1b00, 0xdbc1, 0xda81, 0x1a40, 0x1e00, 0xdec1, 0xdf81, 0x1f40, 0xdd01, 0x1dc0, 0x1c80, 0xdc41, 0x1400, 0xd4c1, 0xd581,
@@ -214,131 +249,127 @@ const TABLE = [
214
249
  0x59c0, 0x5880, 0x9841, 0x8801, 0x48c0, 0x4980, 0x8941, 0x4b00, 0x8bc1, 0x8a81, 0x4a40, 0x4e00, 0x8ec1, 0x8f81, 0x4f40, 0x8d01, 0x4dc0,
215
250
  0x4c80, 0x8c41, 0x4400, 0x84c1, 0x8581, 0x4540, 0x8701, 0x47c0, 0x4680, 0x8641, 0x8201, 0x42c0, 0x4380, 0x8341, 0x4100, 0x81c1, 0x8081,
216
251
  0x4040,
217
- ];
218
- function crc(data, start = 0, end = data.length) {
252
+ ]);
253
+ /** CRC-16 (Modbus) over a single contiguous buffer. */
254
+ function crcFixed(data, start, end) {
219
255
  let crc = 0xffff;
220
256
  for (let index = start; index < end; index++) {
221
- crc = (TABLE[(crc ^ data[index]) & 0xff] ^ (crc >> 8)) & 0xffff;
257
+ crc = CRC_TABLE[(crc ^ data[index]) & 0xff] ^ (crc >> 8);
222
258
  }
223
259
  return crc;
224
260
  }
225
-
226
261
  /**
227
- * Returns true when `n` is an integer in the unsigned-byte range [0, 255].
228
- *
229
- * Used for byte-level Modbus payload validation (function-code values, raw
230
- * byte arrays in FC17/FC43 responses) — rejects negative, fractional, NaN,
231
- * Infinity, and out-of-range values uniformly.
262
+ * CRC-16 (Modbus) over two contiguous buffer segments.
263
+ * Computes CRC(head[headOff:headOff+headLen]) followed by CRC(tail[tailOff:tailOff+tailLen]).
232
264
  */
233
- function isUint8(n) {
234
- return Number.isInteger(n) && n >= 0 && n <= 255;
265
+ function crcDual(head, headOff, headLen, tail, tailOff, tailLen) {
266
+ let crc = 0xffff;
267
+ const headEnd = headOff + headLen;
268
+ for (let i = headOff; i < headEnd; i++) {
269
+ crc = CRC_TABLE[(crc ^ head[i]) & 0xff] ^ (crc >> 8);
270
+ }
271
+ const tailEnd = tailOff + tailLen;
272
+ for (let i = tailOff; i < tailEnd; i++) {
273
+ crc = CRC_TABLE[(crc ^ tail[i]) & 0xff] ^ (crc >> 8);
274
+ }
275
+ return crc;
235
276
  }
236
277
 
237
- function lrc(data, start = 0, end = data.length) {
278
+ function lrc(data, start, end) {
238
279
  let sum = 0;
239
280
  for (let i = start; i < end; i++) {
240
281
  sum += data[i];
241
282
  }
242
- return (~sum + 1) & 0xff;
283
+ return -sum & 0xff;
243
284
  }
244
285
 
245
- const REQUEST_FIXED_LENGTHS = {
246
- [FunctionCode.READ_COILS]: 8,
247
- [FunctionCode.READ_DISCRETE_INPUTS]: 8,
248
- [FunctionCode.READ_HOLDING_REGISTERS]: 8,
249
- [FunctionCode.READ_INPUT_REGISTERS]: 8,
250
- [FunctionCode.WRITE_SINGLE_COIL]: 8,
251
- [FunctionCode.WRITE_SINGLE_REGISTER]: 8,
252
- [FunctionCode.REPORT_SERVER_ID]: 4,
253
- [FunctionCode.MASK_WRITE_REGISTER]: 10,
254
- [FunctionCode.READ_DEVICE_IDENTIFICATION]: 7,
255
- };
256
- const REQUEST_BYTE_COUNT = {
257
- [FunctionCode.WRITE_MULTIPLE_COILS]: { offset: 6, extra: 9 },
258
- [FunctionCode.WRITE_MULTIPLE_REGISTERS]: { offset: 6, extra: 9 },
259
- [FunctionCode.READ_WRITE_MULTIPLE_REGISTERS]: { offset: 10, extra: 13 },
260
- };
261
- const RESPONSE_FIXED_LENGTHS = {
262
- [FunctionCode.WRITE_SINGLE_COIL]: 8,
263
- [FunctionCode.WRITE_SINGLE_REGISTER]: 8,
264
- [FunctionCode.WRITE_MULTIPLE_COILS]: 8,
265
- [FunctionCode.WRITE_MULTIPLE_REGISTERS]: 8,
266
- [FunctionCode.MASK_WRITE_REGISTER]: 10,
267
- };
268
- const RESPONSE_BYTE_COUNT = {
269
- [FunctionCode.READ_COILS]: { offset: 2, extra: 5 },
270
- [FunctionCode.READ_DISCRETE_INPUTS]: { offset: 2, extra: 5 },
271
- [FunctionCode.READ_HOLDING_REGISTERS]: { offset: 2, extra: 5 },
272
- [FunctionCode.READ_INPUT_REGISTERS]: { offset: 2, extra: 5 },
273
- [FunctionCode.REPORT_SERVER_ID]: { offset: 2, extra: 5 },
274
- [FunctionCode.READ_WRITE_MULTIPLE_REGISTERS]: { offset: 2, extra: 5 },
275
- };
276
- /** Sentinel: caller needs to feed more bytes before length can be determined. */
277
286
  const PREDICT_NEED_MORE = 0;
278
- /** Sentinel: function code is not in the standard tables. */
279
287
  const PREDICT_UNKNOWN = -1;
280
- /**
281
- * Predict the total RTU frame length (PDU + 2-byte CRC) given the leading bytes.
282
- *
283
- * Returns a sentinel-encoded number to avoid per-call object allocation on the
284
- * RTU decode hot path:
285
- * - Positive integer (>= 4): total frame length, function code is known.
286
- * - {@link PREDICT_NEED_MORE} (0): function code is known but more bytes are
287
- * required (typically waiting on the byteCount byte).
288
- * - {@link PREDICT_UNKNOWN} (-1): function code is not in the standard tables —
289
- * the framing layer must defer to a registered `CustomFunctionCode` or treat
290
- * this as a framing error.
291
- */
292
- function predictRtuFrameLength(buffer, start, end, isResponse) {
293
- if (end - start < 2) {
288
+ const REQ_TABLE = new Int32Array(256);
289
+ const RES_TABLE = new Int32Array(256);
290
+ (function initTables() {
291
+ REQ_TABLE[FunctionCode.READ_COILS] = 8;
292
+ REQ_TABLE[FunctionCode.READ_DISCRETE_INPUTS] = 8;
293
+ REQ_TABLE[FunctionCode.READ_HOLDING_REGISTERS] = 8;
294
+ REQ_TABLE[FunctionCode.READ_INPUT_REGISTERS] = 8;
295
+ REQ_TABLE[FunctionCode.WRITE_SINGLE_COIL] = 8;
296
+ REQ_TABLE[FunctionCode.WRITE_SINGLE_REGISTER] = 8;
297
+ REQ_TABLE[FunctionCode.REPORT_SERVER_ID] = 4;
298
+ REQ_TABLE[FunctionCode.MASK_WRITE_REGISTER] = 10;
299
+ REQ_TABLE[FunctionCode.READ_DEVICE_IDENTIFICATION] = 7;
300
+ REQ_TABLE[FunctionCode.WRITE_MULTIPLE_COILS] = -1545;
301
+ REQ_TABLE[FunctionCode.WRITE_MULTIPLE_REGISTERS] = -1545;
302
+ REQ_TABLE[FunctionCode.READ_WRITE_MULTIPLE_REGISTERS] = -2573;
303
+ RES_TABLE[FunctionCode.WRITE_SINGLE_COIL] = 8;
304
+ RES_TABLE[FunctionCode.WRITE_SINGLE_REGISTER] = 8;
305
+ RES_TABLE[FunctionCode.WRITE_MULTIPLE_COILS] = 8;
306
+ RES_TABLE[FunctionCode.WRITE_MULTIPLE_REGISTERS] = 8;
307
+ RES_TABLE[FunctionCode.MASK_WRITE_REGISTER] = 10;
308
+ RES_TABLE[FunctionCode.READ_COILS] = -517;
309
+ RES_TABLE[FunctionCode.READ_DISCRETE_INPUTS] = -517;
310
+ RES_TABLE[FunctionCode.READ_HOLDING_REGISTERS] = -517;
311
+ RES_TABLE[FunctionCode.READ_INPUT_REGISTERS] = -517;
312
+ RES_TABLE[FunctionCode.REPORT_SERVER_ID] = -517;
313
+ RES_TABLE[FunctionCode.READ_WRITE_MULTIPLE_REGISTERS] = -517;
314
+ RES_TABLE[FunctionCode.READ_DEVICE_IDENTIFICATION] = -999;
315
+ })();
316
+ function predictRtuFrameLength(residual, data, residualLen, start, end, isResponse) {
317
+ const len = end - start;
318
+ if (len < 2) {
294
319
  return PREDICT_NEED_MORE;
295
320
  }
296
- const fc = buffer[start + 1];
297
- if (isResponse && (fc & EXCEPTION_OFFSET) !== 0) {
298
- return 5;
299
- }
300
- const fixed = (isResponse ? RESPONSE_FIXED_LENGTHS : REQUEST_FIXED_LENGTHS)[fc];
301
- if (fixed !== undefined) {
302
- return fixed;
303
- }
304
- const bc = (isResponse ? RESPONSE_BYTE_COUNT : REQUEST_BYTE_COUNT)[fc];
305
- if (bc !== undefined) {
306
- if (end - start <= bc.offset) {
307
- return PREDICT_NEED_MORE;
321
+ const fc = start + 1 < residualLen ? residual[start + 1] : data[start + 1 - residualLen];
322
+ if (isResponse) {
323
+ if ((fc & EXCEPTION_OFFSET) !== 0) {
324
+ return 5;
325
+ }
326
+ const val = RES_TABLE[fc];
327
+ if (val > 0) {
328
+ return val;
329
+ }
330
+ if (val < 0) {
331
+ if (val === -999) {
332
+ // FC 43 / MEI 14 response — inline to avoid function-call overhead on
333
+ // the framing hot path (even though this FC is uncommon).
334
+ if (end - start < 8) {
335
+ return PREDICT_NEED_MORE;
336
+ }
337
+ if ((start + 2 < residualLen ? residual[start + 2] : data[start + 2 - residualLen]) !== MEI_READ_DEVICE_ID) {
338
+ return PREDICT_UNKNOWN;
339
+ }
340
+ const numObjs = start + 7 < residualLen ? residual[start + 7] : data[start + 7 - residualLen];
341
+ let cursor = start + 8;
342
+ for (let i = 0; i < numObjs; i++) {
343
+ if (end < cursor + 2) {
344
+ return PREDICT_NEED_MORE;
345
+ }
346
+ cursor += 2 + (cursor + 1 < residualLen ? residual[cursor + 1] : data[cursor + 1 - residualLen]);
347
+ }
348
+ return cursor - start + 2;
349
+ }
350
+ const decode = -val;
351
+ const offset = decode >> 8;
352
+ if (len <= offset) {
353
+ return PREDICT_NEED_MORE;
354
+ }
355
+ return (decode & 0xff) + (start + offset < residualLen ? residual[start + offset] : data[start + offset - residualLen]);
308
356
  }
309
- return bc.extra + buffer[start + bc.offset];
310
- }
311
- if (isResponse && fc === FunctionCode.READ_DEVICE_IDENTIFICATION) {
312
- return predictFc43_14Response(buffer, start, end);
313
357
  }
314
- return PREDICT_UNKNOWN;
315
- }
316
- /**
317
- * Walk the variable-length FC 0x2B / MEI 0x0E (Read Device Identification)
318
- * response structure per Modbus V1.1b3 §6.21.
319
- *
320
- * Layout (after unit and fc):
321
- * mei(1) rdic(1) conformity(1) more(1) nextObjId(1) numObjs(1)
322
- * [objId(1) objLen(1) objData(objLen)] × numObjs
323
- * CRC(2)
324
- */
325
- function predictFc43_14Response(buffer, start, end) {
326
- if (end - start < 8) {
327
- return PREDICT_NEED_MORE;
328
- }
329
- if (buffer[start + 2] !== MEI_READ_DEVICE_ID) {
330
- return PREDICT_UNKNOWN;
331
- }
332
- const numObjs = buffer[start + 7];
333
- let cursor = start + 8;
334
- for (let i = 0; i < numObjs; i++) {
335
- if (end < cursor + 2) {
336
- return PREDICT_NEED_MORE;
358
+ else {
359
+ const val = REQ_TABLE[fc];
360
+ if (val > 0) {
361
+ return val;
362
+ }
363
+ if (val < 0) {
364
+ const decode = -val;
365
+ const offset = decode >> 8;
366
+ if (len <= offset) {
367
+ return PREDICT_NEED_MORE;
368
+ }
369
+ return (decode & 0xff) + (start + offset < residualLen ? residual[start + offset] : data[start + offset - residualLen]);
337
370
  }
338
- const objLen = buffer[cursor + 1];
339
- cursor += 2 + objLen;
340
371
  }
341
- return cursor - start + 2;
372
+ return PREDICT_UNKNOWN;
342
373
  }
343
374
 
344
375
  /**
@@ -347,10 +378,12 @@ function predictFc43_14Response(buffer, start, end) {
347
378
  function promisifyCb(fn) {
348
379
  return new Promise((resolve, reject) => {
349
380
  fn((err) => {
350
- if (err)
381
+ if (err) {
351
382
  reject(err);
352
- else
383
+ }
384
+ else {
353
385
  resolve();
386
+ }
354
387
  });
355
388
  });
356
389
  }
@@ -376,7 +409,12 @@ function resolveOne(value, baudRate, fastBaudMs) {
376
409
  if (baudRate === undefined) {
377
410
  return undefined;
378
411
  }
379
- return baudRate > 19200 ? fastBaudMs : Math.ceil(bitsToMs(baudRate, value.value));
412
+ if (baudRate > 19200) {
413
+ return fastBaudMs;
414
+ }
415
+ const ms = bitsToMs(baudRate, value.value);
416
+ const trunc = ms | 0;
417
+ return trunc + (ms > trunc ? 1 : 0);
380
418
  }
381
419
  /**
382
420
  * Resolve Modbus RTU timing parameters from user options into milliseconds.
@@ -393,7 +431,17 @@ function resolveRtuTiming(opts = {}, baudRate) {
393
431
  if (intervalBetweenFrames === undefined) {
394
432
  // Spec default: t3.5 derived from baudRate, or 0 when neither option nor
395
433
  // baudRate were supplied.
396
- intervalBetweenFrames = baudRate !== undefined ? (baudRate > 19200 ? 1.75 : Math.ceil(bitsToMs(baudRate, 38.5))) : 0;
434
+ if (baudRate === undefined) {
435
+ intervalBetweenFrames = 0;
436
+ }
437
+ else if (baudRate > 19200) {
438
+ intervalBetweenFrames = 1.75;
439
+ }
440
+ else {
441
+ const ms = bitsToMs(baudRate, 38.5);
442
+ const trunc = ms | 0;
443
+ intervalBetweenFrames = trunc + (ms > trunc ? 1 : 0);
444
+ }
397
445
  }
398
446
  let interCharTimeout = resolveOne(opts.interCharTimeout, baudRate, 0.75);
399
447
  if (interCharTimeout === undefined) {
@@ -403,102 +451,176 @@ function resolveRtuTiming(opts = {}, baudRate) {
403
451
  return { intervalBetweenFrames, interCharTimeout };
404
452
  }
405
453
 
406
- /** @internal
407
- * Zero-allocation binary min-heap for coalescing per-request timeouts.
454
+ /**
455
+ * Hybrid timer manager: uses native `setTimeout` for low concurrency
456
+ * and switches to a binary min-heap when concurrency exceeds the threshold.
408
457
  *
409
- * Uses two parallel numeric arrays (no object allocation per entry).
410
- * Lazy deletion: callers never remove from the heap; expired entries
411
- * are silently dropped when they surface at the top.
458
+ * Benchmarks (add + clear throughput, Node 24, x64):
459
+ * 1 concurrent: setTimeout ~1.7× faster than heap
460
+ * 2 concurrent: setTimeout ~1.6× faster than heap
461
+ * 5 concurrent: setTimeout ~1.5-1.9× faster than heap
462
+ * 10 concurrent: roughly equal
463
+ * 20 concurrent: heap ~1.3× faster than setTimeout[]
464
+ * 50 concurrent: heap ~1.4-1.7× faster than setTimeout[]
465
+ *
466
+ * The crossover point is around 10 concurrent timers, so the default
467
+ * `concurrentThreshold = 2` keeps the common 1-2 request case on the
468
+ * fast direct path while delegating to the heap for larger batches.
412
469
  */
413
470
  class TimerHeap {
414
471
  _deadlines = [];
415
472
  _ids = [];
473
+ _seqs = [];
474
+ _counter = 0;
416
475
  _timer = null;
417
476
  _onFire;
418
- /** Pre-bound tick handler to avoid creating a new arrow function on every setTimeout. */
419
477
  _boundTick;
420
- constructor(onFire) {
478
+ _threshold;
479
+ _mode = 'direct';
480
+ _directTimers = new Map();
481
+ /**
482
+ * @param onFire Callback invoked with the timer id when it expires.
483
+ * @param concurrentThreshold Maximum number of timers kept as individual
484
+ * native `setTimeout` handles. Once exceeded, all timers migrate to
485
+ * the internal heap and share a single native timer. Default is 2.
486
+ */
487
+ constructor(onFire, concurrentThreshold = 2) {
421
488
  this._onFire = onFire;
422
489
  this._boundTick = this._onTick.bind(this);
490
+ this._threshold = concurrentThreshold;
423
491
  }
424
- /** Number of pending timers in the heap. */
425
492
  get size() {
426
- return this._deadlines.length;
493
+ return this._mode === 'direct' ? this._directTimers.size : this._deadlines.length;
427
494
  }
428
- /** Schedule an entry. If it becomes the new top, the global timer is re-scheduled (pre-emption). */
429
495
  add(id, ms) {
496
+ if (this._mode === 'direct' && this._directTimers.size + 1 <= this._threshold) {
497
+ const deadline = performance.now() + ms;
498
+ const handle = setTimeout(() => {
499
+ if (this._mode !== 'direct') {
500
+ return;
501
+ }
502
+ this._directTimers.delete(id);
503
+ this._onFire(id);
504
+ }, ms);
505
+ this._directTimers.set(id, { handle, deadline });
506
+ return;
507
+ }
508
+ if (this._mode === 'direct') {
509
+ this._mode = 'heap';
510
+ for (const [existingId, { handle, deadline }] of this._directTimers) {
511
+ clearTimeout(handle);
512
+ const diff = deadline - performance.now();
513
+ const trunc = diff | 0;
514
+ const remaining = diff > 0 ? trunc + (diff > trunc ? 1 : 0) : 0;
515
+ if (remaining === 0) {
516
+ this._onFire(existingId);
517
+ }
518
+ else {
519
+ this._heapAdd(existingId, remaining);
520
+ }
521
+ }
522
+ this._directTimers.clear();
523
+ }
524
+ this._heapAdd(id, ms);
525
+ }
526
+ clear() {
527
+ for (const { handle } of this._directTimers.values()) {
528
+ clearTimeout(handle);
529
+ }
530
+ this._directTimers.clear();
531
+ this._mode = 'direct';
532
+ if (this._timer) {
533
+ clearTimeout(this._timer);
534
+ this._timer = null;
535
+ }
536
+ this._deadlines.length = 0;
537
+ this._ids.length = 0;
538
+ this._seqs.length = 0;
539
+ this._counter = 0;
540
+ }
541
+ _heapAdd(id, ms) {
430
542
  const deadline = performance.now() + ms;
543
+ const seq = this._counter++;
431
544
  let i = this._deadlines.length;
432
545
  this._deadlines.push(deadline);
433
546
  this._ids.push(id);
434
- // sift up
547
+ this._seqs.push(seq);
435
548
  while (i > 0) {
436
549
  const p = (i - 1) >> 1;
437
- if (this._deadlines[p] <= deadline)
550
+ const parentComesFirst = this._deadlines[p] < deadline || (this._deadlines[p] === deadline && this._seqs[p] < seq);
551
+ if (parentComesFirst) {
438
552
  break;
553
+ }
439
554
  this._deadlines[i] = this._deadlines[p];
440
555
  this._ids[i] = this._ids[p];
556
+ this._seqs[i] = this._seqs[p];
441
557
  i = p;
442
558
  }
443
559
  this._deadlines[i] = deadline;
444
560
  this._ids[i] = id;
445
- // Only reschedule when the new entry became the heap top.
446
- if (i === 0)
561
+ this._seqs[i] = seq;
562
+ if (i === 0) {
447
563
  this._refresh();
448
- }
449
- /** Dispose without firing callbacks. */
450
- clear() {
451
- if (this._timer) {
452
- clearTimeout(this._timer);
453
- this._timer = null;
454
564
  }
455
- this._deadlines.length = 0;
456
- this._ids.length = 0;
457
565
  }
458
566
  _refresh() {
459
567
  if (this._timer) {
460
568
  clearTimeout(this._timer);
461
569
  this._timer = null;
462
570
  }
463
- if (this._deadlines.length === 0)
571
+ if (this._deadlines.length === 0) {
464
572
  return;
465
- const delay = Math.max(0, Math.ceil(this._deadlines[0] - performance.now()));
466
- this._timer = setTimeout(this._boundTick, delay);
573
+ }
574
+ const diff = this._deadlines[0] - performance.now();
575
+ const trunc = diff | 0;
576
+ const delay = diff > 0 ? trunc + (diff > trunc ? 1 : 0) : 0;
577
+ const safeDelay = delay < 2147483647 ? delay : 2147483647;
578
+ this._timer = setTimeout(this._boundTick, safeDelay);
467
579
  }
468
580
  _onTick() {
469
581
  this._timer = null;
470
582
  const now = performance.now();
471
- while (this._deadlines.length > 0 && this._deadlines[0] <= now) {
472
- const id = this._pop();
473
- this._onFire(id);
583
+ try {
584
+ while (this._deadlines.length > 0 && this._deadlines[0] <= now) {
585
+ const id = this._pop();
586
+ this._onFire(id);
587
+ }
588
+ }
589
+ finally {
590
+ this._refresh();
474
591
  }
475
- this._refresh();
476
592
  }
477
- /** Pop and return the id with the earliest deadline. Assumes heap is non-empty. */
478
593
  _pop() {
479
594
  const topId = this._ids[0];
480
595
  const lastId = this._ids.pop();
481
596
  const lastDeadline = this._deadlines.pop();
597
+ const lastSeq = this._seqs.pop();
482
598
  const n = this._deadlines.length;
483
599
  if (n > 0) {
484
600
  let i = 0;
485
- // sift down
486
- while (true) {
487
- let min = i;
488
- const l = i * 2 + 1;
489
- const r = l + 1;
490
- if (l < n && this._deadlines[l] < this._deadlines[min])
491
- min = l;
492
- if (r < n && this._deadlines[r] < this._deadlines[min])
493
- min = r;
494
- if (min === i)
601
+ const half = n >> 1;
602
+ while (i < half) {
603
+ let minChild = (i << 1) + 1;
604
+ const rightChild = minChild + 1;
605
+ if (rightChild < n) {
606
+ const rightComesFirst = this._deadlines[rightChild] < this._deadlines[minChild] ||
607
+ (this._deadlines[rightChild] === this._deadlines[minChild] && this._seqs[rightChild] < this._seqs[minChild]);
608
+ if (rightComesFirst) {
609
+ minChild = rightChild;
610
+ }
611
+ }
612
+ const lastComesFirst = lastDeadline < this._deadlines[minChild] || (lastDeadline === this._deadlines[minChild] && lastSeq < this._seqs[minChild]);
613
+ if (lastComesFirst) {
495
614
  break;
496
- this._deadlines[i] = this._deadlines[min];
497
- this._ids[i] = this._ids[min];
498
- i = min;
615
+ }
616
+ this._deadlines[i] = this._deadlines[minChild];
617
+ this._ids[i] = this._ids[minChild];
618
+ this._seqs[i] = this._seqs[minChild];
619
+ i = minChild;
499
620
  }
500
621
  this._deadlines[i] = lastDeadline;
501
622
  this._ids[i] = lastId;
623
+ this._seqs[i] = lastSeq;
502
624
  }
503
625
  return topId;
504
626
  }
@@ -536,6 +658,9 @@ class SerialPhysicalConnection extends AbstractPhysicalConnection {
536
658
  get physicalLayer() {
537
659
  return this._physicalLayer;
538
660
  }
661
+ get serialport() {
662
+ return this._serialport;
663
+ }
539
664
  constructor(physicalLayer, serialport) {
540
665
  super();
541
666
  this._physicalLayer = physicalLayer;
@@ -544,6 +669,7 @@ class SerialPhysicalConnection extends AbstractPhysicalConnection {
544
669
  if (this.state !== PhysicalConnectionState.CONNECTED) {
545
670
  return;
546
671
  }
672
+ this.emit('rx', chunk);
547
673
  this.emit('data', chunk);
548
674
  };
549
675
  serialport.on('data', onData);
@@ -572,7 +698,12 @@ class SerialPhysicalConnection extends AbstractPhysicalConnection {
572
698
  }
573
699
  write(data, cb) {
574
700
  if (this.state === PhysicalConnectionState.CONNECTED) {
575
- this._serialport.write(data, cb);
701
+ this._serialport.write(data, (err) => {
702
+ if (!err) {
703
+ this.emit('tx', data);
704
+ }
705
+ cb?.(err);
706
+ });
576
707
  }
577
708
  else {
578
709
  cb?.(new Error('Connection is not connected'));
@@ -733,6 +864,9 @@ class TcpPhysicalConnection extends AbstractPhysicalConnection {
733
864
  get physicalLayer() {
734
865
  return this._physicalLayer;
735
866
  }
867
+ get socket() {
868
+ return this._socket;
869
+ }
736
870
  constructor(physicalLayer, socket) {
737
871
  super();
738
872
  this._physicalLayer = physicalLayer;
@@ -741,6 +875,7 @@ class TcpPhysicalConnection extends AbstractPhysicalConnection {
741
875
  if (this.state !== PhysicalConnectionState.CONNECTED) {
742
876
  return;
743
877
  }
878
+ this.emit('rx', chunk);
744
879
  this.emit('data', chunk);
745
880
  };
746
881
  socket.on('data', onData);
@@ -769,7 +904,12 @@ class TcpPhysicalConnection extends AbstractPhysicalConnection {
769
904
  }
770
905
  write(data, cb) {
771
906
  if (this.state === PhysicalConnectionState.CONNECTED) {
772
- this._socket.write(data, cb);
907
+ this._socket.write(data, (err) => {
908
+ if (!err) {
909
+ this.emit('tx', data);
910
+ }
911
+ cb?.(err);
912
+ });
773
913
  }
774
914
  else {
775
915
  cb?.(new Error('Connection is not connected'));
@@ -1051,6 +1191,9 @@ class UdpClientPhysicalConnection extends AbstractPhysicalConnection {
1051
1191
  get physicalLayer() {
1052
1192
  return this._physicalLayer;
1053
1193
  }
1194
+ get socket() {
1195
+ return this._socket;
1196
+ }
1054
1197
  constructor(physicalLayer, socket) {
1055
1198
  super();
1056
1199
  this._physicalLayer = physicalLayer;
@@ -1059,6 +1202,7 @@ class UdpClientPhysicalConnection extends AbstractPhysicalConnection {
1059
1202
  if (this.state !== PhysicalConnectionState.CONNECTED) {
1060
1203
  return;
1061
1204
  }
1205
+ this.emit('rx', msg);
1062
1206
  this.emit('data', msg);
1063
1207
  };
1064
1208
  socket.on('message', onMessage);
@@ -1087,7 +1231,12 @@ class UdpClientPhysicalConnection extends AbstractPhysicalConnection {
1087
1231
  }
1088
1232
  write(data, cb) {
1089
1233
  if (this.state === PhysicalConnectionState.CONNECTED) {
1090
- this._socket.send(data, cb);
1234
+ this._socket.send(data, (err) => {
1235
+ if (!err) {
1236
+ this.emit('tx', data);
1237
+ }
1238
+ cb?.(err);
1239
+ });
1091
1240
  }
1092
1241
  else {
1093
1242
  cb?.(new Error('Connection is not connected'));
@@ -1227,6 +1376,12 @@ class UdpServerPhysicalConnection extends AbstractPhysicalConnection {
1227
1376
  get physicalLayer() {
1228
1377
  return this._physicalLayer;
1229
1378
  }
1379
+ get socket() {
1380
+ return this._socket;
1381
+ }
1382
+ get remote() {
1383
+ return this._remote;
1384
+ }
1230
1385
  constructor(physicalLayer, socket, remote, idleTimeout, messageEventDelegation) {
1231
1386
  super();
1232
1387
  this._physicalLayer = physicalLayer;
@@ -1245,6 +1400,7 @@ class UdpServerPhysicalConnection extends AbstractPhysicalConnection {
1245
1400
  }, idleTimeout);
1246
1401
  }
1247
1402
  }
1403
+ this.emit('rx', msg);
1248
1404
  this.emit('data', msg);
1249
1405
  };
1250
1406
  messageEventDelegation.add(onMessage);
@@ -1257,7 +1413,12 @@ class UdpServerPhysicalConnection extends AbstractPhysicalConnection {
1257
1413
  }
1258
1414
  write(data, cb) {
1259
1415
  if (this.state === PhysicalConnectionState.CONNECTED) {
1260
- this._socket.send(data, this._remote.port, this._remote.address, cb);
1416
+ this._socket.send(data, this._remote.port, this._remote.address, (err) => {
1417
+ if (!err) {
1418
+ this.emit('tx', data);
1419
+ }
1420
+ cb?.(err);
1421
+ });
1261
1422
  }
1262
1423
  else {
1263
1424
  cb?.(new Error('Connection is not connected'));
@@ -1460,16 +1621,23 @@ class AbstractApplicationLayer {
1460
1621
  removeCustomFunctionCode(fc) { }
1461
1622
  }
1462
1623
 
1463
- const MAX_FRAME_LENGTH = 256;
1624
+ const MAX_FRAME_LENGTH$1 = 256;
1464
1625
  const MIN_FRAME_LENGTH = 4;
1465
1626
  class RtuApplicationLayer extends AbstractApplicationLayer {
1466
1627
  PROTOCOL = 'RTU';
1467
1628
  ROLE;
1468
1629
  _connection;
1469
- _state;
1470
- _poolSize;
1471
- _threePointFiveT;
1472
- _onePointFiveT;
1630
+ // Stores leftover data between parse rounds
1631
+ _residual = Buffer.alloc(MAX_FRAME_LENGTH$1);
1632
+ _residualLen = 0;
1633
+ _expectedLen = PREDICT_NEED_MORE;
1634
+ _t15Time;
1635
+ _t35Time;
1636
+ _t15Strict;
1637
+ _t15Timer;
1638
+ _t35Timer;
1639
+ // t1.5 cursor: 0 = not triggered; > 0 = virtual index where the gap occurred
1640
+ _t15Marker = 0;
1473
1641
  _customFunctionCodes = new Map();
1474
1642
  _cleanupCbs = [];
1475
1643
  get connection() {
@@ -1479,89 +1647,246 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1479
1647
  super();
1480
1648
  this.ROLE = role;
1481
1649
  this._connection = connection;
1482
- const { intervalBetweenFrames, interCharTimeout, poolSize } = options;
1483
- this._poolSize = poolSize ?? MAX_FRAME_LENGTH * 2;
1484
- this._state = { pool: Buffer.alloc(this._poolSize), start: 0, end: 0 };
1485
- this._threePointFiveT = intervalBetweenFrames ?? 0;
1486
- this._onePointFiveT = interCharTimeout ?? 0;
1650
+ const { intervalBetweenFrames, interCharTimeout, strictTiming } = options;
1651
+ this._t35Time = intervalBetweenFrames ?? 0;
1652
+ this._t15Time = this._t35Time === 0 ? 0 : (interCharTimeout ?? 0);
1653
+ if (this._t35Time < this._t15Time) {
1654
+ throw new Error('t3.5 cannot be less than t1.5');
1655
+ }
1656
+ this._t15Strict = strictTiming ?? false;
1657
+ const isResponse = role === 'MASTER';
1658
+ const timingEnabled = this._t35Time > 0;
1487
1659
  const onData = (data) => {
1488
- const state = this._state;
1489
- if (state.t15Expired && state.end > state.start) {
1490
- this.onFramingError(new Error('Inter-character timeout (t1.5) exceeded'));
1491
- state.start = 0;
1492
- state.end = 0;
1493
- }
1494
- state.t15Expired = false;
1495
1660
  const dataLen = data.length;
1496
- const space = state.pool.length - state.end;
1497
- if (dataLen <= space) {
1498
- // Fast path: incoming chunk fits the current pool tail; one bulk copy,
1499
- // no per-iteration bookkeeping. This is the typical case.
1500
- data.copy(state.pool, state.end);
1501
- state.end += dataLen;
1661
+ const residualLen = this._residualLen;
1662
+ const totalAvailable = residualLen + dataLen;
1663
+ // ========================================================================
1664
+ // 1. Timing reset: any bus activity unconditionally kills all silence timers
1665
+ // ========================================================================
1666
+ if (timingEnabled) {
1667
+ if (this._t15Timer !== undefined) {
1668
+ clearTimeout(this._t15Timer);
1669
+ this._t15Timer = undefined;
1670
+ }
1671
+ if (this._t35Timer !== undefined) {
1672
+ clearTimeout(this._t35Timer);
1673
+ this._t35Timer = undefined;
1674
+ }
1502
1675
  }
1503
- else {
1504
- // Slow path: incoming chunk does NOT fit the pool's remaining tail
1505
- // space (not necessarily larger than the whole pool — typically the
1506
- // pool already holds undrained residue from earlier ticks).
1507
- // Strategy drain & absorb in chunks:
1508
- // 1. Copy as much of `data` as fits the current tail.
1509
- // 2. When the pool fills mid-chunk, run flushBuffer(false) to emit
1510
- // any complete frames and collapse the leftover bytes to offset
1511
- // 0, which reclaims tail space.
1512
- // 3. Repeat until `data` is fully ingested.
1513
- // The mid-chunk flush is NEVER strict — t3.5 has not actually elapsed,
1514
- // the rest of `data` is still in hand. Only the t3.5 timer callback
1515
- // below passes strict=true.
1516
- let dataOffset = 0;
1517
- let currentState = state;
1518
- while (dataOffset < dataLen) {
1519
- const room = currentState.pool.length - currentState.end;
1520
- if (room === 0) {
1521
- // Pool full mid-ingest. Try to extract any complete frames and
1522
- // reclaim tail space so the rest of `data` has somewhere to go.
1523
- this.clearStateTimers();
1524
- this.flushBuffer(false);
1525
- // flushBuffer may emit; user callbacks could in principle replace
1526
- // _state. Re-read to stay correct across that boundary.
1527
- currentState = this._state;
1528
- if (currentState.pool.length - currentState.end === 0) {
1529
- // flushBuffer freed nothing — the entire pool is unparseable
1530
- // residue (typically a misconfigured poolSize for the wire's
1531
- // frame size). Hard reset; we cannot recover automatically.
1532
- this.onFramingError(new Error('Frame buffer exhausted before complete frame received'));
1533
- currentState.start = 0;
1534
- currentState.end = 0;
1535
- currentState.t15Expired = false;
1676
+ // ========================================================================
1677
+ // 2. Fast path: no residual data and the new chunk is exactly one frame
1678
+ // ========================================================================
1679
+ if (residualLen === 0 && dataLen >= MIN_FRAME_LENGTH) {
1680
+ const fc = data[1];
1681
+ let frameLen = PREDICT_NEED_MORE;
1682
+ const cfc = this._customFunctionCodes.size > 0 ? this._customFunctionCodes.get(fc) : undefined;
1683
+ if (cfc) {
1684
+ const predictor = isResponse ? cfc.predictResponseLength : cfc.predictRequestLength;
1685
+ frameLen = predictor((idx) => data[idx], dataLen);
1686
+ }
1687
+ else if (isResponse) {
1688
+ if ((fc & 0x80) !== 0) {
1689
+ frameLen = 5;
1690
+ }
1691
+ else {
1692
+ const val = RES_TABLE[fc];
1693
+ if (val > 0) {
1694
+ frameLen = val;
1695
+ }
1696
+ else if (val < 0 && val !== -999) {
1697
+ const decode = -val;
1698
+ const offset = decode >> 8;
1699
+ if (dataLen > offset) {
1700
+ frameLen = (decode & 0xff) + data[offset];
1701
+ }
1536
1702
  }
1537
- continue;
1538
1703
  }
1539
- // Copy the next chunk into the freshly-available tail space.
1540
- const toCopy = room < dataLen - dataOffset ? room : dataLen - dataOffset;
1541
- data.copy(currentState.pool, currentState.end, dataOffset, dataOffset + toCopy);
1542
- currentState.end += toCopy;
1543
- dataOffset += toCopy;
1704
+ }
1705
+ else {
1706
+ const val = REQ_TABLE[fc];
1707
+ if (val > 0) {
1708
+ frameLen = val;
1709
+ }
1710
+ else if (val < 0) {
1711
+ const decode = -val;
1712
+ const offset = decode >> 8;
1713
+ if (dataLen > offset) {
1714
+ frameLen = (decode & 0xff) + data[offset];
1715
+ }
1716
+ }
1717
+ }
1718
+ if (frameLen === dataLen) {
1719
+ const expectedCrc = data[frameLen - 2] | (data[frameLen - 1] << 8);
1720
+ // Inline CRC for the hot single-buffer path — local table reference helps V8 IC.
1721
+ const table = CRC_TABLE;
1722
+ let crc = 0xffff;
1723
+ const crcEnd = frameLen - 2;
1724
+ for (let i = 0; i < crcEnd; i++) {
1725
+ crc = table[(crc ^ data[i]) & 0xff] ^ (crc >> 8);
1726
+ }
1727
+ if (expectedCrc === crc) {
1728
+ const dropFrame = timingEnabled && this._t15Strict && this._t15Marker > 0;
1729
+ if (!dropFrame) {
1730
+ const frame = {
1731
+ unit: data[0],
1732
+ fc: data[1],
1733
+ data: data.subarray(2, frameLen - 2),
1734
+ buffer: data,
1735
+ };
1736
+ this.onFraming(frame);
1737
+ }
1738
+ this._expectedLen = PREDICT_NEED_MORE;
1739
+ this._t15Marker = 0;
1740
+ return;
1741
+ }
1544
1742
  }
1545
1743
  }
1546
- this.clearStateTimers();
1547
- const liveState = this._state;
1548
- if (liveState.end - liveState.start >= MAX_FRAME_LENGTH) {
1549
- this.flushBuffer(this._threePointFiveT > 0);
1550
- }
1551
- else if (this._threePointFiveT) {
1552
- if (this._onePointFiveT > 0) {
1553
- liveState.interCharTimer = setTimeout(() => {
1554
- liveState.interCharTimer = undefined;
1555
- liveState.t15Expired = true;
1556
- }, this._onePointFiveT);
1744
+ // ========================================================================
1745
+ // 3. Extract frames in a loop until no complete frame remains (skip if data length below prediction)
1746
+ // ========================================================================
1747
+ let index = 0;
1748
+ if (!(this._expectedLen > 0 && totalAvailable < this._expectedLen)) {
1749
+ while (index <= totalAvailable - MIN_FRAME_LENGTH) {
1750
+ const fc = index + 1 < residualLen ? this._residual[index + 1] : data[index + 1 - residualLen];
1751
+ const cfc = this._customFunctionCodes.get(fc);
1752
+ let frameLen = PREDICT_NEED_MORE;
1753
+ if (cfc) {
1754
+ const predictor = isResponse ? cfc.predictResponseLength : cfc.predictRequestLength;
1755
+ frameLen = predictor((idx) => {
1756
+ const pos = index + idx;
1757
+ return pos < residualLen ? this._residual[pos] : data[pos - residualLen];
1758
+ }, totalAvailable - index);
1759
+ }
1760
+ else {
1761
+ frameLen = predictRtuFrameLength(this._residual, data, residualLen, index, totalAvailable, isResponse);
1762
+ }
1763
+ if (frameLen === PREDICT_UNKNOWN) {
1764
+ index++;
1765
+ continue;
1766
+ }
1767
+ if (frameLen === PREDICT_NEED_MORE) {
1768
+ break;
1769
+ }
1770
+ if (frameLen > MAX_FRAME_LENGTH$1 || frameLen < MIN_FRAME_LENGTH) {
1771
+ index++;
1772
+ continue;
1773
+ }
1774
+ if (totalAvailable - index < frameLen) {
1775
+ this._expectedLen = index + frameLen;
1776
+ break;
1777
+ }
1778
+ const expectedCrc = (index + frameLen - 2 < residualLen ? this._residual[index + frameLen - 2] : data[index + frameLen - 2 - residualLen]) |
1779
+ ((index + frameLen - 1 < residualLen ? this._residual[index + frameLen - 1] : data[index + frameLen - 1 - residualLen]) << 8);
1780
+ const crcEnd = index + frameLen - 2;
1781
+ let actualCrc;
1782
+ if (crcEnd <= residualLen) {
1783
+ // Entire CRC range sits in the old residual buffer
1784
+ actualCrc = crcFixed(this._residual, index, crcEnd);
1785
+ }
1786
+ else if (index >= residualLen) {
1787
+ // Entire CRC range sits in the new data chunk
1788
+ actualCrc = crcFixed(data, index - residualLen, crcEnd - residualLen);
1789
+ }
1790
+ else {
1791
+ // CRC range spans both buffers
1792
+ actualCrc = crcDual(this._residual, index, residualLen - index, data, 0, crcEnd - residualLen);
1793
+ }
1794
+ if (expectedCrc !== actualCrc) {
1795
+ index++;
1796
+ continue;
1797
+ }
1798
+ // A complete frame has been received; CRC verification still required
1799
+ this._expectedLen = PREDICT_NEED_MORE;
1800
+ const dropFrame = this._t15Strict && this._t15Marker > index && this._t15Marker < index + frameLen;
1801
+ if (!dropFrame) {
1802
+ // Build contiguous raw buffer — one alloc, zero-copy subarray for data.
1803
+ const raw = Buffer.allocUnsafe(frameLen);
1804
+ if (index + frameLen <= residualLen) {
1805
+ this._residual.copy(raw, 0, index, index + frameLen);
1806
+ }
1807
+ else if (index >= residualLen) {
1808
+ data.copy(raw, 0, index - residualLen, index - residualLen + frameLen);
1809
+ }
1810
+ else {
1811
+ const headLen = residualLen - index;
1812
+ this._residual.copy(raw, 0, index, residualLen);
1813
+ data.copy(raw, headLen, 0, frameLen - headLen);
1814
+ }
1815
+ const frame = {
1816
+ unit: raw[0],
1817
+ fc: raw[1],
1818
+ data: raw.subarray(2, frameLen - 2),
1819
+ buffer: raw,
1820
+ };
1821
+ this.onFraming(frame);
1822
+ }
1823
+ index += frameLen;
1557
1824
  }
1558
- liveState.timer = setTimeout(() => {
1559
- this.clearStateTimers();
1560
- this.flushBuffer(true);
1561
- }, this._threePointFiveT);
1825
+ }
1826
+ // ========================================================================
1827
+ // 4. Compact residual buffer and rebuild silence timers
1828
+ // ========================================================================
1829
+ const newFrameStart = index;
1830
+ const finalRestLen = totalAvailable - newFrameStart;
1831
+ if (finalRestLen === 0) {
1832
+ this._residualLen = 0;
1833
+ this._expectedLen = PREDICT_NEED_MORE;
1834
+ this._t15Marker = 0;
1562
1835
  }
1563
1836
  else {
1564
- this.flushBuffer(false);
1837
+ const keepLen = finalRestLen < MAX_FRAME_LENGTH$1 ? finalRestLen : MAX_FRAME_LENGTH$1;
1838
+ const discardLen = totalAvailable - keepLen;
1839
+ if (discardLen >= residualLen) {
1840
+ // Kept portion lies entirely within the new `data`
1841
+ data.copy(this._residual, 0, discardLen - residualLen, dataLen);
1842
+ }
1843
+ else if (discardLen > 0) {
1844
+ // Kept portion spans both buffers, or physical left-shift truncation occurred
1845
+ for (let i = 0; i < keepLen; i++) {
1846
+ this._residual[i] = discardLen + i < residualLen ? this._residual[discardLen + i] : data[discardLen + i - residualLen];
1847
+ }
1848
+ }
1849
+ else {
1850
+ // discardLen === 0 (old data not consumed, truncation limit not hit) — simple append
1851
+ data.copy(this._residual, residualLen, 0, dataLen);
1852
+ }
1853
+ this._residualLen = keepLen;
1854
+ // Unify physical coordinate system translation
1855
+ if (discardLen > 0) {
1856
+ if (this._expectedLen > 0) {
1857
+ const newExpectedLen = this._expectedLen - discardLen;
1858
+ this._expectedLen = newExpectedLen > PREDICT_NEED_MORE ? newExpectedLen : PREDICT_NEED_MORE;
1859
+ }
1860
+ if (this._t15Marker > 0) {
1861
+ const newT15Marker = this._t15Marker - discardLen;
1862
+ this._t15Marker = newT15Marker > 0 ? newT15Marker : 0;
1863
+ }
1864
+ }
1865
+ if (timingEnabled) {
1866
+ let hasErrorEmitted = false;
1867
+ // Establish t3.5 absolute deadline
1868
+ this._t35Timer = setTimeout(() => {
1869
+ this._t35Timer = undefined;
1870
+ // No complete frame parsed within t3.5: circuit-break, discard all data
1871
+ this._residualLen = 0;
1872
+ this._expectedLen = PREDICT_NEED_MORE;
1873
+ this._t15Marker = 0;
1874
+ if (!hasErrorEmitted) {
1875
+ this.onFramingError(new Error('Incomplete frame at t3.5'));
1876
+ }
1877
+ }, this._t35Time);
1878
+ if (this._t15Time > 0) {
1879
+ // Establish t1.5 inter-character gap monitor
1880
+ this._t15Timer = setTimeout(() => {
1881
+ this._t15Timer = undefined;
1882
+ this._t15Marker = this._residualLen; // Record the residual boundary where the gap occurred
1883
+ if (this._t15Strict) {
1884
+ hasErrorEmitted = true;
1885
+ this.onFramingError(new Error('Inter-character timeout (t1.5) exceeded'));
1886
+ }
1887
+ }, this._t15Time);
1888
+ }
1889
+ }
1565
1890
  }
1566
1891
  };
1567
1892
  connection.on('data', onData);
@@ -1571,147 +1896,33 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1571
1896
  fn();
1572
1897
  }
1573
1898
  this._cleanupCbs.length = 0;
1574
- this.clearStateTimers();
1899
+ if (this._t15Timer !== undefined) {
1900
+ clearTimeout(this._t15Timer);
1901
+ this._t15Timer = undefined;
1902
+ }
1903
+ if (this._t35Timer !== undefined) {
1904
+ clearTimeout(this._t35Timer);
1905
+ this._t35Timer = undefined;
1906
+ }
1575
1907
  };
1576
1908
  connection.on('close', onClose);
1577
1909
  this._cleanupCbs.push(() => connection.off('close', onClose));
1578
1910
  }
1579
- clearStateTimers() {
1580
- const state = this._state;
1581
- if (state.timer) {
1582
- clearTimeout(state.timer);
1583
- state.timer = undefined;
1584
- }
1585
- if (state.interCharTimer) {
1586
- clearTimeout(state.interCharTimer);
1587
- state.interCharTimer = undefined;
1588
- }
1589
- }
1590
- /**
1591
- * Shared handler for every "frame is not yet complete" exit in `flushBuffer`.
1592
- * Returns `true` when the caller should `return` (strict reset), `false` to
1593
- * `break` the parse loop. Hot path never reaches here — only error/incomplete
1594
- * edges. Extracted as a method so it is not recreated on every `flushBuffer`
1595
- * call.
1596
- */
1597
- _handleIncomplete(state, strict) {
1598
- if (strict) {
1599
- this.onFramingError(new Error(state.t15Expired ? 'Inter-character timeout (t1.5) exceeded' : 'Incomplete frame at t3.5'));
1600
- state.start = 0;
1601
- state.end = 0;
1602
- state.t15Expired = false;
1603
- return true;
1604
- }
1605
- if (state.t15Expired) {
1606
- this.onFramingError(new Error('Inter-character timeout (t1.5) exceeded'));
1607
- state.start = 0;
1608
- state.end = 0;
1609
- state.t15Expired = false;
1610
- }
1611
- return false;
1612
- }
1613
- flushBuffer(strict) {
1614
- const state = this._state;
1615
- const isResponse = this.ROLE === 'MASTER';
1616
- const pool = state.pool;
1617
- const customFCs = this._customFunctionCodes;
1618
- while (state.end - state.start > 0) {
1619
- const available = state.end - state.start;
1620
- if (available < MIN_FRAME_LENGTH) {
1621
- if (this._handleIncomplete(state, strict))
1622
- return;
1623
- break;
1624
- }
1625
- const fc = pool[state.start + 1];
1626
- const cfc = customFCs.get(fc);
1627
- let expected;
1628
- if (cfc) {
1629
- const predictor = isResponse ? cfc.predictResponseLength : cfc.predictRequestLength;
1630
- const predicted = predictor(pool, state.start, state.end);
1631
- // Normalize custom predictor's `null` to the std sentinel so both
1632
- // paths share the same NEED_MORE tail below.
1633
- expected = predicted ?? PREDICT_NEED_MORE;
1634
- }
1635
- else {
1636
- // Standard FC path. predictRtuFrameLength uses sentinel returns to
1637
- // avoid per-call object allocation on the decode hot path.
1638
- expected = predictRtuFrameLength(pool, state.start, state.end, isResponse);
1639
- if (expected === PREDICT_UNKNOWN) {
1640
- this.onFramingError(new Error(`Unknown function code 0x${fc.toString(16).padStart(2, '0')} — register a CustomFunctionCode to frame this FC`));
1641
- state.start = 0;
1642
- state.end = 0;
1643
- return;
1644
- }
1645
- }
1646
- if (expected === PREDICT_NEED_MORE) {
1647
- if (available >= MAX_FRAME_LENGTH) {
1648
- state.start += 1;
1649
- continue;
1650
- }
1651
- if (this._handleIncomplete(state, strict))
1652
- return;
1653
- break;
1654
- }
1655
- if (expected > MAX_FRAME_LENGTH || expected < MIN_FRAME_LENGTH) {
1656
- this.onFramingError(new Error('Invalid data'));
1657
- state.start = 0;
1658
- state.end = 0;
1659
- return;
1660
- }
1661
- if (available < expected) {
1662
- if (available >= MAX_FRAME_LENGTH) {
1663
- state.start += 1;
1664
- continue;
1665
- }
1666
- if (this._handleIncomplete(state, strict))
1667
- return;
1668
- break;
1669
- }
1670
- // CRC check inline: no helper call, no subarray for the CRC body.
1671
- const crcStart = state.start;
1672
- const crcEnd = crcStart + expected - 2;
1673
- const expectedCrc = pool.readUInt16LE(crcEnd);
1674
- const actualCrc = crc(pool, crcStart, crcEnd);
1675
- if (expectedCrc !== actualCrc) {
1676
- if (strict) {
1677
- this.onFramingError(new Error('CRC mismatch'));
1678
- state.start = 0;
1679
- state.end = 0;
1680
- state.t15Expired = false;
1681
- return;
1682
- }
1683
- state.start += 1;
1684
- continue;
1685
- }
1686
- // Frame located. Copy it out of the pool so the emitted buffer remains
1687
- // valid even if the consumer queues the frame across `onData` ticks.
1688
- // `Buffer.copyBytesFrom` is a native fast-path (Node 18.19+) — measurably
1689
- // faster than `Buffer.from(buffer)` for this size.
1690
- const frameBuf = Buffer.copyBytesFrom(pool, crcStart, expected);
1691
- state.start += expected;
1692
- const frame = {
1693
- unit: frameBuf[0],
1694
- fc: frameBuf[1],
1695
- data: frameBuf.subarray(2, expected - 2),
1696
- buffer: frameBuf,
1697
- };
1698
- this.onFraming(frame);
1911
+ flush() {
1912
+ this._residualLen = 0;
1913
+ this._expectedLen = PREDICT_NEED_MORE;
1914
+ this._t15Marker = 0;
1915
+ if (this._t15Timer !== undefined) {
1916
+ clearTimeout(this._t15Timer);
1917
+ this._t15Timer = undefined;
1699
1918
  }
1700
- if (state.start > 0) {
1701
- if (state.start < state.end) {
1702
- state.pool.copy(state.pool, 0, state.start, state.end);
1703
- }
1704
- state.end -= state.start;
1705
- state.start = 0;
1919
+ if (this._t35Timer !== undefined) {
1920
+ clearTimeout(this._t35Timer);
1921
+ this._t35Timer = undefined;
1706
1922
  }
1707
1923
  }
1708
- flush() {
1709
- this.clearStateTimers();
1710
- this._state.start = 0;
1711
- this._state.end = 0;
1712
- }
1713
1924
  addCustomFunctionCode(cfc) {
1714
- if (!isUint8(cfc.fc)) {
1925
+ if ((cfc.fc & 0xff) !== cfc.fc) {
1715
1926
  throw new Error(`fc must be an integer in 0..255, got ${cfc.fc}`);
1716
1927
  }
1717
1928
  this._customFunctionCodes.set(cfc.fc, cfc);
@@ -1726,14 +1937,15 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1726
1937
  buffer[0] = unit;
1727
1938
  buffer[1] = fc;
1728
1939
  if (data.length <= 16) {
1729
- for (let i = 0; i < data.length; i++)
1940
+ for (let i = 0; i < data.length; i++) {
1730
1941
  buffer[2 + i] = data[i];
1942
+ }
1731
1943
  }
1732
1944
  else {
1733
1945
  buffer.set(data, 2);
1734
1946
  }
1735
1947
  const crcEnd = buffer.length - 2;
1736
- const c = crc(buffer, 0, crcEnd);
1948
+ const c = crcFixed(buffer, 0, crcEnd);
1737
1949
  // Little-endian inline write of CRC trailer.
1738
1950
  buffer[crcEnd] = c & 0xff;
1739
1951
  buffer[crcEnd + 1] = (c >>> 8) & 0xff;
@@ -1746,9 +1958,8 @@ const CHAR_CODE = {
1746
1958
  CR: '\r'.charCodeAt(0),
1747
1959
  LF: '\n'.charCodeAt(0),
1748
1960
  };
1749
- // Modbus ASCII frame encodes at most 256 PDU bytes as 512 hex chars between
1750
- // `:` and `\r`. Cap per-connection buffering so a peer that never sends `\r`
1751
- // cannot grow `state.frame` without bound.
1961
+ // Modbus ASCII frame body is capped well below the theoretical maximum so a
1962
+ // peer that never sends `\r` cannot grow `state.frame` without bound.
1752
1963
  const MAX_ASCII_PAYLOAD = 512;
1753
1964
  const HEX_DECODE = new Uint8Array(256);
1754
1965
  HEX_DECODE.fill(0xff);
@@ -1761,11 +1972,16 @@ for (let i = 0x41; i <= 0x46; i++) {
1761
1972
  for (let i = 0x61; i <= 0x66; i++) {
1762
1973
  HEX_DECODE[i] = i - 0x61 + 10;
1763
1974
  }
1975
+ // Strict variant: lowercase hex digits (a-f) are treated as invalid so the
1976
+ // hot-path validation loop needs only one table lookup instead of two checks.
1977
+ const HEX_DECODE_STRICT = new Uint8Array(HEX_DECODE);
1978
+ for (let i = 0x61; i <= 0x66; i++) {
1979
+ HEX_DECODE_STRICT[i] = 0xff;
1980
+ }
1764
1981
  const HEX_ENCODE = new Uint8Array('0123456789ABCDEF'.split('').map((c) => c.charCodeAt(0)));
1765
1982
  class AsciiApplicationLayer extends AbstractApplicationLayer {
1766
1983
  PROTOCOL = 'ASCII';
1767
1984
  ROLE;
1768
- lenientHex;
1769
1985
  _connection;
1770
1986
  _state = { status: 'idle', frame: new Uint8Array(MAX_ASCII_PAYLOAD), frameLen: 0 };
1771
1987
  _cleanupCbs = [];
@@ -1776,23 +1992,30 @@ class AsciiApplicationLayer extends AbstractApplicationLayer {
1776
1992
  super();
1777
1993
  this.ROLE = role;
1778
1994
  this._connection = connection;
1779
- this.lenientHex = options.lenientHex ?? false;
1780
- const lenientHex = this.lenientHex;
1781
- const isHexChar = (value) => {
1782
- if (value >= 0x30 && value <= 0x39) {
1783
- return true;
1784
- }
1785
- if (value >= 0x41 && value <= 0x46) {
1786
- return true;
1787
- }
1788
- if (lenientHex && value >= 0x61 && value <= 0x66) {
1789
- return true;
1790
- }
1791
- return false;
1792
- };
1995
+ const lenientHex = options.lenientHex ?? false;
1996
+ const hexTable = lenientHex ? HEX_DECODE : HEX_DECODE_STRICT;
1793
1997
  const onData = (data) => {
1794
1998
  const state = this._state;
1795
- for (let i = 0; i < data.length; i++) {
1999
+ const dataLen = data.length;
2000
+ // Fast path: idle state and the chunk is exactly one complete frame
2001
+ // ASCII frame: length >= 9 and always odd (9 + 2n)
2002
+ if (state.status === 'idle' && dataLen >= 9 && dataLen % 2 !== 0) {
2003
+ if (data[0] === CHAR_CODE.COLON && data[dataLen - 2] === CHAR_CODE.CR && data[dataLen - 1] === CHAR_CODE.LF) {
2004
+ let valid = true;
2005
+ for (let i = 1; i < dataLen - 2; i++) {
2006
+ if (hexTable[data[i]] > 15) {
2007
+ valid = false;
2008
+ break;
2009
+ }
2010
+ }
2011
+ if (valid) {
2012
+ const hexView = data.subarray(1, dataLen - 2);
2013
+ this.framing(hexView, hexView.length, hexTable);
2014
+ return;
2015
+ }
2016
+ }
2017
+ }
2018
+ for (let i = 0; i < dataLen; i++) {
1796
2019
  const value = data[i];
1797
2020
  switch (state.status) {
1798
2021
  case 'idle': {
@@ -1814,7 +2037,7 @@ class AsciiApplicationLayer extends AbstractApplicationLayer {
1814
2037
  state.frameLen = 0;
1815
2038
  this.onFramingError(new Error('Invalid data'));
1816
2039
  }
1817
- else if (!isHexChar(value)) {
2040
+ else if (hexTable[value] > 15) {
1818
2041
  state.status = 'idle';
1819
2042
  state.frameLen = 0;
1820
2043
  this.onFramingError(new Error('Invalid hex character'));
@@ -1832,7 +2055,7 @@ class AsciiApplicationLayer extends AbstractApplicationLayer {
1832
2055
  else {
1833
2056
  state.status = 'idle';
1834
2057
  if (value === CHAR_CODE.LF) {
1835
- this.framing(state.frame, state.frameLen);
2058
+ this.framing(state.frame, state.frameLen, hexTable);
1836
2059
  }
1837
2060
  }
1838
2061
  break;
@@ -1851,7 +2074,7 @@ class AsciiApplicationLayer extends AbstractApplicationLayer {
1851
2074
  connection.on('close', onClose);
1852
2075
  this._cleanupCbs.push(() => connection.off('close', onClose));
1853
2076
  }
1854
- framing(hexChars, hexLen) {
2077
+ framing(hexChars, hexLen, hexTable) {
1855
2078
  if (hexLen < 6) {
1856
2079
  this.onFramingError(new Error('Insufficient data length'));
1857
2080
  return;
@@ -1863,10 +2086,10 @@ class AsciiApplicationLayer extends AbstractApplicationLayer {
1863
2086
  const byteLen = hexLen >> 1;
1864
2087
  // Decode unit and fc directly from the first 4 hex characters —
1865
2088
  // avoids allocating a full decoded buffer just to read two bytes.
1866
- const unitHi = HEX_DECODE[hexChars[0]];
1867
- const unitLo = HEX_DECODE[hexChars[1]];
1868
- const fcHi = HEX_DECODE[hexChars[2]];
1869
- const fcLo = HEX_DECODE[hexChars[3]];
2089
+ const unitHi = hexTable[hexChars[0]];
2090
+ const unitLo = hexTable[hexChars[1]];
2091
+ const fcHi = hexTable[hexChars[2]];
2092
+ const fcLo = hexTable[hexChars[3]];
1870
2093
  if (unitHi === 0xff || unitLo === 0xff || fcHi === 0xff || fcLo === 0xff) {
1871
2094
  this.onFramingError(new Error('Invalid hex character'));
1872
2095
  return;
@@ -1874,30 +2097,31 @@ class AsciiApplicationLayer extends AbstractApplicationLayer {
1874
2097
  const unit = (unitHi << 4) | unitLo;
1875
2098
  const fc = (fcHi << 4) | fcLo;
1876
2099
  // Decode LRC from the last 2 hex characters.
1877
- const lrcHi = HEX_DECODE[hexChars[hexLen - 2]];
1878
- const lrcLo = HEX_DECODE[hexChars[hexLen - 1]];
2100
+ const lrcHi = hexTable[hexChars[hexLen - 2]];
2101
+ const lrcLo = hexTable[hexChars[hexLen - 1]];
1879
2102
  if (lrcHi === 0xff || lrcLo === 0xff) {
1880
2103
  this.onFramingError(new Error('Invalid hex character'));
1881
2104
  return;
1882
2105
  }
1883
2106
  const lrcIn = (lrcHi << 4) | lrcLo;
1884
- // Decode data portion (between unit/fc and lrc) into a right-sized buffer.
2107
+ // Decode data portion (between unit/fc and lrc) into a right-sized buffer,
2108
+ // while simultaneously accumulating the LRC sum — one pass instead of two.
1885
2109
  // dataLen may be 0 for a frame that is only unit + fc + lrc.
1886
2110
  const dataLen = byteLen - 3;
1887
2111
  const data = Buffer.allocUnsafe(dataLen);
2112
+ let hexOff = 4;
2113
+ let sum = unit + fc;
1888
2114
  for (let i = 0; i < dataLen; i++) {
1889
- const hi = HEX_DECODE[hexChars[4 + i * 2]];
1890
- const lo = HEX_DECODE[hexChars[4 + i * 2 + 1]];
2115
+ const hi = hexTable[hexChars[hexOff]];
2116
+ const lo = hexTable[hexChars[hexOff + 1]];
1891
2117
  if (hi === 0xff || lo === 0xff) {
1892
2118
  this.onFramingError(new Error('Invalid hex character'));
1893
2119
  return;
1894
2120
  }
1895
- data[i] = (hi << 4) | lo;
1896
- }
1897
- // Compute LRC over unit + fc + data.
1898
- let sum = unit + fc;
1899
- for (let i = 0; i < dataLen; i++) {
1900
- sum += data[i];
2121
+ const byte = (hi << 4) | lo;
2122
+ data[i] = byte;
2123
+ sum += byte;
2124
+ hexOff += 2;
1901
2125
  }
1902
2126
  const lrcComputed = (~sum + 1) & 0xff;
1903
2127
  if (lrcIn !== lrcComputed) {
@@ -1923,13 +2147,15 @@ class AsciiApplicationLayer extends AbstractApplicationLayer {
1923
2147
  buffer[0] = unit;
1924
2148
  buffer[1] = fc;
1925
2149
  buffer.set(data, 2);
1926
- buffer[buffer.length - 1] = lrc(buffer.subarray(0, -1));
2150
+ buffer[buffer.length - 1] = lrc(buffer, 0, buffer.length - 1);
1927
2151
  const out = Buffer.allocUnsafe(1 + buffer.length * 2 + 2);
1928
2152
  out[0] = CHAR_CODE.COLON;
2153
+ let outOff = 1;
1929
2154
  for (let i = 0; i < buffer.length; i++) {
1930
2155
  const byte = buffer[i];
1931
- out[1 + i * 2] = HEX_ENCODE[byte >> 4];
1932
- out[2 + i * 2] = HEX_ENCODE[byte & 0x0f];
2156
+ out[outOff] = HEX_ENCODE[byte >> 4];
2157
+ out[outOff + 1] = HEX_ENCODE[byte & 0x0f];
2158
+ outOff += 2;
1933
2159
  }
1934
2160
  out[out.length - 2] = CHAR_CODE.CR;
1935
2161
  out[out.length - 1] = CHAR_CODE.LF;
@@ -1937,13 +2163,15 @@ class AsciiApplicationLayer extends AbstractApplicationLayer {
1937
2163
  }
1938
2164
  }
1939
2165
 
1940
- const MAX_TCP_FRAME = 260;
2166
+ const MAX_FRAME_LENGTH = 260;
1941
2167
  class TcpApplicationLayer extends AbstractApplicationLayer {
1942
2168
  PROTOCOL = 'TCP';
1943
2169
  ROLE;
1944
2170
  _connection;
1945
2171
  _transactionId = 1;
1946
- _buffer = EMPTY_BUFFER;
2172
+ _residual = Buffer.alloc(MAX_FRAME_LENGTH);
2173
+ _residualLen = 0;
2174
+ _expectedLen = PREDICT_NEED_MORE;
1947
2175
  _cleanupCbs = [];
1948
2176
  get connection() {
1949
2177
  return this._connection;
@@ -1953,51 +2181,108 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1953
2181
  this.ROLE = role;
1954
2182
  this._connection = connection;
1955
2183
  const onData = (data) => {
1956
- // Fast path: _buffer is empty and data is a single, complete frame.
1957
- // This avoids the tryExtract subarray allocations + while loop
1958
- // for the overwhelmingly common case (one frame per TCP packet).
1959
- if (this._buffer.length === 0 && data.length >= 8) {
1960
- const length = (data[4] << 8) | data[5]; // inline BE read
1961
- const total = 6 + length;
1962
- if (data[2] === 0 && data[3] === 0 && total <= MAX_TCP_FRAME && length >= 2 && data.length === total) {
1963
- this.processFrame(data);
1964
- return;
2184
+ const dataLen = data.length;
2185
+ const residualLen = this._residualLen;
2186
+ const totalAvailable = residualLen + dataLen;
2187
+ // Fast path: no residual data and the new chunk is exactly one frame
2188
+ if (residualLen === 0 && dataLen >= 8) {
2189
+ if (data[2] === 0 && data[3] === 0) {
2190
+ const length = (data[4] << 8) | data[5];
2191
+ const frameLen = 6 + length;
2192
+ if (frameLen === dataLen && frameLen <= MAX_FRAME_LENGTH && length >= 2) {
2193
+ const frame = {
2194
+ transaction: (data[0] << 8) | data[1],
2195
+ unit: data[6],
2196
+ fc: data[7],
2197
+ data: data.subarray(8),
2198
+ buffer: data,
2199
+ };
2200
+ this.onFraming(frame);
2201
+ this._expectedLen = PREDICT_NEED_MORE;
2202
+ return;
2203
+ }
1965
2204
  }
1966
2205
  }
1967
- let buffer = this._buffer;
1968
- if (buffer.length === 0) {
1969
- buffer = data;
2206
+ let index = 0;
2207
+ if (!(this._expectedLen > 0 && totalAvailable < this._expectedLen)) {
2208
+ while (index <= totalAvailable - 6) {
2209
+ // Validate MBAP protocol ID (bytes 2-3 must be 0x0000)
2210
+ if ((index + 2 < residualLen ? this._residual[index + 2] : data[index + 2 - residualLen]) !== 0 ||
2211
+ (index + 3 < residualLen ? this._residual[index + 3] : data[index + 3 - residualLen]) !== 0) {
2212
+ this.onFramingError(new Error('Invalid data'));
2213
+ this._residualLen = 0;
2214
+ this._expectedLen = PREDICT_NEED_MORE;
2215
+ return;
2216
+ }
2217
+ const length = ((index + 4 < residualLen ? this._residual[index + 4] : data[index + 4 - residualLen]) << 8) |
2218
+ (index + 5 < residualLen ? this._residual[index + 5] : data[index + 5 - residualLen]);
2219
+ const frameLen = 6 + length;
2220
+ if (frameLen > MAX_FRAME_LENGTH || length < 2) {
2221
+ this.onFramingError(new Error('Invalid data'));
2222
+ this._residualLen = 0;
2223
+ this._expectedLen = PREDICT_NEED_MORE;
2224
+ return;
2225
+ }
2226
+ if (totalAvailable - index < frameLen) {
2227
+ this._expectedLen = index + frameLen;
2228
+ break;
2229
+ }
2230
+ // A complete frame has been received
2231
+ this._expectedLen = PREDICT_NEED_MORE;
2232
+ const raw = Buffer.allocUnsafe(frameLen);
2233
+ if (index + frameLen <= residualLen) {
2234
+ this._residual.copy(raw, 0, index, index + frameLen);
2235
+ }
2236
+ else if (index >= residualLen) {
2237
+ data.copy(raw, 0, index - residualLen, index - residualLen + frameLen);
2238
+ }
2239
+ else {
2240
+ const headLen = residualLen - index;
2241
+ this._residual.copy(raw, 0, index, residualLen);
2242
+ data.copy(raw, headLen, 0, frameLen - headLen);
2243
+ }
2244
+ const frame = {
2245
+ transaction: (raw[0] << 8) | raw[1],
2246
+ unit: raw[6],
2247
+ fc: raw[7],
2248
+ data: raw.subarray(8),
2249
+ buffer: raw,
2250
+ };
2251
+ this.onFraming(frame);
2252
+ index += frameLen;
2253
+ }
1970
2254
  }
1971
- else if (data.length > 0) {
1972
- buffer = Buffer.concat([buffer, data]);
2255
+ const newFrameStart = index;
2256
+ const finalRestLen = totalAvailable - newFrameStart;
2257
+ if (finalRestLen === 0) {
2258
+ this._residualLen = 0;
2259
+ this._expectedLen = PREDICT_NEED_MORE;
1973
2260
  }
1974
- while (buffer.length > 0) {
1975
- const result = this.tryExtract(buffer);
1976
- if (result.kind === 'frame') {
1977
- this.processFrame(result.frame);
1978
- buffer = result.rest;
2261
+ else {
2262
+ const keepLen = finalRestLen < MAX_FRAME_LENGTH ? finalRestLen : MAX_FRAME_LENGTH;
2263
+ const discardLen = totalAvailable - keepLen;
2264
+ if (discardLen >= residualLen) {
2265
+ // Kept portion lies entirely within the new `data`
2266
+ data.copy(this._residual, 0, discardLen - residualLen, dataLen);
1979
2267
  }
1980
- else if (result.kind === 'insufficient') {
1981
- break;
2268
+ else if (discardLen > 0) {
2269
+ // Kept portion spans both buffers, or physical left-shift truncation occurred
2270
+ for (let i = 0; i < keepLen; i++) {
2271
+ this._residual[i] = discardLen + i < residualLen ? this._residual[discardLen + i] : data[discardLen + i - residualLen];
2272
+ }
1982
2273
  }
1983
2274
  else {
1984
- this.onFramingError(result.error);
1985
- buffer = EMPTY_BUFFER;
1986
- break;
2275
+ // discardLen === 0 (old data not consumed, truncation limit not hit) — simple append
2276
+ data.copy(this._residual, residualLen, 0, dataLen);
2277
+ }
2278
+ this._residualLen = keepLen;
2279
+ // Unify physical coordinate system translation
2280
+ if (discardLen > 0) {
2281
+ if (this._expectedLen > 0) {
2282
+ const newExpectedLen = this._expectedLen - discardLen;
2283
+ this._expectedLen = newExpectedLen > PREDICT_NEED_MORE ? newExpectedLen : PREDICT_NEED_MORE;
2284
+ }
1987
2285
  }
1988
- }
1989
- if (buffer.length === 0) {
1990
- this._buffer = EMPTY_BUFFER;
1991
- }
1992
- else if (buffer === data) {
1993
- // Copy into a right-sized buffer so we do not retain the potentially
1994
- // large backing buffer of the original incoming data (Node.js pool).
1995
- // `Buffer.copyBytesFrom` is a native fast-path — one C++ memcpy
1996
- // vs allocUnsafe + JS-level copy.
1997
- this._buffer = Buffer.copyBytesFrom(buffer);
1998
- }
1999
- else {
2000
- this._buffer = buffer;
2001
2286
  }
2002
2287
  };
2003
2288
  connection.on('data', onData);
@@ -2011,38 +2296,9 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
2011
2296
  connection.on('close', onClose);
2012
2297
  this._cleanupCbs.push(() => connection.off('close', onClose));
2013
2298
  }
2014
- tryExtract(buffer) {
2015
- if (buffer.length < 8) {
2016
- return { kind: 'insufficient' };
2017
- }
2018
- if (buffer[2] !== 0 || buffer[3] !== 0) {
2019
- return { kind: 'error', error: new Error('Invalid data') };
2020
- }
2021
- const length = (buffer[4] << 8) | buffer[5]; // inline BE read
2022
- const total = 6 + length;
2023
- if (total > MAX_TCP_FRAME || length < 2) {
2024
- return { kind: 'error', error: new Error('Invalid data') };
2025
- }
2026
- if (buffer.length < total) {
2027
- return { kind: 'insufficient' };
2028
- }
2029
- return { kind: 'frame', frame: buffer.subarray(0, total), rest: total === buffer.length ? EMPTY_BUFFER : buffer.subarray(total) };
2030
- }
2031
- processFrame(buffer) {
2032
- const frame = {
2033
- // Inline 16-bit BE read — direct typed-array loads skip readUInt16BE's
2034
- // argument coercion + bounds check. Symmetric to the header writes in
2035
- // encode() above. Hits on every received TCP frame.
2036
- transaction: (buffer[0] << 8) | buffer[1],
2037
- unit: buffer[6],
2038
- fc: buffer[7],
2039
- data: buffer.subarray(8),
2040
- buffer,
2041
- };
2042
- this.onFraming(frame);
2043
- }
2044
2299
  flush() {
2045
- this._buffer = EMPTY_BUFFER;
2300
+ this._residualLen = 0;
2301
+ this._expectedLen = PREDICT_NEED_MORE;
2046
2302
  }
2047
2303
  encode(unit, fc, data, transaction) {
2048
2304
  const buffer = Buffer.allocUnsafe(data.length + 8);
@@ -2061,8 +2317,9 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
2061
2317
  // Small-payload fast path: avoid C++ TypedArray.prototype.set boundary
2062
2318
  // crossing when the copy is just a few bytes (common for FC 3/4/6 requests).
2063
2319
  if (data.length <= 16) {
2064
- for (let i = 0; i < data.length; i++)
2320
+ for (let i = 0; i < data.length; i++) {
2065
2321
  buffer[8 + i] = data[i];
2322
+ }
2066
2323
  }
2067
2324
  else {
2068
2325
  buffer.set(data, 8);
@@ -2110,26 +2367,33 @@ class MasterSession {
2110
2367
  }
2111
2368
 
2112
2369
  function validateResponse(frame, unit, fc) {
2113
- if (frame.unit !== unit || frame.fc !== fc)
2370
+ if (frame.unit !== unit || frame.fc !== fc) {
2114
2371
  throw new Error('Invalid response');
2372
+ }
2115
2373
  }
2116
2374
  function validateByteCountResponse(frame, unit, fc, byteCount) {
2117
2375
  validateResponse(frame, unit, fc);
2118
- if (frame.data.length < 1 + byteCount)
2376
+ if (frame.data.length < 1 + byteCount) {
2119
2377
  throw new Error('Insufficient data length');
2120
- if (frame.data.length !== 1 + byteCount)
2378
+ }
2379
+ if (frame.data.length !== 1 + byteCount) {
2121
2380
  throw new Error('Invalid response');
2122
- if (frame.data[0] !== byteCount)
2381
+ }
2382
+ if (frame.data[0] !== byteCount) {
2123
2383
  throw new Error('Invalid response');
2384
+ }
2124
2385
  }
2125
2386
  function validateEchoResponse(frame, unit, fc, expected) {
2126
2387
  validateResponse(frame, unit, fc);
2127
- if (frame.data.length < expected.length)
2388
+ if (frame.data.length < expected.length) {
2128
2389
  throw new Error('Insufficient data length');
2129
- if (frame.data.length !== expected.length)
2390
+ }
2391
+ if (frame.data.length !== expected.length) {
2130
2392
  throw new Error('Invalid response');
2131
- if (!frame.data.equals(expected))
2393
+ }
2394
+ if (!frame.data.equals(expected)) {
2132
2395
  throw new Error('Invalid response');
2396
+ }
2133
2397
  }
2134
2398
  class ModbusMaster extends EventEmitter {
2135
2399
  timeout;
@@ -2157,8 +2421,9 @@ class ModbusMaster extends EventEmitter {
2157
2421
  _pendingExchanges = new Map();
2158
2422
  _timerHeap = new TimerHeap((id) => {
2159
2423
  const pending = this._pendingExchanges.get(id);
2160
- if (!pending)
2161
- return; // lazy deletion: already handled
2424
+ if (!pending) {
2425
+ return;
2426
+ } // lazy deletion: already handled
2162
2427
  pending.settled = true;
2163
2428
  this._pendingExchanges.delete(id);
2164
2429
  if (pending.sessionKey !== null) {
@@ -2200,6 +2465,7 @@ class ModbusMaster extends EventEmitter {
2200
2465
  appLayer.onFraming = NOOP;
2201
2466
  };
2202
2467
  const onFraming = (frame) => {
2468
+ this.emit('framing', frame, connection);
2203
2469
  this._masterSession.handleFrame(frame);
2204
2470
  };
2205
2471
  appLayer.onFraming = onFraming;
@@ -2208,17 +2474,34 @@ class ModbusMaster extends EventEmitter {
2208
2474
  appLayer.onFramingError = NOOP;
2209
2475
  };
2210
2476
  const onFramingError = (error) => {
2477
+ this.emit('framingError', error, connection);
2211
2478
  this._masterSession.handleError(error);
2212
2479
  };
2213
2480
  appLayer.onFramingError = onFramingError;
2214
2481
  this._cleanupFns.add(cleanupFramingError);
2482
+ const cleanupTx = () => connection.off('tx', onTx);
2483
+ const onTx = (buffer) => {
2484
+ this.emit('tx', buffer, connection);
2485
+ };
2486
+ connection.on('tx', onTx);
2487
+ this._cleanupFns.add(cleanupTx);
2488
+ const cleanupRx = () => connection.off('rx', onRx);
2489
+ const onRx = (buffer) => {
2490
+ this.emit('rx', buffer, connection);
2491
+ };
2492
+ connection.on('rx', onRx);
2493
+ this._cleanupFns.add(cleanupRx);
2215
2494
  const cleanupClose = () => connection.off('close', onClose);
2216
2495
  const onClose = () => {
2217
2496
  cleanupFraming();
2218
2497
  cleanupFramingError();
2498
+ cleanupTx();
2499
+ cleanupRx();
2219
2500
  cleanupClose();
2220
2501
  this._cleanupFns.delete(cleanupFraming);
2221
2502
  this._cleanupFns.delete(cleanupFramingError);
2503
+ this._cleanupFns.delete(cleanupTx);
2504
+ this._cleanupFns.delete(cleanupRx);
2222
2505
  this._cleanupFns.delete(cleanupClose);
2223
2506
  };
2224
2507
  connection.on('close', onClose);
@@ -2258,7 +2541,7 @@ class ModbusMaster extends EventEmitter {
2258
2541
  return new RtuApplicationLayer('MASTER', connection, {
2259
2542
  intervalBetweenFrames,
2260
2543
  interCharTimeout,
2261
- poolSize: this._protocol.opts?.poolSize,
2544
+ strictTiming: this._protocol.opts?.strictTiming,
2262
2545
  });
2263
2546
  }
2264
2547
  if (this._protocol.type === 'TCP') {
@@ -2345,7 +2628,9 @@ class ModbusMaster extends EventEmitter {
2345
2628
  }
2346
2629
  // Lazy-deletion timer architecture:
2347
2630
  // 1. Assign an exchangeId and register in _pendingExchanges.
2348
- // 2. Push deadline into the global TimerHeap (no per-request setTimeout).
2631
+ // 2. Push deadline into the global TimerHeap (one native setTimeout under
2632
+ // load; a fast direct-timer path is used when only 1-2 exchanges are
2633
+ // pending).
2349
2634
  // 3. When the response arrives, delete from Map — the heap entry is left
2350
2635
  // behind and silently discarded when it surfaces at the top (lazy deletion).
2351
2636
  const exchangeId = this._nextExchangeId++;
@@ -2356,11 +2641,13 @@ class ModbusMaster extends EventEmitter {
2356
2641
  this._timerHeap.add(exchangeId, timeout);
2357
2642
  connection.write(appLayer.encode(unit, fc, data), (writeErr) => {
2358
2643
  const p = this._pendingExchanges.get(exchangeId);
2359
- if (!p || p.settled)
2644
+ if (!p || p.settled) {
2360
2645
  return;
2646
+ }
2361
2647
  const cb = p.callback;
2362
- if (!cb)
2648
+ if (!cb) {
2363
2649
  return;
2650
+ }
2364
2651
  p.settled = true;
2365
2652
  p.callback = null;
2366
2653
  this._pendingExchanges.delete(exchangeId);
@@ -2389,8 +2676,9 @@ class ModbusMaster extends EventEmitter {
2389
2676
  this._timerHeap.add(exchangeId, timeout);
2390
2677
  connection.write(payload, (writeErr) => {
2391
2678
  const p = this._pendingExchanges.get(exchangeId);
2392
- if (!p || p.settled)
2679
+ if (!p || p.settled) {
2393
2680
  return;
2681
+ }
2394
2682
  if (writeErr) {
2395
2683
  const cb = p.callback;
2396
2684
  if (cb) {
@@ -2415,8 +2703,9 @@ class ModbusMaster extends EventEmitter {
2415
2703
  // Timeout is managed by the global timer heap above.
2416
2704
  this._masterSession.start(key, (err, frame) => {
2417
2705
  const p2 = this._pendingExchanges.get(exchangeId);
2418
- if (!p2 || p2.settled)
2706
+ if (!p2 || p2.settled) {
2419
2707
  return;
2708
+ }
2420
2709
  const cb = p2.callback;
2421
2710
  if (cb) {
2422
2711
  p2.settled = true;
@@ -2428,10 +2717,8 @@ class ModbusMaster extends EventEmitter {
2428
2717
  });
2429
2718
  }
2430
2719
  writeFC1Or2(unit, fc, address, length, timeout) {
2431
- const byteCount = Math.ceil(length / 8);
2720
+ const byteCount = (length + 7) >> 3;
2432
2721
  const bufferTx = Buffer.allocUnsafe(4);
2433
- // Inline big-endian writes — direct typed-array stores skip the argument
2434
- // validation + bounds checks that `writeUInt16BE` runs on each call.
2435
2722
  bufferTx[0] = (address >>> 8) & 0xff;
2436
2723
  bufferTx[1] = address & 0xff;
2437
2724
  bufferTx[2] = (length >>> 8) & 0xff;
@@ -2448,12 +2735,44 @@ class ModbusMaster extends EventEmitter {
2448
2735
  }
2449
2736
  try {
2450
2737
  validateByteCountResponse(frame, unit, fc, byteCount);
2451
- const data = new Array(length);
2452
- for (let i = 0; i < length; i++) {
2453
- data[i] = (frame.data[1 + Math.floor(i / 8)] & (1 << i % 8)) > 0;
2738
+ const data = new Uint8Array(length);
2739
+ let byteIdx = 1;
2740
+ let outIdx = 0;
2741
+ const fullBytes = length >> 3;
2742
+ for (let b = 0; b < fullBytes; b++) {
2743
+ const byte = frame.data[byteIdx++];
2744
+ data[outIdx++] = byte & 0x01;
2745
+ data[outIdx++] = (byte >>> 1) & 0x01;
2746
+ data[outIdx++] = (byte >>> 2) & 0x01;
2747
+ data[outIdx++] = (byte >>> 3) & 0x01;
2748
+ data[outIdx++] = (byte >>> 4) & 0x01;
2749
+ data[outIdx++] = (byte >>> 5) & 0x01;
2750
+ data[outIdx++] = (byte >>> 6) & 0x01;
2751
+ data[outIdx++] = (byte >>> 7) & 0x01;
2752
+ }
2753
+ const rem = length & 7;
2754
+ if (rem) {
2755
+ const byte = frame.data[byteIdx];
2756
+ data[outIdx++] = byte & 0x01;
2757
+ if (rem > 1) {
2758
+ data[outIdx++] = (byte >>> 1) & 0x01;
2759
+ }
2760
+ if (rem > 2) {
2761
+ data[outIdx++] = (byte >>> 2) & 0x01;
2762
+ }
2763
+ if (rem > 3) {
2764
+ data[outIdx++] = (byte >>> 3) & 0x01;
2765
+ }
2766
+ if (rem > 4) {
2767
+ data[outIdx++] = (byte >>> 4) & 0x01;
2768
+ }
2769
+ if (rem > 5) {
2770
+ data[outIdx++] = (byte >>> 5) & 0x01;
2771
+ }
2772
+ if (rem > 6) {
2773
+ data[outIdx++] = (byte >>> 6) & 0x01;
2774
+ }
2454
2775
  }
2455
- // Mutate the frame in place rather than spread-copying — `frame` is freshly
2456
- // allocated per request and not retained anywhere else.
2457
2776
  frame.data = data;
2458
2777
  resolve(frame);
2459
2778
  }
@@ -2498,9 +2817,10 @@ class ModbusMaster extends EventEmitter {
2498
2817
  // on each call. Symmetric to the slave-side BE write inlining
2499
2818
  // in handleFC3/FC4. At length=125 (FC3 max) that's 250 saved
2500
2819
  // bounds-check pairs per response.
2820
+ let off = 0;
2501
2821
  for (let i = 0; i < length; i++) {
2502
- const off = i * 2;
2503
2822
  data[i] = (bufferRx[off] << 8) | bufferRx[off + 1];
2823
+ off += 2;
2504
2824
  }
2505
2825
  frame.data = data;
2506
2826
  resolve(frame);
@@ -2523,7 +2843,7 @@ class ModbusMaster extends EventEmitter {
2523
2843
  writeSingleCoil(unit, address, value, timeout = this.timeout) {
2524
2844
  const fc = FunctionCode.WRITE_SINGLE_COIL;
2525
2845
  const bufferTx = Buffer.allocUnsafe(4);
2526
- const coilValue = value ? COIL_ON : COIL_OFF;
2846
+ const coilValue = value === 1 ? COIL_ON : COIL_OFF;
2527
2847
  // Inline big-endian writes — see writeFC1Or2 for the rationale.
2528
2848
  bufferTx[0] = (address >>> 8) & 0xff;
2529
2849
  bufferTx[1] = address & 0xff;
@@ -2583,18 +2903,52 @@ class ModbusMaster extends EventEmitter {
2583
2903
  writeFC15;
2584
2904
  writeMultipleCoils(unit, address, value, timeout = this.timeout) {
2585
2905
  const fc = FunctionCode.WRITE_MULTIPLE_COILS;
2586
- const byteCount = Math.ceil(value.length / 8);
2587
- const bufferTx = Buffer.alloc(5 + byteCount);
2906
+ const len = value.length;
2907
+ const byteCount = (len + 7) >> 3;
2908
+ const bufferTx = Buffer.allocUnsafe(5 + byteCount);
2588
2909
  // Inline big-endian writes — see writeFC1Or2 for the rationale.
2589
2910
  bufferTx[0] = (address >>> 8) & 0xff;
2590
2911
  bufferTx[1] = address & 0xff;
2591
- bufferTx[2] = (value.length >>> 8) & 0xff;
2592
- bufferTx[3] = value.length & 0xff;
2912
+ bufferTx[2] = (len >>> 8) & 0xff;
2913
+ bufferTx[3] = len & 0xff;
2593
2914
  bufferTx[4] = byteCount;
2594
- for (let i = 0; i < value.length; i++) {
2595
- if (value[i]) {
2596
- bufferTx[5 + Math.floor(i / 8)] |= 1 << i % 8;
2915
+ let out = 5;
2916
+ const fullBytes = len >> 3;
2917
+ for (let b = 0; b < fullBytes; b++) {
2918
+ const base = b << 3;
2919
+ bufferTx[out++] =
2920
+ (value[base] & 1) |
2921
+ ((value[base + 1] & 1) << 1) |
2922
+ ((value[base + 2] & 1) << 2) |
2923
+ ((value[base + 3] & 1) << 3) |
2924
+ ((value[base + 4] & 1) << 4) |
2925
+ ((value[base + 5] & 1) << 5) |
2926
+ ((value[base + 6] & 1) << 6) |
2927
+ ((value[base + 7] & 1) << 7);
2928
+ }
2929
+ const rem = len & 7;
2930
+ if (rem) {
2931
+ const base = fullBytes << 3;
2932
+ let acc = value[base] & 1;
2933
+ if (rem > 1) {
2934
+ acc |= (value[base + 1] & 1) << 1;
2935
+ }
2936
+ if (rem > 2) {
2937
+ acc |= (value[base + 2] & 1) << 2;
2938
+ }
2939
+ if (rem > 3) {
2940
+ acc |= (value[base + 3] & 1) << 3;
2597
2941
  }
2942
+ if (rem > 4) {
2943
+ acc |= (value[base + 4] & 1) << 4;
2944
+ }
2945
+ if (rem > 5) {
2946
+ acc |= (value[base + 5] & 1) << 5;
2947
+ }
2948
+ if (rem > 6) {
2949
+ acc |= (value[base + 6] & 1) << 6;
2950
+ }
2951
+ bufferTx[out] = acc;
2598
2952
  }
2599
2953
  return new Promise((resolve, reject) => {
2600
2954
  this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
@@ -2628,11 +2982,12 @@ class ModbusMaster extends EventEmitter {
2628
2982
  bufferTx[2] = (value.length >>> 8) & 0xff;
2629
2983
  bufferTx[3] = value.length & 0xff;
2630
2984
  bufferTx[4] = byteCount;
2985
+ let off = 5;
2631
2986
  for (let i = 0; i < value.length; i++) {
2632
2987
  const v = value[i];
2633
- const off = 5 + i * 2;
2634
2988
  bufferTx[off] = (v >>> 8) & 0xff;
2635
2989
  bufferTx[off + 1] = v & 0xff;
2990
+ off += 2;
2636
2991
  }
2637
2992
  return new Promise((resolve, reject) => {
2638
2993
  this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
@@ -2670,15 +3025,17 @@ class ModbusMaster extends EventEmitter {
2670
3025
  }
2671
3026
  try {
2672
3027
  validateResponse(frame, unit, fc);
2673
- if (frame.data.length < 2 + serverIdLength)
3028
+ if (frame.data.length < 2 + serverIdLength) {
2674
3029
  throw new Error('Insufficient data length');
2675
- if (frame.data.length !== 1 + frame.data[0])
3030
+ }
3031
+ if (frame.data.length !== 1 + frame.data[0]) {
2676
3032
  throw new Error('Invalid response');
3033
+ }
2677
3034
  const runStatusIndex = 1 + serverIdLength;
2678
3035
  frame.data = {
2679
3036
  serverId: Array.from(frame.data.subarray(1, runStatusIndex)),
2680
3037
  runIndicatorStatus: frame.data[runStatusIndex] === 0xff,
2681
- additionalData: Array.from(frame.data.subarray(runStatusIndex + 1)),
3038
+ additionalData: frame.data.subarray(runStatusIndex + 1),
2682
3039
  };
2683
3040
  resolve(frame);
2684
3041
  }
@@ -2736,11 +3093,12 @@ class ModbusMaster extends EventEmitter {
2736
3093
  bufferTx[6] = (write.value.length >>> 8) & 0xff;
2737
3094
  bufferTx[7] = write.value.length & 0xff;
2738
3095
  bufferTx[8] = byteCount;
3096
+ let off = 9;
2739
3097
  for (let i = 0; i < write.value.length; i++) {
2740
3098
  const v = write.value[i];
2741
- const off = 9 + i * 2;
2742
3099
  bufferTx[off] = (v >>> 8) & 0xff;
2743
3100
  bufferTx[off + 1] = v & 0xff;
3101
+ off += 2;
2744
3102
  }
2745
3103
  return new Promise((resolve, reject) => {
2746
3104
  this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
@@ -2759,9 +3117,10 @@ class ModbusMaster extends EventEmitter {
2759
3117
  // closure + N readUInt16BE bounds-check pairs. See writeFC3Or4
2760
3118
  // response handler for the same optimization.
2761
3119
  const data = new Array(read.length);
3120
+ let off = 0;
2762
3121
  for (let i = 0; i < read.length; i++) {
2763
- const off = i * 2;
2764
3122
  data[i] = (bufferRx[off] << 8) | bufferRx[off + 1];
3123
+ off += 2;
2765
3124
  }
2766
3125
  frame.data = data;
2767
3126
  resolve(frame);
@@ -2787,10 +3146,12 @@ class ModbusMaster extends EventEmitter {
2787
3146
  }
2788
3147
  try {
2789
3148
  validateResponse(frame, unit, fc);
2790
- if (frame.data.length < 6)
3149
+ if (frame.data.length < 6) {
2791
3150
  throw new Error('Insufficient data length');
2792
- if (frame.data[0] !== MEI_READ_DEVICE_ID || frame.data[1] !== readDeviceIDCode)
3151
+ }
3152
+ if (frame.data[0] !== MEI_READ_DEVICE_ID || frame.data[1] !== readDeviceIDCode) {
2793
3153
  throw new Error('Invalid response');
3154
+ }
2794
3155
  const objects = [];
2795
3156
  let object = [];
2796
3157
  let totalBytes = 0;
@@ -2818,10 +3179,12 @@ class ModbusMaster extends EventEmitter {
2818
3179
  break;
2819
3180
  }
2820
3181
  }
2821
- if (objects.length !== frame.data[5])
3182
+ if (objects.length !== frame.data[5]) {
2822
3183
  throw new Error('Invalid response');
2823
- if (frame.data.length !== 6 + totalBytes)
3184
+ }
3185
+ if (frame.data.length !== 6 + totalBytes) {
2824
3186
  throw new Error('Invalid response');
3187
+ }
2825
3188
  frame.data = {
2826
3189
  readDeviceIDCode,
2827
3190
  conformityLevel: frame.data[2],
@@ -2868,7 +3231,7 @@ class ModbusMaster extends EventEmitter {
2868
3231
  });
2869
3232
  }
2870
3233
  /**
2871
- * Open the underlying physical layer and begin accepting connections.
3234
+ * Open the underlying physical layer and establish a connection.
2872
3235
  *
2873
3236
  * A `ModbusMaster` instance can only be opened once. Once {@link close}
2874
3237
  * is called — explicitly or because the physical layer disconnected —
@@ -2898,8 +3261,9 @@ class ModbusMaster extends EventEmitter {
2898
3261
  // (broadcasts have no session waiter; non-broadcasts still in the
2899
3262
  // pre-write-window haven't registered in session yet).
2900
3263
  for (const pending of this._pendingExchanges.values()) {
2901
- if (pending.settled)
3264
+ if (pending.settled) {
2902
3265
  continue;
3266
+ }
2903
3267
  pending.settled = true;
2904
3268
  const cb = pending.callback;
2905
3269
  if (cb) {
@@ -2977,6 +3341,7 @@ class ModbusSlave extends EventEmitter {
2977
3341
  appLayer.onFraming = NOOP;
2978
3342
  };
2979
3343
  const onFraming = (frame) => {
3344
+ this.emit('framing', frame, connection);
2980
3345
  if (this._physicalLayer.state !== PhysicalState.OPEN) {
2981
3346
  return;
2982
3347
  }
@@ -2992,11 +3357,37 @@ class ModbusSlave extends EventEmitter {
2992
3357
  };
2993
3358
  appLayer.onFraming = onFraming;
2994
3359
  this._cleanupFns.add(cleanupFraming);
3360
+ const cleanupFramingError = () => {
3361
+ appLayer.onFramingError = NOOP;
3362
+ };
3363
+ const onFramingError = (error) => {
3364
+ this.emit('framingError', error, connection);
3365
+ };
3366
+ appLayer.onFramingError = onFramingError;
3367
+ this._cleanupFns.add(cleanupFramingError);
3368
+ const cleanupTx = () => connection.off('tx', onTx);
3369
+ const onTx = (buffer) => {
3370
+ this.emit('tx', buffer, connection);
3371
+ };
3372
+ connection.on('tx', onTx);
3373
+ this._cleanupFns.add(cleanupTx);
3374
+ const cleanupRx = () => connection.off('rx', onRx);
3375
+ const onRx = (buffer) => {
3376
+ this.emit('rx', buffer, connection);
3377
+ };
3378
+ connection.on('rx', onRx);
3379
+ this._cleanupFns.add(cleanupRx);
2995
3380
  const cleanupClose = () => connection.off('close', onClose);
2996
3381
  const onClose = () => {
2997
3382
  cleanupFraming();
3383
+ cleanupFramingError();
3384
+ cleanupTx();
3385
+ cleanupRx();
2998
3386
  cleanupClose();
2999
3387
  this._cleanupFns.delete(cleanupFraming);
3388
+ this._cleanupFns.delete(cleanupFramingError);
3389
+ this._cleanupFns.delete(cleanupTx);
3390
+ this._cleanupFns.delete(cleanupRx);
3000
3391
  this._cleanupFns.delete(cleanupClose);
3001
3392
  this._appLayers.delete(appLayer);
3002
3393
  };
@@ -3025,7 +3416,7 @@ class ModbusSlave extends EventEmitter {
3025
3416
  return new RtuApplicationLayer('SLAVE', connection, {
3026
3417
  intervalBetweenFrames,
3027
3418
  interCharTimeout,
3028
- poolSize: this._protocol.opts?.poolSize,
3419
+ strictTiming: this._protocol.opts?.strictTiming,
3029
3420
  });
3030
3421
  }
3031
3422
  if (this._protocol.type === 'TCP') {
@@ -3057,23 +3448,44 @@ class ModbusSlave extends EventEmitter {
3057
3448
  const byteCount = (length + 7) >> 3;
3058
3449
  const pdu = Buffer.allocUnsafe(byteCount + 1);
3059
3450
  pdu[0] = byteCount;
3060
- // Pack 8 booleans per byte without first zero-filling: accumulate into
3061
- // `acc` and write a full byte once each lane is finished. Saves the
3062
- // `pdu.fill(0, 1, ...)` pass and replaces N `|=` reads-modify-writes
3063
- // with N `read`s + ⌈N/8⌉ `write`s. Measured ~+5% on FC01 / ~+3% on FC02
3064
- // at max payload (2000 / 1968 coils) via benchmark/all-fcs.ts bisect.
3065
- let acc = 0;
3066
3451
  let out = 1;
3067
- for (let i = 0; i < length; i++) {
3068
- if (coils[i])
3069
- acc |= 1 << (i & 7);
3070
- if ((i & 7) === 7) {
3071
- pdu[out++] = acc;
3072
- acc = 0;
3073
- }
3452
+ const fullBytes = length >> 3;
3453
+ for (let i = 0; i < fullBytes; i++) {
3454
+ const base = i << 3;
3455
+ pdu[out++] =
3456
+ (coils[base] & 1) |
3457
+ ((coils[base + 1] & 1) << 1) |
3458
+ ((coils[base + 2] & 1) << 2) |
3459
+ ((coils[base + 3] & 1) << 3) |
3460
+ ((coils[base + 4] & 1) << 4) |
3461
+ ((coils[base + 5] & 1) << 5) |
3462
+ ((coils[base + 6] & 1) << 6) |
3463
+ ((coils[base + 7] & 1) << 7);
3074
3464
  }
3075
- if ((length & 7) !== 0)
3465
+ const rem = length & 7;
3466
+ if (rem) {
3467
+ const base = fullBytes << 3;
3468
+ let acc = coils[base] & 1;
3469
+ if (rem > 1) {
3470
+ acc |= (coils[base + 1] & 1) << 1;
3471
+ }
3472
+ if (rem > 2) {
3473
+ acc |= (coils[base + 2] & 1) << 2;
3474
+ }
3475
+ if (rem > 3) {
3476
+ acc |= (coils[base + 3] & 1) << 3;
3477
+ }
3478
+ if (rem > 4) {
3479
+ acc |= (coils[base + 4] & 1) << 4;
3480
+ }
3481
+ if (rem > 5) {
3482
+ acc |= (coils[base + 5] & 1) << 5;
3483
+ }
3484
+ if (rem > 6) {
3485
+ acc |= (coils[base + 6] & 1) << 6;
3486
+ }
3076
3487
  pdu[out] = acc;
3488
+ }
3077
3489
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
3078
3490
  }
3079
3491
  catch (error) {
@@ -3104,19 +3516,44 @@ class ModbusSlave extends EventEmitter {
3104
3516
  const byteCount = (length + 7) >> 3;
3105
3517
  const pdu = Buffer.allocUnsafe(byteCount + 1);
3106
3518
  pdu[0] = byteCount;
3107
- // Accumulator-based bit pack — see handleFC1 for the rationale.
3108
- let acc = 0;
3109
3519
  let out = 1;
3110
- for (let i = 0; i < length; i++) {
3111
- if (discreteInputs[i])
3112
- acc |= 1 << (i & 7);
3113
- if ((i & 7) === 7) {
3114
- pdu[out++] = acc;
3115
- acc = 0;
3116
- }
3520
+ const fullBytes = length >> 3;
3521
+ for (let i = 0; i < fullBytes; i++) {
3522
+ const base = i << 3;
3523
+ pdu[out++] =
3524
+ (discreteInputs[base] & 1) |
3525
+ ((discreteInputs[base + 1] & 1) << 1) |
3526
+ ((discreteInputs[base + 2] & 1) << 2) |
3527
+ ((discreteInputs[base + 3] & 1) << 3) |
3528
+ ((discreteInputs[base + 4] & 1) << 4) |
3529
+ ((discreteInputs[base + 5] & 1) << 5) |
3530
+ ((discreteInputs[base + 6] & 1) << 6) |
3531
+ ((discreteInputs[base + 7] & 1) << 7);
3117
3532
  }
3118
- if ((length & 7) !== 0)
3533
+ const rem = length & 7;
3534
+ if (rem) {
3535
+ const base = fullBytes << 3;
3536
+ let acc = discreteInputs[base] & 1;
3537
+ if (rem > 1) {
3538
+ acc |= (discreteInputs[base + 1] & 1) << 1;
3539
+ }
3540
+ if (rem > 2) {
3541
+ acc |= (discreteInputs[base + 2] & 1) << 2;
3542
+ }
3543
+ if (rem > 3) {
3544
+ acc |= (discreteInputs[base + 3] & 1) << 3;
3545
+ }
3546
+ if (rem > 4) {
3547
+ acc |= (discreteInputs[base + 4] & 1) << 4;
3548
+ }
3549
+ if (rem > 5) {
3550
+ acc |= (discreteInputs[base + 5] & 1) << 5;
3551
+ }
3552
+ if (rem > 6) {
3553
+ acc |= (discreteInputs[base + 6] & 1) << 6;
3554
+ }
3119
3555
  pdu[out] = acc;
3556
+ }
3120
3557
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
3121
3558
  }
3122
3559
  catch (error) {
@@ -3149,11 +3586,12 @@ class ModbusSlave extends EventEmitter {
3149
3586
  // Inline big-endian write — `pdu[i] = v` is a direct typed-array store,
3150
3587
  // while `writeUInt16BE` runs argument validation + bounds checks on each
3151
3588
  // call. At length=125 (FC3 max) that's 250 saved checks per request.
3589
+ let off = 1;
3152
3590
  for (let i = 0; i < length; i++) {
3153
3591
  const v = registers[i];
3154
- const off = 1 + i * 2;
3155
3592
  pdu[off] = (v >>> 8) & 0xff;
3156
3593
  pdu[off + 1] = v & 0xff;
3594
+ off += 2;
3157
3595
  }
3158
3596
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
3159
3597
  }
@@ -3185,11 +3623,12 @@ class ModbusSlave extends EventEmitter {
3185
3623
  const pdu = Buffer.allocUnsafe(length * 2 + 1);
3186
3624
  pdu[0] = length * 2;
3187
3625
  // Inline big-endian write — see handleFC3 for the rationale.
3626
+ let off = 1;
3188
3627
  for (let i = 0; i < length; i++) {
3189
3628
  const v = registers[i];
3190
- const off = 1 + i * 2;
3191
3629
  pdu[off] = (v >>> 8) & 0xff;
3192
3630
  pdu[off + 1] = v & 0xff;
3631
+ off += 2;
3193
3632
  }
3194
3633
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
3195
3634
  }
@@ -3217,7 +3656,7 @@ class ModbusSlave extends EventEmitter {
3217
3656
  return;
3218
3657
  }
3219
3658
  try {
3220
- await model.writeSingleCoil(address, value === COIL_ON);
3659
+ await model.writeSingleCoil(address, value === COIL_ON ? 1 : 0);
3221
3660
  await response(appLayer.encode(frame.unit, frame.fc, frame.data, frame.transaction));
3222
3661
  }
3223
3662
  catch (error) {
@@ -3259,7 +3698,7 @@ class ModbusSlave extends EventEmitter {
3259
3698
  const address = (frame.data[0] << 8) | frame.data[1];
3260
3699
  const length = (frame.data[2] << 8) | frame.data[3];
3261
3700
  const byteCount = frame.data[4];
3262
- if (length < LIMITS.READ_COILS_MIN || length > LIMITS.WRITE_COILS_MAX || byteCount !== Math.ceil(length / 8)) {
3701
+ if (length < LIMITS.READ_COILS_MIN || length > LIMITS.WRITE_COILS_MAX || byteCount !== (length + 7) >> 3) {
3263
3702
  await this.responseError(appLayer, frame, response, getErrorByCode(ErrorCode.ILLEGAL_DATA_VALUE));
3264
3703
  return;
3265
3704
  }
@@ -3267,13 +3706,43 @@ class ModbusSlave extends EventEmitter {
3267
3706
  await this.responseError(appLayer, frame, response, getErrorByCode(ErrorCode.ILLEGAL_DATA_ADDRESS));
3268
3707
  return;
3269
3708
  }
3270
- const value = new Array(length);
3271
- for (let i = 0; i < length; i++) {
3272
- // Bit math: kept as `Math.floor(i / 8)` / `i % 8` — V8 pattern-matches
3273
- // both forms with `i` as an integer loop counter to direct shifts, but
3274
- // measured ~+3% throughput vs the bit-shift form on this exact loop
3275
- // (benchmark/all-fcs.ts FC15 slave A/B, 8/8 paired samples).
3276
- value[i] = (frame.data[5 + Math.floor(i / 8)] & (1 << i % 8)) > 0;
3709
+ const value = new Uint8Array(length);
3710
+ let byteIdx = 5;
3711
+ let outIdx = 0;
3712
+ const fullBytes = length >> 3;
3713
+ for (let b = 0; b < fullBytes; b++) {
3714
+ const byte = frame.data[byteIdx++];
3715
+ value[outIdx++] = byte & 0x01;
3716
+ value[outIdx++] = (byte >>> 1) & 0x01;
3717
+ value[outIdx++] = (byte >>> 2) & 0x01;
3718
+ value[outIdx++] = (byte >>> 3) & 0x01;
3719
+ value[outIdx++] = (byte >>> 4) & 0x01;
3720
+ value[outIdx++] = (byte >>> 5) & 0x01;
3721
+ value[outIdx++] = (byte >>> 6) & 0x01;
3722
+ value[outIdx++] = (byte >>> 7) & 0x01;
3723
+ }
3724
+ const rem = length & 7;
3725
+ if (rem) {
3726
+ const byte = frame.data[byteIdx];
3727
+ value[outIdx++] = byte & 0x01;
3728
+ if (rem > 1) {
3729
+ value[outIdx++] = (byte >>> 1) & 0x01;
3730
+ }
3731
+ if (rem > 2) {
3732
+ value[outIdx++] = (byte >>> 2) & 0x01;
3733
+ }
3734
+ if (rem > 3) {
3735
+ value[outIdx++] = (byte >>> 3) & 0x01;
3736
+ }
3737
+ if (rem > 4) {
3738
+ value[outIdx++] = (byte >>> 4) & 0x01;
3739
+ }
3740
+ if (rem > 5) {
3741
+ value[outIdx++] = (byte >>> 5) & 0x01;
3742
+ }
3743
+ if (rem > 6) {
3744
+ value[outIdx++] = (byte >>> 6) & 0x01;
3745
+ }
3277
3746
  }
3278
3747
  try {
3279
3748
  if (model.writeMultipleCoils) {
@@ -3311,8 +3780,10 @@ class ModbusSlave extends EventEmitter {
3311
3780
  return;
3312
3781
  }
3313
3782
  const value = new Array(length);
3783
+ let off = 5;
3314
3784
  for (let i = 0; i < length; i++) {
3315
- value[i] = (frame.data[5 + i * 2] << 8) | frame.data[6 + i * 2];
3785
+ value[i] = (frame.data[off] << 8) | frame.data[off + 1];
3786
+ off += 2;
3316
3787
  }
3317
3788
  try {
3318
3789
  if (model.writeMultipleRegisters) {
@@ -3339,21 +3810,41 @@ class ModbusSlave extends EventEmitter {
3339
3810
  return;
3340
3811
  }
3341
3812
  try {
3342
- const { serverId = [model.unit ?? 1], runIndicatorStatus = true, additionalData = [] } = await model.reportServerId();
3343
- const serverIdBytes = serverId;
3344
- const byteCount = serverIdBytes.length + 1 + additionalData.length;
3813
+ const result = await model.reportServerId();
3814
+ const sid = result.serverId;
3815
+ const extra = result.additionalData;
3816
+ const sidLen = sid?.length ?? 1;
3817
+ const extraLen = extra?.length ?? 0;
3818
+ const byteCount = sidLen + 1 + extraLen;
3345
3819
  if (byteCount > 255) {
3346
3820
  await this.responseError(appLayer, frame, response, getErrorByCode(ErrorCode.SERVER_DEVICE_FAILURE));
3347
3821
  return;
3348
3822
  }
3349
- const allBytes = [...serverIdBytes, runIndicatorStatus ? 0xff : 0x00, ...additionalData];
3350
- if (allBytes.some((b) => !isUint8(b))) {
3351
- await this.responseError(appLayer, frame, response, getErrorByCode(ErrorCode.SERVER_DEVICE_FAILURE));
3352
- return;
3353
- }
3354
3823
  const data = Buffer.allocUnsafe(byteCount + 1);
3355
3824
  data[0] = byteCount;
3356
- data.set(allBytes, 1);
3825
+ let off = 1;
3826
+ if (sid) {
3827
+ for (let i = 0; i < sidLen; i++) {
3828
+ const b = sid[i];
3829
+ if ((b & 0xff) !== b) {
3830
+ await this.responseError(appLayer, frame, response, getErrorByCode(ErrorCode.SERVER_DEVICE_FAILURE));
3831
+ return;
3832
+ }
3833
+ data[off++] = b;
3834
+ }
3835
+ }
3836
+ else {
3837
+ const unitId = model.unit ?? 1;
3838
+ if ((unitId & 0xff) !== unitId) {
3839
+ await this.responseError(appLayer, frame, response, getErrorByCode(ErrorCode.SERVER_DEVICE_FAILURE));
3840
+ return;
3841
+ }
3842
+ data[off++] = unitId;
3843
+ }
3844
+ data[off++] = (result.runIndicatorStatus ?? true) ? 0xff : 0x00;
3845
+ if (extra) {
3846
+ extra.copy(data, off);
3847
+ }
3357
3848
  await response(appLayer.encode(frame.unit, frame.fc, data, frame.transaction));
3358
3849
  }
3359
3850
  catch (error) {
@@ -3423,8 +3914,10 @@ class ModbusSlave extends EventEmitter {
3423
3914
  return;
3424
3915
  }
3425
3916
  const value = new Array(length.write);
3917
+ let off = 9;
3426
3918
  for (let i = 0; i < length.write; i++) {
3427
- value[i] = (frame.data[9 + i * 2] << 8) | frame.data[10 + i * 2];
3919
+ value[i] = (frame.data[off] << 8) | frame.data[off + 1];
3920
+ off += 2;
3428
3921
  }
3429
3922
  try {
3430
3923
  await this._withIntervalLock(address.write, address.write + length.write, async () => {
@@ -3441,11 +3934,12 @@ class ModbusSlave extends EventEmitter {
3441
3934
  const pdu = Buffer.allocUnsafe(length.read * 2 + 1);
3442
3935
  pdu[0] = length.read * 2;
3443
3936
  // Inline big-endian write — see handleFC3 for the rationale.
3937
+ let off = 1;
3444
3938
  for (let i = 0; i < length.read; i++) {
3445
3939
  const v = registers[i];
3446
- const off = 1 + i * 2;
3447
3940
  pdu[off] = (v >>> 8) & 0xff;
3448
3941
  pdu[off + 1] = v & 0xff;
3942
+ off += 2;
3449
3943
  }
3450
3944
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
3451
3945
  }
@@ -3677,8 +4171,9 @@ class ModbusSlave extends EventEmitter {
3677
4171
  for (let i = 0; i < locks.length; i++) {
3678
4172
  const l = locks[i];
3679
4173
  if (l.lo < hi && lo < l.hi) {
3680
- if (overlap === null)
4174
+ if (overlap === null) {
3681
4175
  overlap = [];
4176
+ }
3682
4177
  overlap.push(l.promise);
3683
4178
  }
3684
4179
  }
@@ -3714,8 +4209,9 @@ class ModbusSlave extends EventEmitter {
3714
4209
  if (i !== -1) {
3715
4210
  // O(1) swap-and-pop since lock order doesn't matter for correctness.
3716
4211
  const last = locks.length - 1;
3717
- if (i !== last)
4212
+ if (i !== last) {
3718
4213
  locks[i] = locks[last];
4214
+ }
3719
4215
  locks.pop();
3720
4216
  }
3721
4217
  }