njs-modbus 3.1.1 → 3.2.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.
Files changed (44) hide show
  1. package/README.md +20 -0
  2. package/README.zh-CN.md +20 -0
  3. package/dist/index.cjs +878 -384
  4. package/dist/index.d.ts +87 -25
  5. package/dist/index.mjs +878 -385
  6. package/dist/utils.cjs +439 -0
  7. package/dist/utils.d.ts +161 -0
  8. package/dist/utils.mjs +425 -0
  9. package/package.json +15 -2
  10. package/dist/src/error-code.d.ts +0 -17
  11. package/dist/src/index.d.ts +0 -7
  12. package/dist/src/layers/application/abstract-application-layer.d.ts +0 -26
  13. package/dist/src/layers/application/ascii-application-layer.d.ts +0 -23
  14. package/dist/src/layers/application/index.d.ts +0 -6
  15. package/dist/src/layers/application/rtu-application-layer.d.ts +0 -34
  16. package/dist/src/layers/application/tcp-application-layer.d.ts +0 -16
  17. package/dist/src/layers/physical/abstract-physical-layer.d.ts +0 -50
  18. package/dist/src/layers/physical/index.d.ts +0 -12
  19. package/dist/src/layers/physical/serial-physical-layer.d.ts +0 -70
  20. package/dist/src/layers/physical/tcp-client-physical-layer.d.ts +0 -20
  21. package/dist/src/layers/physical/tcp-physical-connection.d.ts +0 -16
  22. package/dist/src/layers/physical/tcp-server-physical-layer.d.ts +0 -29
  23. package/dist/src/layers/physical/udp-client-physical-layer.d.ts +0 -34
  24. package/dist/src/layers/physical/udp-server-physical-layer.d.ts +0 -51
  25. package/dist/src/layers/physical/utils.d.ts +0 -39
  26. package/dist/src/layers/physical/vars.d.ts +0 -11
  27. package/dist/src/master/index.d.ts +0 -3
  28. package/dist/src/master/master-session.d.ts +0 -18
  29. package/dist/src/master/master.d.ts +0 -140
  30. package/dist/src/slave/index.d.ts +0 -2
  31. package/dist/src/slave/slave.d.ts +0 -119
  32. package/dist/src/types.d.ts +0 -54
  33. package/dist/src/utils/bitsToMs.d.ts +0 -13
  34. package/dist/src/utils/callback.d.ts +0 -8
  35. package/dist/src/utils/checkRange.d.ts +0 -1
  36. package/dist/src/utils/crc.d.ts +0 -1
  37. package/dist/src/utils/index.d.ts +0 -11
  38. package/dist/src/utils/isUint8.d.ts +0 -8
  39. package/dist/src/utils/lrc.d.ts +0 -1
  40. package/dist/src/utils/predictRtuFrameLength.d.ts +0 -17
  41. package/dist/src/utils/promisify-cb.d.ts +0 -4
  42. package/dist/src/utils/rtu-timing.d.ts +0 -63
  43. package/dist/src/utils/whitelist.d.ts +0 -11
  44. package/dist/src/vars.d.ts +0 -49
package/dist/index.cjs CHANGED
@@ -91,6 +91,10 @@ exports.ConformityLevel = void 0;
91
91
  })(exports.ConformityLevel || (exports.ConformityLevel = {}));
92
92
  /** Shared empty Buffer to avoid repeated allocations. */
93
93
  const EMPTY_BUFFER = Buffer.alloc(0);
94
+ /** Shared no-op function to avoid repeated allocations. */
95
+ const NOOP = () => {
96
+ /* no-op */
97
+ };
94
98
  /** Modbus V1.1b3 PDU quantity limits. */
95
99
  const LIMITS = {
96
100
  READ_COILS_MIN: 0x0001,
@@ -232,8 +236,12 @@ function isUint8(n) {
232
236
  return Number.isInteger(n) && n >= 0 && n <= 255;
233
237
  }
234
238
 
235
- function lrc(data) {
236
- return (~data.reduce((sum, n) => sum + n, 0) + 1) & 0xff;
239
+ function lrc(data, start = 0, end = data.length) {
240
+ let sum = 0;
241
+ for (let i = start; i < end; i++) {
242
+ sum += data[i];
243
+ }
244
+ return (~sum + 1) & 0xff;
237
245
  }
238
246
 
239
247
  const REQUEST_FIXED_LENGTHS = {
@@ -283,11 +291,11 @@ const PREDICT_UNKNOWN = -1;
283
291
  * the framing layer must defer to a registered `CustomFunctionCode` or treat
284
292
  * this as a framing error.
285
293
  */
286
- function predictRtuFrameLength(buffer, isResponse) {
287
- if (buffer.length < 2) {
294
+ function predictRtuFrameLength(buffer, start, end, isResponse) {
295
+ if (end - start < 2) {
288
296
  return PREDICT_NEED_MORE;
289
297
  }
290
- const fc = buffer[1];
298
+ const fc = buffer[start + 1];
291
299
  if (isResponse && (fc & EXCEPTION_OFFSET) !== 0) {
292
300
  return 5;
293
301
  }
@@ -297,13 +305,13 @@ function predictRtuFrameLength(buffer, isResponse) {
297
305
  }
298
306
  const bc = (isResponse ? RESPONSE_BYTE_COUNT : REQUEST_BYTE_COUNT)[fc];
299
307
  if (bc !== undefined) {
300
- if (buffer.length <= bc.offset) {
308
+ if (end - start <= bc.offset) {
301
309
  return PREDICT_NEED_MORE;
302
310
  }
303
- return bc.extra + buffer[bc.offset];
311
+ return bc.extra + buffer[start + bc.offset];
304
312
  }
305
313
  if (isResponse && fc === exports.FunctionCode.READ_DEVICE_IDENTIFICATION) {
306
- return predictFc43_14Response(buffer);
314
+ return predictFc43_14Response(buffer, start, end);
307
315
  }
308
316
  return PREDICT_UNKNOWN;
309
317
  }
@@ -316,23 +324,23 @@ function predictRtuFrameLength(buffer, isResponse) {
316
324
  * [objId(1) objLen(1) objData(objLen)] × numObjs
317
325
  * CRC(2)
318
326
  */
319
- function predictFc43_14Response(buffer) {
320
- if (buffer.length < 8) {
327
+ function predictFc43_14Response(buffer, start, end) {
328
+ if (end - start < 8) {
321
329
  return PREDICT_NEED_MORE;
322
330
  }
323
- if (buffer[2] !== MEI_READ_DEVICE_ID) {
331
+ if (buffer[start + 2] !== MEI_READ_DEVICE_ID) {
324
332
  return PREDICT_UNKNOWN;
325
333
  }
326
- const numObjs = buffer[7];
327
- let offset = 8;
334
+ const numObjs = buffer[start + 7];
335
+ let cursor = start + 8;
328
336
  for (let i = 0; i < numObjs; i++) {
329
- if (buffer.length < offset + 2) {
337
+ if (end < cursor + 2) {
330
338
  return PREDICT_NEED_MORE;
331
339
  }
332
- const objLen = buffer[offset + 1];
333
- offset += 2 + objLen;
340
+ const objLen = buffer[cursor + 1];
341
+ cursor += 2 + objLen;
334
342
  }
335
- return offset + 2;
343
+ return cursor - start + 2;
336
344
  }
337
345
 
338
346
  /**
@@ -397,6 +405,107 @@ function resolveRtuTiming(opts = {}, baudRate) {
397
405
  return { intervalBetweenFrames, interCharTimeout };
398
406
  }
399
407
 
408
+ /** @internal
409
+ * Zero-allocation binary min-heap for coalescing per-request timeouts.
410
+ *
411
+ * Uses two parallel numeric arrays (no object allocation per entry).
412
+ * Lazy deletion: callers never remove from the heap; expired entries
413
+ * are silently dropped when they surface at the top.
414
+ */
415
+ class TimerHeap {
416
+ _deadlines = [];
417
+ _ids = [];
418
+ _timer = null;
419
+ _onFire;
420
+ /** Pre-bound tick handler to avoid creating a new arrow function on every setTimeout. */
421
+ _boundTick;
422
+ constructor(onFire) {
423
+ this._onFire = onFire;
424
+ this._boundTick = this._onTick.bind(this);
425
+ }
426
+ /** Number of pending timers in the heap. */
427
+ get size() {
428
+ return this._deadlines.length;
429
+ }
430
+ /** Schedule an entry. If it becomes the new top, the global timer is re-scheduled (pre-emption). */
431
+ add(id, ms) {
432
+ const deadline = performance.now() + ms;
433
+ let i = this._deadlines.length;
434
+ this._deadlines.push(deadline);
435
+ this._ids.push(id);
436
+ // sift up
437
+ while (i > 0) {
438
+ const p = (i - 1) >> 1;
439
+ if (this._deadlines[p] <= deadline)
440
+ break;
441
+ this._deadlines[i] = this._deadlines[p];
442
+ this._ids[i] = this._ids[p];
443
+ i = p;
444
+ }
445
+ this._deadlines[i] = deadline;
446
+ this._ids[i] = id;
447
+ // Only reschedule when the new entry became the heap top.
448
+ if (i === 0)
449
+ this._refresh();
450
+ }
451
+ /** Dispose without firing callbacks. */
452
+ clear() {
453
+ if (this._timer) {
454
+ clearTimeout(this._timer);
455
+ this._timer = null;
456
+ }
457
+ this._deadlines.length = 0;
458
+ this._ids.length = 0;
459
+ }
460
+ _refresh() {
461
+ if (this._timer) {
462
+ clearTimeout(this._timer);
463
+ this._timer = null;
464
+ }
465
+ if (this._deadlines.length === 0)
466
+ return;
467
+ const delay = Math.max(0, Math.ceil(this._deadlines[0] - performance.now()));
468
+ this._timer = setTimeout(this._boundTick, delay);
469
+ }
470
+ _onTick() {
471
+ this._timer = null;
472
+ const now = performance.now();
473
+ while (this._deadlines.length > 0 && this._deadlines[0] <= now) {
474
+ const id = this._pop();
475
+ this._onFire(id);
476
+ }
477
+ this._refresh();
478
+ }
479
+ /** Pop and return the id with the earliest deadline. Assumes heap is non-empty. */
480
+ _pop() {
481
+ const topId = this._ids[0];
482
+ const lastId = this._ids.pop();
483
+ const lastDeadline = this._deadlines.pop();
484
+ const n = this._deadlines.length;
485
+ if (n > 0) {
486
+ let i = 0;
487
+ // sift down
488
+ while (true) {
489
+ let min = i;
490
+ const l = i * 2 + 1;
491
+ const r = l + 1;
492
+ if (l < n && this._deadlines[l] < this._deadlines[min])
493
+ min = l;
494
+ if (r < n && this._deadlines[r] < this._deadlines[min])
495
+ min = r;
496
+ if (min === i)
497
+ break;
498
+ this._deadlines[i] = this._deadlines[min];
499
+ this._ids[i] = this._ids[min];
500
+ i = min;
501
+ }
502
+ this._deadlines[i] = lastDeadline;
503
+ this._ids[i] = lastId;
504
+ }
505
+ return topId;
506
+ }
507
+ }
508
+
400
509
  /**
401
510
  * Normalize an IP address by stripping the IPv4-mapped IPv6 prefix.
402
511
  * This ensures consistent comparison of addresses like `::ffff:192.168.1.1`.
@@ -1341,14 +1450,16 @@ function createPhysicalLayer(config) {
1341
1450
  * established and discarded when the connection closes. Subclasses implement
1342
1451
  * ASCII, RTU, or TCP framing rules.
1343
1452
  */
1344
- class AbstractApplicationLayer extends EventEmitter {
1453
+ class AbstractApplicationLayer {
1454
+ /** Called when a complete frame is decoded. Defaults to no-op. */
1455
+ onFraming = NOOP;
1456
+ /** Called when a framing error is detected. Defaults to no-op. */
1457
+ onFramingError = NOOP;
1345
1458
  flush() {
1346
1459
  // no-op — override in subclasses
1347
1460
  }
1348
- addCustomFunctionCode(cfc) {
1349
- }
1350
- removeCustomFunctionCode(fc) {
1351
- }
1461
+ addCustomFunctionCode(cfc) { }
1462
+ removeCustomFunctionCode(fc) { }
1352
1463
  }
1353
1464
 
1354
1465
  const MAX_FRAME_LENGTH = 256;
@@ -1362,7 +1473,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1362
1473
  _threePointFiveT;
1363
1474
  _onePointFiveT;
1364
1475
  _customFunctionCodes = new Map();
1365
- _cleanupFns = new Set();
1476
+ _cleanupCbs = [];
1366
1477
  get connection() {
1367
1478
  return this._connection;
1368
1479
  }
@@ -1378,7 +1489,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1378
1489
  const onData = (data) => {
1379
1490
  const state = this._state;
1380
1491
  if (state.t15Expired && state.end > state.start) {
1381
- this.emit('framing-error', new Error('Inter-character timeout (t1.5) exceeded'));
1492
+ this.onFramingError(new Error('Inter-character timeout (t1.5) exceeded'));
1382
1493
  state.start = 0;
1383
1494
  state.end = 0;
1384
1495
  }
@@ -1420,7 +1531,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1420
1531
  // flushBuffer freed nothing — the entire pool is unparseable
1421
1532
  // residue (typically a misconfigured poolSize for the wire's
1422
1533
  // frame size). Hard reset; we cannot recover automatically.
1423
- this.emit('framing-error', new Error('Frame buffer exhausted before complete frame received'));
1534
+ this.onFramingError(new Error('Frame buffer exhausted before complete frame received'));
1424
1535
  currentState.start = 0;
1425
1536
  currentState.end = 0;
1426
1537
  currentState.t15Expired = false;
@@ -1456,16 +1567,16 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1456
1567
  }
1457
1568
  };
1458
1569
  connection.on('data', onData);
1459
- this._cleanupFns.add(() => connection.off('data', onData));
1570
+ this._cleanupCbs.push(() => connection.off('data', onData));
1460
1571
  const onClose = () => {
1461
- for (const fn of this._cleanupFns) {
1572
+ for (const fn of this._cleanupCbs) {
1462
1573
  fn();
1463
1574
  }
1464
- this._cleanupFns.clear();
1575
+ this._cleanupCbs.length = 0;
1465
1576
  this.clearStateTimers();
1466
1577
  };
1467
1578
  connection.on('close', onClose);
1468
- this._cleanupFns.add(() => connection.off('close', onClose));
1579
+ this._cleanupCbs.push(() => connection.off('close', onClose));
1469
1580
  }
1470
1581
  clearStateTimers() {
1471
1582
  const state = this._state;
@@ -1478,34 +1589,38 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1478
1589
  state.interCharTimer = undefined;
1479
1590
  }
1480
1591
  }
1592
+ /**
1593
+ * Shared handler for every "frame is not yet complete" exit in `flushBuffer`.
1594
+ * Returns `true` when the caller should `return` (strict reset), `false` to
1595
+ * `break` the parse loop. Hot path never reaches here — only error/incomplete
1596
+ * edges. Extracted as a method so it is not recreated on every `flushBuffer`
1597
+ * call.
1598
+ */
1599
+ _handleIncomplete(state, strict) {
1600
+ if (strict) {
1601
+ this.onFramingError(new Error(state.t15Expired ? 'Inter-character timeout (t1.5) exceeded' : 'Incomplete frame at t3.5'));
1602
+ state.start = 0;
1603
+ state.end = 0;
1604
+ state.t15Expired = false;
1605
+ return true;
1606
+ }
1607
+ if (state.t15Expired) {
1608
+ this.onFramingError(new Error('Inter-character timeout (t1.5) exceeded'));
1609
+ state.start = 0;
1610
+ state.end = 0;
1611
+ state.t15Expired = false;
1612
+ }
1613
+ return false;
1614
+ }
1481
1615
  flushBuffer(strict) {
1482
1616
  const state = this._state;
1483
1617
  const isResponse = this.ROLE === 'MASTER';
1484
1618
  const pool = state.pool;
1485
1619
  const customFCs = this._customFunctionCodes;
1486
- // Shared handler for every "frame is not yet complete" exit. Returns true
1487
- // when the caller should `return` (strict reset), false to `break` the
1488
- // parse loop. Hot path never reaches here — only error/incomplete edges.
1489
- const handleIncomplete = () => {
1490
- if (strict) {
1491
- this.emit('framing-error', new Error(state.t15Expired ? 'Inter-character timeout (t1.5) exceeded' : 'Incomplete frame at t3.5'));
1492
- state.start = 0;
1493
- state.end = 0;
1494
- state.t15Expired = false;
1495
- return true;
1496
- }
1497
- if (state.t15Expired) {
1498
- this.emit('framing-error', new Error('Inter-character timeout (t1.5) exceeded'));
1499
- state.start = 0;
1500
- state.end = 0;
1501
- state.t15Expired = false;
1502
- }
1503
- return false;
1504
- };
1505
1620
  while (state.end - state.start > 0) {
1506
1621
  const available = state.end - state.start;
1507
1622
  if (available < MIN_FRAME_LENGTH) {
1508
- if (handleIncomplete())
1623
+ if (this._handleIncomplete(state, strict))
1509
1624
  return;
1510
1625
  break;
1511
1626
  }
@@ -1514,7 +1629,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1514
1629
  let expected;
1515
1630
  if (cfc) {
1516
1631
  const predictor = isResponse ? cfc.predictResponseLength : cfc.predictRequestLength;
1517
- const predicted = predictor(pool.subarray(state.start, state.end));
1632
+ const predicted = predictor(pool, state.start, state.end);
1518
1633
  // Normalize custom predictor's `null` to the std sentinel so both
1519
1634
  // paths share the same NEED_MORE tail below.
1520
1635
  expected = predicted ?? PREDICT_NEED_MORE;
@@ -1522,9 +1637,9 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1522
1637
  else {
1523
1638
  // Standard FC path. predictRtuFrameLength uses sentinel returns to
1524
1639
  // avoid per-call object allocation on the decode hot path.
1525
- expected = predictRtuFrameLength(pool.subarray(state.start, state.end), isResponse);
1640
+ expected = predictRtuFrameLength(pool, state.start, state.end, isResponse);
1526
1641
  if (expected === PREDICT_UNKNOWN) {
1527
- this.emit('framing-error', new Error(`Unknown function code 0x${fc.toString(16).padStart(2, '0')} — register a CustomFunctionCode to frame this FC`));
1642
+ this.onFramingError(new Error(`Unknown function code 0x${fc.toString(16).padStart(2, '0')} — register a CustomFunctionCode to frame this FC`));
1528
1643
  state.start = 0;
1529
1644
  state.end = 0;
1530
1645
  return;
@@ -1535,12 +1650,12 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1535
1650
  state.start += 1;
1536
1651
  continue;
1537
1652
  }
1538
- if (handleIncomplete())
1653
+ if (this._handleIncomplete(state, strict))
1539
1654
  return;
1540
1655
  break;
1541
1656
  }
1542
1657
  if (expected > MAX_FRAME_LENGTH || expected < MIN_FRAME_LENGTH) {
1543
- this.emit('framing-error', new Error('Invalid data'));
1658
+ this.onFramingError(new Error('Invalid data'));
1544
1659
  state.start = 0;
1545
1660
  state.end = 0;
1546
1661
  return;
@@ -1550,7 +1665,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1550
1665
  state.start += 1;
1551
1666
  continue;
1552
1667
  }
1553
- if (handleIncomplete())
1668
+ if (this._handleIncomplete(state, strict))
1554
1669
  return;
1555
1670
  break;
1556
1671
  }
@@ -1561,7 +1676,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1561
1676
  const actualCrc = crc(pool, crcStart, crcEnd);
1562
1677
  if (expectedCrc !== actualCrc) {
1563
1678
  if (strict) {
1564
- this.emit('framing-error', new Error('CRC mismatch'));
1679
+ this.onFramingError(new Error('CRC mismatch'));
1565
1680
  state.start = 0;
1566
1681
  state.end = 0;
1567
1682
  state.t15Expired = false;
@@ -1582,7 +1697,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1582
1697
  data: frameBuf.subarray(2, expected - 2),
1583
1698
  buffer: frameBuf,
1584
1699
  };
1585
- this.emit('framing', frame);
1700
+ this.onFraming(frame);
1586
1701
  }
1587
1702
  if (state.start > 0) {
1588
1703
  if (state.start < state.end) {
@@ -1609,11 +1724,21 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1609
1724
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1610
1725
  encode(unit, fc, data, transaction) {
1611
1726
  const buffer = Buffer.allocUnsafe(data.length + 4);
1612
- buffer.writeUInt8(unit, 0);
1613
- buffer.writeUInt8(fc, 1);
1614
- buffer.set(data, 2);
1727
+ // Inline header — direct typed-array stores skip Buffer's per-call checks.
1728
+ buffer[0] = unit;
1729
+ buffer[1] = fc;
1730
+ if (data.length <= 16) {
1731
+ for (let i = 0; i < data.length; i++)
1732
+ buffer[2 + i] = data[i];
1733
+ }
1734
+ else {
1735
+ buffer.set(data, 2);
1736
+ }
1615
1737
  const crcEnd = buffer.length - 2;
1616
- buffer.writeUInt16LE(crc(buffer, 0, crcEnd), crcEnd);
1738
+ const c = crc(buffer, 0, crcEnd);
1739
+ // Little-endian inline write of CRC trailer.
1740
+ buffer[crcEnd] = c & 0xff;
1741
+ buffer[crcEnd + 1] = (c >>> 8) & 0xff;
1617
1742
  return buffer;
1618
1743
  }
1619
1744
  }
@@ -1644,8 +1769,8 @@ class AsciiApplicationLayer extends AbstractApplicationLayer {
1644
1769
  ROLE;
1645
1770
  lenientHex;
1646
1771
  _connection;
1647
- _state = { status: 'idle', frame: [] };
1648
- _cleanupFns = new Set();
1772
+ _state = { status: 'idle', frame: new Uint8Array(MAX_ASCII_PAYLOAD), frameLen: 0 };
1773
+ _cleanupCbs = [];
1649
1774
  get connection() {
1650
1775
  return this._connection;
1651
1776
  }
@@ -1669,108 +1794,138 @@ class AsciiApplicationLayer extends AbstractApplicationLayer {
1669
1794
  };
1670
1795
  const onData = (data) => {
1671
1796
  const state = this._state;
1672
- data.forEach((value) => {
1797
+ for (let i = 0; i < data.length; i++) {
1798
+ const value = data[i];
1673
1799
  switch (state.status) {
1674
1800
  case 'idle': {
1675
1801
  if (value === CHAR_CODE.COLON) {
1676
1802
  state.status = 'reception';
1677
- state.frame = [];
1803
+ state.frameLen = 0;
1678
1804
  }
1679
1805
  break;
1680
1806
  }
1681
1807
  case 'reception': {
1682
1808
  if (value === CHAR_CODE.COLON) {
1683
- state.frame = [];
1809
+ state.frameLen = 0;
1684
1810
  }
1685
1811
  else if (value === CHAR_CODE.CR) {
1686
1812
  state.status = 'waiting end';
1687
1813
  }
1688
- else if (state.frame.length >= MAX_ASCII_PAYLOAD) {
1814
+ else if (state.frameLen >= MAX_ASCII_PAYLOAD) {
1689
1815
  state.status = 'idle';
1690
- state.frame = [];
1691
- this.emit('framing-error', new Error('Invalid data'));
1816
+ state.frameLen = 0;
1817
+ this.onFramingError(new Error('Invalid data'));
1692
1818
  }
1693
1819
  else if (!isHexChar(value)) {
1694
1820
  state.status = 'idle';
1695
- state.frame = [];
1696
- this.emit('framing-error', new Error('Invalid hex character'));
1821
+ state.frameLen = 0;
1822
+ this.onFramingError(new Error('Invalid hex character'));
1697
1823
  }
1698
1824
  else {
1699
- state.frame.push(value);
1825
+ state.frame[state.frameLen++] = value;
1700
1826
  }
1701
1827
  break;
1702
1828
  }
1703
1829
  case 'waiting end': {
1704
1830
  if (value === CHAR_CODE.COLON) {
1705
1831
  state.status = 'reception';
1706
- state.frame = [];
1832
+ state.frameLen = 0;
1707
1833
  }
1708
1834
  else {
1709
1835
  state.status = 'idle';
1710
1836
  if (value === CHAR_CODE.LF) {
1711
- this.framing(Buffer.from(state.frame));
1837
+ this.framing(state.frame, state.frameLen);
1712
1838
  }
1713
1839
  }
1714
1840
  break;
1715
1841
  }
1716
1842
  }
1717
- });
1843
+ }
1718
1844
  };
1719
1845
  connection.on('data', onData);
1720
- this._cleanupFns.add(() => connection.off('data', onData));
1846
+ this._cleanupCbs.push(() => connection.off('data', onData));
1721
1847
  const onClose = () => {
1722
- for (const fn of this._cleanupFns) {
1848
+ for (const fn of this._cleanupCbs) {
1723
1849
  fn();
1724
1850
  }
1725
- this._cleanupFns.clear();
1851
+ this._cleanupCbs.length = 0;
1726
1852
  };
1727
1853
  connection.on('close', onClose);
1728
- this._cleanupFns.add(() => connection.off('close', onClose));
1854
+ this._cleanupCbs.push(() => connection.off('close', onClose));
1729
1855
  }
1730
- framing(hexChars) {
1731
- if (hexChars.length < 6) {
1732
- this.emit('framing-error', new Error('Insufficient data length'));
1856
+ framing(hexChars, hexLen) {
1857
+ if (hexLen < 6) {
1858
+ this.onFramingError(new Error('Insufficient data length'));
1859
+ return;
1860
+ }
1861
+ if (hexLen % 2 !== 0) {
1862
+ this.onFramingError(new Error('Invalid data'));
1863
+ return;
1864
+ }
1865
+ const byteLen = hexLen >> 1;
1866
+ // Decode unit and fc directly from the first 4 hex characters —
1867
+ // avoids allocating a full decoded buffer just to read two bytes.
1868
+ const unitHi = HEX_DECODE[hexChars[0]];
1869
+ const unitLo = HEX_DECODE[hexChars[1]];
1870
+ const fcHi = HEX_DECODE[hexChars[2]];
1871
+ const fcLo = HEX_DECODE[hexChars[3]];
1872
+ if (unitHi === 0xff || unitLo === 0xff || fcHi === 0xff || fcLo === 0xff) {
1873
+ this.onFramingError(new Error('Invalid hex character'));
1733
1874
  return;
1734
1875
  }
1735
- if (hexChars.length % 2 !== 0) {
1736
- this.emit('framing-error', new Error('Invalid data'));
1876
+ const unit = (unitHi << 4) | unitLo;
1877
+ const fc = (fcHi << 4) | fcLo;
1878
+ // Decode LRC from the last 2 hex characters.
1879
+ const lrcHi = HEX_DECODE[hexChars[hexLen - 2]];
1880
+ const lrcLo = HEX_DECODE[hexChars[hexLen - 1]];
1881
+ if (lrcHi === 0xff || lrcLo === 0xff) {
1882
+ this.onFramingError(new Error('Invalid hex character'));
1737
1883
  return;
1738
1884
  }
1739
- const decoded = Buffer.allocUnsafe(hexChars.length / 2);
1740
- for (let i = 0; i < hexChars.length; i += 2) {
1741
- const hi = HEX_DECODE[hexChars[i]];
1742
- const lo = HEX_DECODE[hexChars[i + 1]];
1743
- // Defensive: the FSM should already have filtered non-hex characters,
1744
- // but guard here in case framing is ever called directly.
1885
+ const lrcIn = (lrcHi << 4) | lrcLo;
1886
+ // Decode data portion (between unit/fc and lrc) into a right-sized buffer.
1887
+ // dataLen may be 0 for a frame that is only unit + fc + lrc.
1888
+ const dataLen = byteLen - 3;
1889
+ const data = Buffer.allocUnsafe(dataLen);
1890
+ for (let i = 0; i < dataLen; i++) {
1891
+ const hi = HEX_DECODE[hexChars[4 + i * 2]];
1892
+ const lo = HEX_DECODE[hexChars[4 + i * 2 + 1]];
1745
1893
  if (hi === 0xff || lo === 0xff) {
1746
- this.emit('framing-error', new Error('Invalid hex character'));
1894
+ this.onFramingError(new Error('Invalid hex character'));
1747
1895
  return;
1748
1896
  }
1749
- decoded[i / 2] = (hi << 4) | lo;
1897
+ data[i] = (hi << 4) | lo;
1750
1898
  }
1751
- const frame = {
1752
- unit: decoded[0],
1753
- fc: decoded[1],
1754
- data: decoded.subarray(2, decoded.length - 1),
1755
- buffer: hexChars,
1756
- };
1757
- const lrcPassed = decoded[decoded.length - 1] === lrc(decoded.subarray(0, decoded.length - 1));
1758
- if (!lrcPassed) {
1759
- this.emit('framing-error', new Error('LRC check failed'));
1899
+ // Compute LRC over unit + fc + data.
1900
+ let sum = unit + fc;
1901
+ for (let i = 0; i < dataLen; i++) {
1902
+ sum += data[i];
1903
+ }
1904
+ const lrcComputed = (~sum + 1) & 0xff;
1905
+ if (lrcIn !== lrcComputed) {
1906
+ this.onFramingError(new Error('LRC check failed'));
1760
1907
  return;
1761
1908
  }
1762
- this.emit('framing', frame);
1909
+ const frame = {
1910
+ unit,
1911
+ fc,
1912
+ data,
1913
+ buffer: Buffer.copyBytesFrom(hexChars, 0, hexLen),
1914
+ };
1915
+ this.onFraming(frame);
1763
1916
  }
1764
1917
  flush() {
1765
- this._state = { status: 'idle', frame: [] };
1918
+ this._state.status = 'idle';
1919
+ this._state.frameLen = 0;
1766
1920
  }
1767
1921
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1768
1922
  encode(unit, fc, data, transaction) {
1769
1923
  const buffer = Buffer.allocUnsafe(data.length + 3);
1770
- buffer.writeUInt8(unit, 0);
1771
- buffer.writeUInt8(fc, 1);
1924
+ // Inline header + LRC — direct typed-array stores skip Buffer's per-call checks.
1925
+ buffer[0] = unit;
1926
+ buffer[1] = fc;
1772
1927
  buffer.set(data, 2);
1773
- buffer.writeUInt8(lrc(buffer.subarray(0, -1)), buffer.length - 1);
1928
+ buffer[buffer.length - 1] = lrc(buffer.subarray(0, -1));
1774
1929
  const out = Buffer.allocUnsafe(1 + buffer.length * 2 + 2);
1775
1930
  out[0] = CHAR_CODE.COLON;
1776
1931
  for (let i = 0; i < buffer.length; i++) {
@@ -1791,7 +1946,7 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1791
1946
  _connection;
1792
1947
  _transactionId = 1;
1793
1948
  _buffer = EMPTY_BUFFER;
1794
- _cleanupFns = new Set();
1949
+ _cleanupCbs = [];
1795
1950
  get connection() {
1796
1951
  return this._connection;
1797
1952
  }
@@ -1800,6 +1955,17 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1800
1955
  this.ROLE = role;
1801
1956
  this._connection = connection;
1802
1957
  const onData = (data) => {
1958
+ // Fast path: _buffer is empty and data is a single, complete frame.
1959
+ // This avoids the tryExtract subarray allocations + while loop
1960
+ // for the overwhelmingly common case (one frame per TCP packet).
1961
+ if (this._buffer.length === 0 && data.length >= 8) {
1962
+ const length = (data[4] << 8) | data[5]; // inline BE read
1963
+ const total = 6 + length;
1964
+ if (data[2] === 0 && data[3] === 0 && total <= MAX_TCP_FRAME && length >= 2 && data.length === total) {
1965
+ this.processFrame(data);
1966
+ return;
1967
+ }
1968
+ }
1803
1969
  let buffer = this._buffer;
1804
1970
  if (buffer.length === 0) {
1805
1971
  buffer = data;
@@ -1817,7 +1983,7 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1817
1983
  break;
1818
1984
  }
1819
1985
  else {
1820
- this.emit('framing-error', result.error);
1986
+ this.onFramingError(result.error);
1821
1987
  buffer = EMPTY_BUFFER;
1822
1988
  break;
1823
1989
  }
@@ -1837,15 +2003,15 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1837
2003
  }
1838
2004
  };
1839
2005
  connection.on('data', onData);
1840
- this._cleanupFns.add(() => connection.off('data', onData));
2006
+ this._cleanupCbs.push(() => connection.off('data', onData));
1841
2007
  const onClose = () => {
1842
- for (const fn of this._cleanupFns) {
2008
+ for (const fn of this._cleanupCbs) {
1843
2009
  fn();
1844
2010
  }
1845
- this._cleanupFns.clear();
2011
+ this._cleanupCbs.length = 0;
1846
2012
  };
1847
2013
  connection.on('close', onClose);
1848
- this._cleanupFns.add(() => connection.off('close', onClose));
2014
+ this._cleanupCbs.push(() => connection.off('close', onClose));
1849
2015
  }
1850
2016
  tryExtract(buffer) {
1851
2017
  if (buffer.length < 8) {
@@ -1854,7 +2020,7 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1854
2020
  if (buffer[2] !== 0 || buffer[3] !== 0) {
1855
2021
  return { kind: 'error', error: new Error('Invalid data') };
1856
2022
  }
1857
- const length = buffer.readUInt16BE(4);
2023
+ const length = (buffer[4] << 8) | buffer[5]; // inline BE read
1858
2024
  const total = 6 + length;
1859
2025
  if (total > MAX_TCP_FRAME || length < 2) {
1860
2026
  return { kind: 'error', error: new Error('Invalid data') };
@@ -1862,29 +2028,47 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1862
2028
  if (buffer.length < total) {
1863
2029
  return { kind: 'insufficient' };
1864
2030
  }
1865
- return { kind: 'frame', frame: buffer.subarray(0, total), rest: buffer.subarray(total) };
2031
+ return { kind: 'frame', frame: buffer.subarray(0, total), rest: total === buffer.length ? EMPTY_BUFFER : buffer.subarray(total) };
1866
2032
  }
1867
2033
  processFrame(buffer) {
1868
2034
  const frame = {
1869
- transaction: buffer.readUInt16BE(0),
2035
+ // Inline 16-bit BE read — direct typed-array loads skip readUInt16BE's
2036
+ // argument coercion + bounds check. Symmetric to the header writes in
2037
+ // encode() above. Hits on every received TCP frame.
2038
+ transaction: (buffer[0] << 8) | buffer[1],
1870
2039
  unit: buffer[6],
1871
2040
  fc: buffer[7],
1872
2041
  data: buffer.subarray(8),
1873
2042
  buffer,
1874
2043
  };
1875
- this.emit('framing', frame);
2044
+ this.onFraming(frame);
1876
2045
  }
1877
2046
  flush() {
1878
2047
  this._buffer = EMPTY_BUFFER;
1879
2048
  }
1880
2049
  encode(unit, fc, data, transaction) {
1881
2050
  const buffer = Buffer.allocUnsafe(data.length + 8);
1882
- buffer.writeUInt16BE(transaction ?? this._transactionId, 0);
1883
- buffer.writeUInt16BE(0, 2);
1884
- buffer.writeUInt16BE(data.length + 2, 4);
1885
- buffer.writeUInt8(unit, 6);
1886
- buffer.writeUInt8(fc, 7);
1887
- buffer.set(data, 8);
2051
+ // Inline big-endian header writes — direct typed-array stores skip the
2052
+ // argument validation + bounds checks that `writeUInt16BE/writeUInt8` run.
2053
+ const tid = transaction ?? this._transactionId;
2054
+ const len = data.length + 2;
2055
+ buffer[0] = (tid >>> 8) & 0xff;
2056
+ buffer[1] = tid & 0xff;
2057
+ buffer[2] = 0;
2058
+ buffer[3] = 0;
2059
+ buffer[4] = (len >>> 8) & 0xff;
2060
+ buffer[5] = len & 0xff;
2061
+ buffer[6] = unit;
2062
+ buffer[7] = fc;
2063
+ // Small-payload fast path: avoid C++ TypedArray.prototype.set boundary
2064
+ // crossing when the copy is just a few bytes (common for FC 3/4/6 requests).
2065
+ if (data.length <= 16) {
2066
+ for (let i = 0; i < data.length; i++)
2067
+ buffer[8 + i] = data[i];
2068
+ }
2069
+ else {
2070
+ buffer.set(data, 8);
2071
+ }
1888
2072
  if (transaction === undefined) {
1889
2073
  this._transactionId = (this._transactionId + 1) % 65536 || 1;
1890
2074
  }
@@ -1963,14 +2147,31 @@ class ModbusMaster extends EventEmitter {
1963
2147
  _queueDatas = [];
1964
2148
  _queueTimeouts = [];
1965
2149
  _queueBroadcasts = [];
1966
- _queueResolves = [];
1967
- _queueRejects = [];
2150
+ _queueCallbacks = [];
1968
2151
  _queueHead = 0;
1969
2152
  _queueLen = 0;
1970
2153
  _draining = false;
1971
2154
  _nextTid = 1;
1972
2155
  _cleanupFns = new Set();
1973
2156
  _closePromise = null;
2157
+ _nextExchangeId = 1;
2158
+ // Global timer heap with lazy deletion — one native setTimeout for all requests.
2159
+ _pendingExchanges = new Map();
2160
+ _timerHeap = new TimerHeap((id) => {
2161
+ const pending = this._pendingExchanges.get(id);
2162
+ if (!pending)
2163
+ return; // lazy deletion: already handled
2164
+ pending.settled = true;
2165
+ this._pendingExchanges.delete(id);
2166
+ if (pending.sessionKey !== null) {
2167
+ this._masterSession.stop(pending.sessionKey);
2168
+ }
2169
+ const cb = pending.callback;
2170
+ if (cb) {
2171
+ pending.callback = null;
2172
+ cb(new Error('Timeout'));
2173
+ }
2174
+ });
1974
2175
  get state() {
1975
2176
  return this._physicalLayer.state;
1976
2177
  }
@@ -1997,17 +2198,21 @@ class ModbusMaster extends EventEmitter {
1997
2198
  for (const cfc of this._customFunctionCodes.values()) {
1998
2199
  appLayer.addCustomFunctionCode(cfc);
1999
2200
  }
2000
- const cleanupFraming = () => appLayer.off('framing', onFraming);
2201
+ const cleanupFraming = () => {
2202
+ appLayer.onFraming = NOOP;
2203
+ };
2001
2204
  const onFraming = (frame) => {
2002
2205
  this._masterSession.handleFrame(frame);
2003
2206
  };
2004
- appLayer.on('framing', onFraming);
2207
+ appLayer.onFraming = onFraming;
2005
2208
  this._cleanupFns.add(cleanupFraming);
2006
- const cleanupFramingError = () => appLayer.off('framing-error', onFramingError);
2209
+ const cleanupFramingError = () => {
2210
+ appLayer.onFramingError = NOOP;
2211
+ };
2007
2212
  const onFramingError = (error) => {
2008
2213
  this._masterSession.handleError(error);
2009
2214
  };
2010
- appLayer.on('framing-error', onFramingError);
2215
+ appLayer.onFramingError = onFramingError;
2011
2216
  this._cleanupFns.add(cleanupFramingError);
2012
2217
  const cleanupClose = () => connection.off('close', onClose);
2013
2218
  const onClose = () => {
@@ -2063,31 +2268,23 @@ class ModbusMaster extends EventEmitter {
2063
2268
  }
2064
2269
  return new AsciiApplicationLayer('MASTER', connection, this._protocol.opts);
2065
2270
  }
2066
- send(unit, fc, data, timeout, broadcast) {
2271
+ _send(unit, fc, data, timeout, broadcast, callback) {
2067
2272
  if (this._physicalLayer.state !== exports.PhysicalState.OPEN) {
2068
- return Promise.reject(new Error('Master is not open'));
2273
+ callback(new Error('Master is not open'));
2274
+ return;
2069
2275
  }
2070
2276
  if (this.concurrent) {
2071
- return new Promise((resolve, reject) => {
2072
- this._exchange(unit, fc, data, timeout, broadcast, (err, frame) => {
2073
- if (err)
2074
- reject(err);
2075
- else
2076
- resolve(frame);
2077
- });
2078
- });
2277
+ this._exchange(unit, fc, data, timeout, broadcast, callback);
2278
+ return;
2079
2279
  }
2080
- return new Promise((resolve, reject) => {
2081
- this._queueUnits.push(unit);
2082
- this._queueFcs.push(fc);
2083
- this._queueDatas.push(data);
2084
- this._queueTimeouts.push(timeout);
2085
- this._queueBroadcasts.push(broadcast);
2086
- this._queueResolves.push(resolve);
2087
- this._queueRejects.push(reject);
2088
- this._queueLen++;
2089
- this._drain();
2090
- });
2280
+ this._queueUnits.push(unit);
2281
+ this._queueFcs.push(fc);
2282
+ this._queueDatas.push(data);
2283
+ this._queueTimeouts.push(timeout);
2284
+ this._queueBroadcasts.push(broadcast);
2285
+ this._queueCallbacks.push(callback);
2286
+ this._queueLen++;
2287
+ this._drain();
2091
2288
  }
2092
2289
  _drain() {
2093
2290
  if (this._draining || this._physicalLayer.state !== exports.PhysicalState.OPEN) {
@@ -2108,14 +2305,12 @@ class ModbusMaster extends EventEmitter {
2108
2305
  const data = this._queueDatas[h];
2109
2306
  const timeout = this._queueTimeouts[h];
2110
2307
  const broadcast = this._queueBroadcasts[h];
2111
- const resolve = this._queueResolves[h];
2112
- const reject = this._queueRejects[h];
2113
- // Drop references so the GC can reclaim data buffers and resolve/reject
2308
+ const callback = this._queueCallbacks[h];
2309
+ // Drop references so the GC can reclaim data buffers and callback
2114
2310
  // closures while the rest of the queue is still draining. Primitives
2115
2311
  // (unit/fc/timeout/broadcast) need no clearing.
2116
2312
  this._queueDatas[h] = undefined;
2117
- this._queueResolves[h] = undefined;
2118
- this._queueRejects[h] = undefined;
2313
+ this._queueCallbacks[h] = undefined;
2119
2314
  this._queueHead = h + 1;
2120
2315
  this._queueLen--;
2121
2316
  if (this._queueLen === 0) {
@@ -2126,15 +2321,11 @@ class ModbusMaster extends EventEmitter {
2126
2321
  this._queueDatas.length = 0;
2127
2322
  this._queueTimeouts.length = 0;
2128
2323
  this._queueBroadcasts.length = 0;
2129
- this._queueResolves.length = 0;
2130
- this._queueRejects.length = 0;
2324
+ this._queueCallbacks.length = 0;
2131
2325
  this._queueHead = 0;
2132
2326
  }
2133
2327
  this._exchange(unit, fc, data, timeout, broadcast, (err, frame) => {
2134
- if (err)
2135
- reject(err);
2136
- else
2137
- resolve(frame);
2328
+ callback(err, frame);
2138
2329
  this._processNext();
2139
2330
  });
2140
2331
  }
@@ -2154,30 +2345,35 @@ class ModbusMaster extends EventEmitter {
2154
2345
  if (!this.concurrent) {
2155
2346
  appLayer.flush();
2156
2347
  }
2348
+ // Lazy-deletion timer architecture:
2349
+ // 1. Assign an exchangeId and register in _pendingExchanges.
2350
+ // 2. Push deadline into the global TimerHeap (no per-request setTimeout).
2351
+ // 3. When the response arrives, delete from Map — the heap entry is left
2352
+ // behind and silently discarded when it surfaces at the top (lazy deletion).
2353
+ const exchangeId = this._nextExchangeId++;
2354
+ const pending = { settled: false, callback, sessionKey: null };
2355
+ this._pendingExchanges.set(exchangeId, pending);
2157
2356
  if (broadcast) {
2158
- // Broadcast: no response expected. Skip the session entirely — registering
2159
- // a waiter under FIFO_KEY would race with any concurrent FIFO request (the
2160
- // broadcast's stop() would clear the other request's slot/timer).
2161
- let settled = false;
2162
- const timer = setTimeout(() => {
2163
- if (settled)
2164
- return;
2165
- settled = true;
2166
- callback(new Error('Timeout'));
2167
- }, timeout);
2357
+ // Broadcast: no response expected. Skip the session entirely.
2358
+ this._timerHeap.add(exchangeId, timeout);
2168
2359
  connection.write(appLayer.encode(unit, fc, data), (writeErr) => {
2169
- if (settled)
2360
+ const p = this._pendingExchanges.get(exchangeId);
2361
+ if (!p || p.settled)
2362
+ return;
2363
+ const cb = p.callback;
2364
+ if (!cb)
2170
2365
  return;
2171
- clearTimeout(timer);
2172
- settled = true;
2366
+ p.settled = true;
2367
+ p.callback = null;
2368
+ this._pendingExchanges.delete(exchangeId);
2173
2369
  if (writeErr) {
2174
- callback(writeErr);
2370
+ cb(writeErr);
2175
2371
  }
2176
2372
  else if (this._physicalLayer.state !== exports.PhysicalState.OPEN) {
2177
- callback(new Error('Master is not open'));
2373
+ cb(new Error('Master is not open'));
2178
2374
  }
2179
2375
  else {
2180
- callback(null);
2376
+ cb(null);
2181
2377
  }
2182
2378
  });
2183
2379
  return;
@@ -2191,62 +2387,82 @@ class ModbusMaster extends EventEmitter {
2191
2387
  }
2192
2388
  const key = tid ?? FIFO_KEY;
2193
2389
  const payload = appLayer.encode(unit, fc, data, tid);
2194
- // Timeout starts before write (covers write + response phases).
2195
- // The session waiter is registered only after write succeeds.
2196
- // settled guard prevents double-invocation when timeout fires during write.
2197
- let settled = false;
2198
- const timer = setTimeout(() => {
2199
- if (settled)
2200
- return;
2201
- settled = true;
2202
- this._masterSession.stop(key);
2203
- callback(new Error('Timeout'));
2204
- }, timeout);
2390
+ pending.sessionKey = key;
2391
+ this._timerHeap.add(exchangeId, timeout);
2205
2392
  connection.write(payload, (writeErr) => {
2393
+ const p = this._pendingExchanges.get(exchangeId);
2394
+ if (!p || p.settled)
2395
+ return;
2206
2396
  if (writeErr) {
2207
- if (!settled) {
2208
- clearTimeout(timer);
2209
- settled = true;
2210
- callback(writeErr);
2397
+ const cb = p.callback;
2398
+ if (cb) {
2399
+ p.settled = true;
2400
+ p.callback = null;
2401
+ this._pendingExchanges.delete(exchangeId);
2402
+ cb(writeErr);
2211
2403
  }
2212
2404
  return;
2213
2405
  }
2214
2406
  if (this._physicalLayer.state !== exports.PhysicalState.OPEN) {
2215
- if (!settled) {
2216
- clearTimeout(timer);
2217
- settled = true;
2218
- callback(new Error('Master is not open'));
2407
+ const cb = p.callback;
2408
+ if (cb) {
2409
+ p.settled = true;
2410
+ p.callback = null;
2411
+ this._pendingExchanges.delete(exchangeId);
2412
+ cb(new Error('Master is not open'));
2219
2413
  }
2220
2414
  return;
2221
2415
  }
2222
2416
  // Write succeeded — register in session for frame matching only.
2223
- // Timeout is managed by the local timer above.
2417
+ // Timeout is managed by the global timer heap above.
2224
2418
  this._masterSession.start(key, (err, frame) => {
2225
- if (settled)
2419
+ const p2 = this._pendingExchanges.get(exchangeId);
2420
+ if (!p2 || p2.settled)
2226
2421
  return;
2227
- clearTimeout(timer);
2228
- settled = true;
2229
- callback(err, frame);
2422
+ const cb = p2.callback;
2423
+ if (cb) {
2424
+ p2.settled = true;
2425
+ p2.callback = null;
2426
+ this._pendingExchanges.delete(exchangeId);
2427
+ cb(err, frame);
2428
+ }
2230
2429
  });
2231
2430
  });
2232
2431
  }
2233
2432
  writeFC1Or2(unit, fc, address, length, timeout) {
2234
2433
  const byteCount = Math.ceil(length / 8);
2235
2434
  const bufferTx = Buffer.allocUnsafe(4);
2236
- bufferTx.writeUInt16BE(address, 0);
2237
- bufferTx.writeUInt16BE(length, 2);
2238
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2239
- if (!frame)
2240
- return;
2241
- validateByteCountResponse(frame, unit, fc, byteCount);
2242
- const data = new Array(length);
2243
- for (let i = 0; i < length; i++) {
2244
- data[i] = (frame.data[1 + ~~(i / 8)] & (1 << i % 8)) > 0;
2245
- }
2246
- // Mutate the frame in place rather than spread-copying — `frame` is freshly
2247
- // allocated per request and not retained anywhere else.
2248
- frame.data = data;
2249
- return frame;
2435
+ // Inline big-endian writes — direct typed-array stores skip the argument
2436
+ // validation + bounds checks that `writeUInt16BE` runs on each call.
2437
+ bufferTx[0] = (address >>> 8) & 0xff;
2438
+ bufferTx[1] = address & 0xff;
2439
+ bufferTx[2] = (length >>> 8) & 0xff;
2440
+ bufferTx[3] = length & 0xff;
2441
+ return new Promise((resolve, reject) => {
2442
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2443
+ if (err) {
2444
+ reject(err);
2445
+ return;
2446
+ }
2447
+ if (!frame) {
2448
+ resolve(undefined);
2449
+ return;
2450
+ }
2451
+ try {
2452
+ validateByteCountResponse(frame, unit, fc, byteCount);
2453
+ const data = new Array(length);
2454
+ for (let i = 0; i < length; i++) {
2455
+ data[i] = (frame.data[1 + Math.floor(i / 8)] & (1 << i % 8)) > 0;
2456
+ }
2457
+ // Mutate the frame in place rather than spread-copying — `frame` is freshly
2458
+ // allocated per request and not retained anywhere else.
2459
+ frame.data = data;
2460
+ resolve(frame);
2461
+ }
2462
+ catch (e) {
2463
+ reject(e);
2464
+ }
2465
+ });
2250
2466
  });
2251
2467
  }
2252
2468
  writeFC1;
@@ -2260,19 +2476,41 @@ class ModbusMaster extends EventEmitter {
2260
2476
  writeFC3Or4(unit, fc, address, length, timeout) {
2261
2477
  const byteCount = length * 2;
2262
2478
  const bufferTx = Buffer.allocUnsafe(4);
2263
- bufferTx.writeUInt16BE(address, 0);
2264
- bufferTx.writeUInt16BE(length, 2);
2265
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2266
- if (!frame)
2267
- return;
2268
- validateByteCountResponse(frame, unit, fc, byteCount);
2269
- const bufferRx = frame.data.subarray(1);
2270
- const data = new Array(length);
2271
- for (let i = 0; i < length; i++) {
2272
- data[i] = bufferRx.readUInt16BE(i * 2);
2273
- }
2274
- frame.data = data;
2275
- return frame;
2479
+ // Inline big-endian writes — see writeFC1Or2 for the rationale.
2480
+ bufferTx[0] = (address >>> 8) & 0xff;
2481
+ bufferTx[1] = address & 0xff;
2482
+ bufferTx[2] = (length >>> 8) & 0xff;
2483
+ bufferTx[3] = length & 0xff;
2484
+ return new Promise((resolve, reject) => {
2485
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2486
+ if (err) {
2487
+ reject(err);
2488
+ return;
2489
+ }
2490
+ if (!frame) {
2491
+ resolve(undefined);
2492
+ return;
2493
+ }
2494
+ try {
2495
+ validateByteCountResponse(frame, unit, fc, byteCount);
2496
+ const bufferRx = frame.data.subarray(1);
2497
+ const data = new Array(length);
2498
+ // Inline big-endian read — `bufferRx[i]` is a direct typed-array
2499
+ // load, while `readUInt16BE` runs argument coercion + bounds check
2500
+ // on each call. Symmetric to the slave-side BE write inlining
2501
+ // in handleFC3/FC4. At length=125 (FC3 max) that's 250 saved
2502
+ // bounds-check pairs per response.
2503
+ for (let i = 0; i < length; i++) {
2504
+ const off = i * 2;
2505
+ data[i] = (bufferRx[off] << 8) | bufferRx[off + 1];
2506
+ }
2507
+ frame.data = data;
2508
+ resolve(frame);
2509
+ }
2510
+ catch (e) {
2511
+ reject(e);
2512
+ }
2513
+ });
2276
2514
  });
2277
2515
  }
2278
2516
  writeFC3;
@@ -2287,28 +2525,61 @@ class ModbusMaster extends EventEmitter {
2287
2525
  writeSingleCoil(unit, address, value, timeout = this.timeout) {
2288
2526
  const fc = exports.FunctionCode.WRITE_SINGLE_COIL;
2289
2527
  const bufferTx = Buffer.allocUnsafe(4);
2290
- bufferTx.writeUInt16BE(address, 0);
2291
- bufferTx.writeUInt16BE(value ? COIL_ON : COIL_OFF, 2);
2292
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2293
- if (!frame)
2294
- return;
2295
- validateEchoResponse(frame, unit, fc, bufferTx);
2296
- frame.data = value;
2297
- return frame;
2528
+ const coilValue = value ? COIL_ON : COIL_OFF;
2529
+ // Inline big-endian writes see writeFC1Or2 for the rationale.
2530
+ bufferTx[0] = (address >>> 8) & 0xff;
2531
+ bufferTx[1] = address & 0xff;
2532
+ bufferTx[2] = (coilValue >>> 8) & 0xff;
2533
+ bufferTx[3] = coilValue & 0xff;
2534
+ return new Promise((resolve, reject) => {
2535
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2536
+ if (err) {
2537
+ reject(err);
2538
+ return;
2539
+ }
2540
+ if (!frame) {
2541
+ resolve(undefined);
2542
+ return;
2543
+ }
2544
+ try {
2545
+ validateEchoResponse(frame, unit, fc, bufferTx);
2546
+ frame.data = value;
2547
+ resolve(frame);
2548
+ }
2549
+ catch (e) {
2550
+ reject(e);
2551
+ }
2552
+ });
2298
2553
  });
2299
2554
  }
2300
2555
  writeFC6;
2301
2556
  writeSingleRegister(unit, address, value, timeout = this.timeout) {
2302
2557
  const fc = exports.FunctionCode.WRITE_SINGLE_REGISTER;
2303
2558
  const bufferTx = Buffer.allocUnsafe(4);
2304
- bufferTx.writeUInt16BE(address, 0);
2305
- bufferTx.writeUInt16BE(value, 2);
2306
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2307
- if (!frame)
2308
- return;
2309
- validateEchoResponse(frame, unit, fc, bufferTx);
2310
- frame.data = value;
2311
- return frame;
2559
+ // Inline big-endian writes — see writeFC1Or2 for the rationale.
2560
+ bufferTx[0] = (address >>> 8) & 0xff;
2561
+ bufferTx[1] = address & 0xff;
2562
+ bufferTx[2] = (value >>> 8) & 0xff;
2563
+ bufferTx[3] = value & 0xff;
2564
+ return new Promise((resolve, reject) => {
2565
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2566
+ if (err) {
2567
+ reject(err);
2568
+ return;
2569
+ }
2570
+ if (!frame) {
2571
+ resolve(undefined);
2572
+ return;
2573
+ }
2574
+ try {
2575
+ validateEchoResponse(frame, unit, fc, bufferTx);
2576
+ frame.data = value;
2577
+ resolve(frame);
2578
+ }
2579
+ catch (e) {
2580
+ reject(e);
2581
+ }
2582
+ });
2312
2583
  });
2313
2584
  }
2314
2585
  writeFC15;
@@ -2316,20 +2587,36 @@ class ModbusMaster extends EventEmitter {
2316
2587
  const fc = exports.FunctionCode.WRITE_MULTIPLE_COILS;
2317
2588
  const byteCount = Math.ceil(value.length / 8);
2318
2589
  const bufferTx = Buffer.alloc(5 + byteCount);
2319
- bufferTx.writeUInt16BE(address, 0);
2320
- bufferTx.writeUInt16BE(value.length, 2);
2321
- bufferTx.writeUInt8(byteCount, 4);
2322
- value.forEach((v, i) => {
2323
- if (v) {
2324
- bufferTx[5 + ~~(i / 8)] |= 1 << i % 8;
2590
+ // Inline big-endian writes — see writeFC1Or2 for the rationale.
2591
+ bufferTx[0] = (address >>> 8) & 0xff;
2592
+ bufferTx[1] = address & 0xff;
2593
+ bufferTx[2] = (value.length >>> 8) & 0xff;
2594
+ bufferTx[3] = value.length & 0xff;
2595
+ bufferTx[4] = byteCount;
2596
+ for (let i = 0; i < value.length; i++) {
2597
+ if (value[i]) {
2598
+ bufferTx[5 + Math.floor(i / 8)] |= 1 << i % 8;
2325
2599
  }
2326
- });
2327
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2328
- if (!frame)
2329
- return;
2330
- validateEchoResponse(frame, unit, fc, bufferTx.subarray(0, 4));
2331
- frame.data = value;
2332
- return frame;
2600
+ }
2601
+ return new Promise((resolve, reject) => {
2602
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2603
+ if (err) {
2604
+ reject(err);
2605
+ return;
2606
+ }
2607
+ if (!frame) {
2608
+ resolve(undefined);
2609
+ return;
2610
+ }
2611
+ try {
2612
+ validateEchoResponse(frame, unit, fc, bufferTx.subarray(0, 4));
2613
+ frame.data = value;
2614
+ resolve(frame);
2615
+ }
2616
+ catch (e) {
2617
+ reject(e);
2618
+ }
2619
+ });
2333
2620
  });
2334
2621
  }
2335
2622
  writeFC16;
@@ -2337,53 +2624,102 @@ class ModbusMaster extends EventEmitter {
2337
2624
  const fc = exports.FunctionCode.WRITE_MULTIPLE_REGISTERS;
2338
2625
  const byteCount = value.length * 2;
2339
2626
  const bufferTx = Buffer.allocUnsafe(5 + byteCount);
2340
- bufferTx.writeUInt16BE(address, 0);
2341
- bufferTx.writeUInt16BE(value.length, 2);
2342
- bufferTx.writeUInt8(byteCount, 4);
2343
- value.forEach((v, i) => {
2344
- bufferTx.writeUInt16BE(v, 5 + i * 2);
2345
- });
2346
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2347
- if (!frame)
2348
- return;
2349
- validateEchoResponse(frame, unit, fc, bufferTx.subarray(0, 4));
2350
- frame.data = value;
2351
- return frame;
2627
+ // Inline big-endian writes — see writeFC1Or2 for the rationale.
2628
+ bufferTx[0] = (address >>> 8) & 0xff;
2629
+ bufferTx[1] = address & 0xff;
2630
+ bufferTx[2] = (value.length >>> 8) & 0xff;
2631
+ bufferTx[3] = value.length & 0xff;
2632
+ bufferTx[4] = byteCount;
2633
+ for (let i = 0; i < value.length; i++) {
2634
+ const v = value[i];
2635
+ const off = 5 + i * 2;
2636
+ bufferTx[off] = (v >>> 8) & 0xff;
2637
+ bufferTx[off + 1] = v & 0xff;
2638
+ }
2639
+ return new Promise((resolve, reject) => {
2640
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2641
+ if (err) {
2642
+ reject(err);
2643
+ return;
2644
+ }
2645
+ if (!frame) {
2646
+ resolve(undefined);
2647
+ return;
2648
+ }
2649
+ try {
2650
+ validateEchoResponse(frame, unit, fc, bufferTx.subarray(0, 4));
2651
+ frame.data = value;
2652
+ resolve(frame);
2653
+ }
2654
+ catch (e) {
2655
+ reject(e);
2656
+ }
2657
+ });
2352
2658
  });
2353
2659
  }
2354
2660
  handleFC17;
2355
2661
  reportServerId(unit, serverIdLength = 1, timeout = this.timeout) {
2356
2662
  const fc = exports.FunctionCode.REPORT_SERVER_ID;
2357
- return this.send(unit, fc, EMPTY_BUFFER, timeout, unit === 0).then((frame) => {
2358
- if (!frame)
2359
- return;
2360
- validateResponse(frame, unit, fc);
2361
- if (frame.data.length < 2 + serverIdLength)
2362
- throw new Error('Insufficient data length');
2363
- if (frame.data.length !== 1 + frame.data[0])
2364
- throw new Error('Invalid response');
2365
- const runStatusIndex = 1 + serverIdLength;
2366
- frame.data = {
2367
- serverId: Array.from(frame.data.subarray(1, runStatusIndex)),
2368
- runIndicatorStatus: frame.data[runStatusIndex] === 0xff,
2369
- additionalData: Array.from(frame.data.subarray(runStatusIndex + 1)),
2370
- };
2371
- return frame;
2663
+ return new Promise((resolve, reject) => {
2664
+ this._send(unit, fc, EMPTY_BUFFER, timeout, unit === 0, (err, frame) => {
2665
+ if (err) {
2666
+ reject(err);
2667
+ return;
2668
+ }
2669
+ if (!frame) {
2670
+ resolve(undefined);
2671
+ return;
2672
+ }
2673
+ try {
2674
+ validateResponse(frame, unit, fc);
2675
+ if (frame.data.length < 2 + serverIdLength)
2676
+ throw new Error('Insufficient data length');
2677
+ if (frame.data.length !== 1 + frame.data[0])
2678
+ throw new Error('Invalid response');
2679
+ const runStatusIndex = 1 + serverIdLength;
2680
+ frame.data = {
2681
+ serverId: Array.from(frame.data.subarray(1, runStatusIndex)),
2682
+ runIndicatorStatus: frame.data[runStatusIndex] === 0xff,
2683
+ additionalData: Array.from(frame.data.subarray(runStatusIndex + 1)),
2684
+ };
2685
+ resolve(frame);
2686
+ }
2687
+ catch (e) {
2688
+ reject(e);
2689
+ }
2690
+ });
2372
2691
  });
2373
2692
  }
2374
2693
  handleFC22;
2375
2694
  maskWriteRegister(unit, address, andMask, orMask, timeout = this.timeout) {
2376
2695
  const fc = exports.FunctionCode.MASK_WRITE_REGISTER;
2377
2696
  const bufferTx = Buffer.allocUnsafe(6);
2378
- bufferTx.writeUInt16BE(address, 0);
2379
- bufferTx.writeUInt16BE(andMask, 2);
2380
- bufferTx.writeUInt16BE(orMask, 4);
2381
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2382
- if (!frame)
2383
- return;
2384
- validateEchoResponse(frame, unit, fc, bufferTx);
2385
- frame.data = { andMask, orMask };
2386
- return frame;
2697
+ // Inline big-endian writes — see writeFC1Or2 for the rationale.
2698
+ bufferTx[0] = (address >>> 8) & 0xff;
2699
+ bufferTx[1] = address & 0xff;
2700
+ bufferTx[2] = (andMask >>> 8) & 0xff;
2701
+ bufferTx[3] = andMask & 0xff;
2702
+ bufferTx[4] = (orMask >>> 8) & 0xff;
2703
+ bufferTx[5] = orMask & 0xff;
2704
+ return new Promise((resolve, reject) => {
2705
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2706
+ if (err) {
2707
+ reject(err);
2708
+ return;
2709
+ }
2710
+ if (!frame) {
2711
+ resolve(undefined);
2712
+ return;
2713
+ }
2714
+ try {
2715
+ validateEchoResponse(frame, unit, fc, bufferTx);
2716
+ frame.data = { andMask, orMask };
2717
+ resolve(frame);
2718
+ }
2719
+ catch (e) {
2720
+ reject(e);
2721
+ }
2722
+ });
2387
2723
  });
2388
2724
  }
2389
2725
  handleFC23;
@@ -2392,71 +2728,115 @@ class ModbusMaster extends EventEmitter {
2392
2728
  const byteCount = write.value.length * 2;
2393
2729
  const readByteCount = read.length * 2;
2394
2730
  const bufferTx = Buffer.allocUnsafe(9 + byteCount);
2395
- bufferTx.writeUInt16BE(read.address, 0);
2396
- bufferTx.writeUInt16BE(read.length, 2);
2397
- bufferTx.writeUInt16BE(write.address, 4);
2398
- bufferTx.writeUInt16BE(write.value.length, 6);
2399
- bufferTx.writeUInt8(byteCount, 8);
2400
- write.value.forEach((v, i) => {
2401
- bufferTx.writeUInt16BE(v, 9 + i * 2);
2402
- });
2403
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2404
- if (!frame)
2405
- return;
2406
- validateByteCountResponse(frame, unit, fc, readByteCount);
2407
- const bufferRx = frame.data.subarray(1);
2408
- frame.data = Array.from({ length: read.length }, (_, index) => bufferRx.readUInt16BE(index * 2));
2409
- return frame;
2731
+ // Inline big-endian writes — see writeFC1Or2 for the rationale.
2732
+ bufferTx[0] = (read.address >>> 8) & 0xff;
2733
+ bufferTx[1] = read.address & 0xff;
2734
+ bufferTx[2] = (read.length >>> 8) & 0xff;
2735
+ bufferTx[3] = read.length & 0xff;
2736
+ bufferTx[4] = (write.address >>> 8) & 0xff;
2737
+ bufferTx[5] = write.address & 0xff;
2738
+ bufferTx[6] = (write.value.length >>> 8) & 0xff;
2739
+ bufferTx[7] = write.value.length & 0xff;
2740
+ bufferTx[8] = byteCount;
2741
+ for (let i = 0; i < write.value.length; i++) {
2742
+ const v = write.value[i];
2743
+ const off = 9 + i * 2;
2744
+ bufferTx[off] = (v >>> 8) & 0xff;
2745
+ bufferTx[off + 1] = v & 0xff;
2746
+ }
2747
+ return new Promise((resolve, reject) => {
2748
+ this._send(unit, fc, bufferTx, timeout, unit === 0, (err, frame) => {
2749
+ if (err) {
2750
+ reject(err);
2751
+ return;
2752
+ }
2753
+ if (!frame) {
2754
+ resolve(undefined);
2755
+ return;
2756
+ }
2757
+ try {
2758
+ validateByteCountResponse(frame, unit, fc, readByteCount);
2759
+ const bufferRx = frame.data.subarray(1);
2760
+ // Dense pre-sized array + inline BE read — drops the Array.from
2761
+ // closure + N readUInt16BE bounds-check pairs. See writeFC3Or4
2762
+ // response handler for the same optimization.
2763
+ const data = new Array(read.length);
2764
+ for (let i = 0; i < read.length; i++) {
2765
+ const off = i * 2;
2766
+ data[i] = (bufferRx[off] << 8) | bufferRx[off + 1];
2767
+ }
2768
+ frame.data = data;
2769
+ resolve(frame);
2770
+ }
2771
+ catch (e) {
2772
+ reject(e);
2773
+ }
2774
+ });
2410
2775
  });
2411
2776
  }
2412
2777
  handleFC43_14;
2413
2778
  readDeviceIdentification(unit, readDeviceIDCode, objectId, timeout = this.timeout) {
2414
2779
  const fc = exports.FunctionCode.READ_DEVICE_IDENTIFICATION;
2415
- return this.send(unit, fc, Buffer.from([MEI_READ_DEVICE_ID, readDeviceIDCode, objectId]), timeout, unit === 0).then((frame) => {
2416
- if (!frame)
2417
- return;
2418
- validateResponse(frame, unit, fc);
2419
- if (frame.data.length < 6)
2420
- throw new Error('Insufficient data length');
2421
- if (frame.data[0] !== MEI_READ_DEVICE_ID || frame.data[1] !== readDeviceIDCode)
2422
- throw new Error('Invalid response');
2423
- const objects = [];
2424
- let object = [];
2425
- let totalBytes = 0;
2426
- for (const v of frame.data.subarray(6)) {
2427
- switch (object.length) {
2428
- case 0:
2429
- case 1: {
2430
- object.push(v);
2431
- break;
2432
- }
2433
- case 2: {
2434
- object.push([v]);
2435
- break;
2436
- }
2437
- case 3: {
2438
- object[2].push(v);
2439
- if (object[1] === object[2].length) {
2440
- objects.push({ id: object[0], value: Buffer.from(object[2]).toString() });
2441
- totalBytes += 2 + object[1];
2442
- object = [];
2780
+ return new Promise((resolve, reject) => {
2781
+ this._send(unit, fc, Buffer.from([MEI_READ_DEVICE_ID, readDeviceIDCode, objectId]), timeout, unit === 0, (err, frame) => {
2782
+ if (err) {
2783
+ reject(err);
2784
+ return;
2785
+ }
2786
+ if (!frame) {
2787
+ resolve(undefined);
2788
+ return;
2789
+ }
2790
+ try {
2791
+ validateResponse(frame, unit, fc);
2792
+ if (frame.data.length < 6)
2793
+ throw new Error('Insufficient data length');
2794
+ if (frame.data[0] !== MEI_READ_DEVICE_ID || frame.data[1] !== readDeviceIDCode)
2795
+ throw new Error('Invalid response');
2796
+ const objects = [];
2797
+ let object = [];
2798
+ let totalBytes = 0;
2799
+ for (const v of frame.data.subarray(6)) {
2800
+ switch (object.length) {
2801
+ case 0:
2802
+ case 1: {
2803
+ object.push(v);
2804
+ break;
2805
+ }
2806
+ case 2: {
2807
+ object.push([v]);
2808
+ break;
2809
+ }
2810
+ case 3: {
2811
+ object[2].push(v);
2812
+ if (object[1] === object[2].length) {
2813
+ objects.push({ id: object[0], value: Buffer.from(object[2]).toString() });
2814
+ totalBytes += 2 + object[1];
2815
+ object = [];
2816
+ }
2817
+ break;
2818
+ }
2819
+ default:
2820
+ break;
2443
2821
  }
2444
- break;
2445
2822
  }
2823
+ if (objects.length !== frame.data[5])
2824
+ throw new Error('Invalid response');
2825
+ if (frame.data.length !== 6 + totalBytes)
2826
+ throw new Error('Invalid response');
2827
+ frame.data = {
2828
+ readDeviceIDCode,
2829
+ conformityLevel: frame.data[2],
2830
+ moreFollows: frame.data[3] === 0xff,
2831
+ nextObjectId: frame.data[4],
2832
+ objects,
2833
+ };
2834
+ resolve(frame);
2446
2835
  }
2447
- }
2448
- if (objects.length !== frame.data[5])
2449
- throw new Error('Invalid response');
2450
- if (frame.data.length !== 6 + totalBytes)
2451
- throw new Error('Invalid response');
2452
- frame.data = {
2453
- readDeviceIDCode,
2454
- conformityLevel: frame.data[2],
2455
- moreFollows: frame.data[3] === 0xff,
2456
- nextObjectId: frame.data[4],
2457
- objects,
2458
- };
2459
- return frame;
2836
+ catch (e) {
2837
+ reject(e);
2838
+ }
2839
+ });
2460
2840
  });
2461
2841
  }
2462
2842
  addCustomFunctionCode(cfc) {
@@ -2469,11 +2849,24 @@ class ModbusMaster extends EventEmitter {
2469
2849
  }
2470
2850
  sendCustomFC(unit, fc, data, timeout = this.timeout) {
2471
2851
  const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
2472
- return this.send(unit, fc, payload, timeout, unit === 0).then((frame) => {
2473
- if (!frame)
2474
- return;
2475
- validateResponse(frame, unit, fc);
2476
- return frame.data;
2852
+ return new Promise((resolve, reject) => {
2853
+ this._send(unit, fc, payload, timeout, unit === 0, (err, frame) => {
2854
+ if (err) {
2855
+ reject(err);
2856
+ return;
2857
+ }
2858
+ if (!frame) {
2859
+ resolve(undefined);
2860
+ return;
2861
+ }
2862
+ try {
2863
+ validateResponse(frame, unit, fc);
2864
+ resolve(frame.data);
2865
+ }
2866
+ catch (e) {
2867
+ reject(e);
2868
+ }
2869
+ });
2477
2870
  });
2478
2871
  }
2479
2872
  /**
@@ -2499,9 +2892,24 @@ class ModbusMaster extends EventEmitter {
2499
2892
  const end = this._queueHead + this._queueLen;
2500
2893
  this._queueLen = 0;
2501
2894
  for (let i = this._queueHead; i < end; i++) {
2502
- this._queueRejects[i](rejectErr);
2895
+ this._queueCallbacks[i](rejectErr);
2503
2896
  }
2504
2897
  this._masterSession.stopAll(rejectErr);
2898
+ this._timerHeap.clear();
2899
+ // Settle any in-flight exchanges that weren't reached by stopAll
2900
+ // (broadcasts have no session waiter; non-broadcasts still in the
2901
+ // pre-write-window haven't registered in session yet).
2902
+ for (const pending of this._pendingExchanges.values()) {
2903
+ if (pending.settled)
2904
+ continue;
2905
+ pending.settled = true;
2906
+ const cb = pending.callback;
2907
+ if (cb) {
2908
+ pending.callback = null;
2909
+ cb(rejectErr);
2910
+ }
2911
+ }
2912
+ this._pendingExchanges.clear();
2505
2913
  let err = null;
2506
2914
  try {
2507
2915
  await promisifyCb((cb) => this._physicalLayer.close(cb));
@@ -2538,7 +2946,9 @@ class ModbusSlave extends EventEmitter {
2538
2946
  _protocol;
2539
2947
  _appLayers = new Map();
2540
2948
  _customFunctionCodes = new Map();
2541
- _locks = new Map();
2949
+ // Active interval locks. Typical length: 0 (no contention) — 1-2 (cross-
2950
+ // connection contention). Linear scan for overlap is sub-µs.
2951
+ _intervalLocks = [];
2542
2952
  _cleanupFns = new Set();
2543
2953
  _closePromise = null;
2544
2954
  get state() {
@@ -2565,7 +2975,9 @@ class ModbusSlave extends EventEmitter {
2565
2975
  for (const cfc of this._customFunctionCodes.values()) {
2566
2976
  appLayer.addCustomFunctionCode(cfc);
2567
2977
  }
2568
- const cleanupFraming = () => appLayer.off('framing', onFraming);
2978
+ const cleanupFraming = () => {
2979
+ appLayer.onFraming = NOOP;
2980
+ };
2569
2981
  const onFraming = (frame) => {
2570
2982
  if (this._physicalLayer.state !== exports.PhysicalState.OPEN) {
2571
2983
  return;
@@ -2580,7 +2992,7 @@ class ModbusSlave extends EventEmitter {
2580
2992
  appLayerData.queues.frames.push(frame);
2581
2993
  this._drain(appLayer, appLayerData.queues);
2582
2994
  };
2583
- appLayer.on('framing', onFraming);
2995
+ appLayer.onFraming = onFraming;
2584
2996
  this._cleanupFns.add(cleanupFraming);
2585
2997
  const cleanupClose = () => connection.off('close', onClose);
2586
2998
  const onClose = () => {
@@ -2647,12 +3059,23 @@ class ModbusSlave extends EventEmitter {
2647
3059
  const byteCount = (length + 7) >> 3;
2648
3060
  const pdu = Buffer.allocUnsafe(byteCount + 1);
2649
3061
  pdu[0] = byteCount;
2650
- pdu.fill(0, 1, byteCount + 1);
3062
+ // Pack 8 booleans per byte without first zero-filling: accumulate into
3063
+ // `acc` and write a full byte once each lane is finished. Saves the
3064
+ // `pdu.fill(0, 1, ...)` pass and replaces N `|=` reads-modify-writes
3065
+ // with N `read`s + ⌈N/8⌉ `write`s. Measured ~+5% on FC01 / ~+3% on FC02
3066
+ // at max payload (2000 / 1968 coils) via benchmark/all-fcs.ts bisect.
3067
+ let acc = 0;
3068
+ let out = 1;
2651
3069
  for (let i = 0; i < length; i++) {
2652
- if (coils[i]) {
2653
- pdu[1 + (i >> 3)] |= 1 << (i & 7);
3070
+ if (coils[i])
3071
+ acc |= 1 << (i & 7);
3072
+ if ((i & 7) === 7) {
3073
+ pdu[out++] = acc;
3074
+ acc = 0;
2654
3075
  }
2655
3076
  }
3077
+ if ((length & 7) !== 0)
3078
+ pdu[out] = acc;
2656
3079
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
2657
3080
  }
2658
3081
  catch (error) {
@@ -2683,12 +3106,19 @@ class ModbusSlave extends EventEmitter {
2683
3106
  const byteCount = (length + 7) >> 3;
2684
3107
  const pdu = Buffer.allocUnsafe(byteCount + 1);
2685
3108
  pdu[0] = byteCount;
2686
- pdu.fill(0, 1, byteCount + 1);
3109
+ // Accumulator-based bit pack — see handleFC1 for the rationale.
3110
+ let acc = 0;
3111
+ let out = 1;
2687
3112
  for (let i = 0; i < length; i++) {
2688
- if (discreteInputs[i]) {
2689
- pdu[1 + (i >> 3)] |= 1 << (i & 7);
3113
+ if (discreteInputs[i])
3114
+ acc |= 1 << (i & 7);
3115
+ if ((i & 7) === 7) {
3116
+ pdu[out++] = acc;
3117
+ acc = 0;
2690
3118
  }
2691
3119
  }
3120
+ if ((length & 7) !== 0)
3121
+ pdu[out] = acc;
2692
3122
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
2693
3123
  }
2694
3124
  catch (error) {
@@ -2718,8 +3148,14 @@ class ModbusSlave extends EventEmitter {
2718
3148
  const registers = await model.readHoldingRegisters(address, length);
2719
3149
  const pdu = Buffer.allocUnsafe(length * 2 + 1);
2720
3150
  pdu[0] = length * 2;
3151
+ // Inline big-endian write — `pdu[i] = v` is a direct typed-array store,
3152
+ // while `writeUInt16BE` runs argument validation + bounds checks on each
3153
+ // call. At length=125 (FC3 max) that's 250 saved checks per request.
2721
3154
  for (let i = 0; i < length; i++) {
2722
- pdu.writeUInt16BE(registers[i], 1 + i * 2);
3155
+ const v = registers[i];
3156
+ const off = 1 + i * 2;
3157
+ pdu[off] = (v >>> 8) & 0xff;
3158
+ pdu[off + 1] = v & 0xff;
2723
3159
  }
2724
3160
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
2725
3161
  }
@@ -2750,8 +3186,12 @@ class ModbusSlave extends EventEmitter {
2750
3186
  const registers = await model.readInputRegisters(address, length);
2751
3187
  const pdu = Buffer.allocUnsafe(length * 2 + 1);
2752
3188
  pdu[0] = length * 2;
3189
+ // Inline big-endian write — see handleFC3 for the rationale.
2753
3190
  for (let i = 0; i < length; i++) {
2754
- pdu.writeUInt16BE(registers[i], 1 + i * 2);
3191
+ const v = registers[i];
3192
+ const off = 1 + i * 2;
3193
+ pdu[off] = (v >>> 8) & 0xff;
3194
+ pdu[off + 1] = v & 0xff;
2755
3195
  }
2756
3196
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
2757
3197
  }
@@ -2831,6 +3271,10 @@ class ModbusSlave extends EventEmitter {
2831
3271
  }
2832
3272
  const value = new Array(length);
2833
3273
  for (let i = 0; i < length; i++) {
3274
+ // Bit math: kept as `Math.floor(i / 8)` / `i % 8` — V8 pattern-matches
3275
+ // both forms with `i` as an integer loop counter to direct shifts, but
3276
+ // measured ~+3% throughput vs the bit-shift form on this exact loop
3277
+ // (benchmark/all-fcs.ts FC15 slave A/B, 8/8 paired samples).
2834
3278
  value[i] = (frame.data[5 + Math.floor(i / 8)] & (1 << i % 8)) > 0;
2835
3279
  }
2836
3280
  try {
@@ -2935,7 +3379,7 @@ class ModbusSlave extends EventEmitter {
2935
3379
  return;
2936
3380
  }
2937
3381
  try {
2938
- await this._withAddressLock([address], async () => {
3382
+ await this._withIntervalLock(address, address + 1, async () => {
2939
3383
  if (model.maskWriteRegister) {
2940
3384
  await model.maskWriteRegister(address, andMask, orMask);
2941
3385
  }
@@ -2985,8 +3429,7 @@ class ModbusSlave extends EventEmitter {
2985
3429
  value[i] = (frame.data[9 + i * 2] << 8) | frame.data[10 + i * 2];
2986
3430
  }
2987
3431
  try {
2988
- const writeAddresses = Array.from({ length: length.write }, (_, i) => address.write + i);
2989
- await this._withAddressLock(writeAddresses, async () => {
3432
+ await this._withIntervalLock(address.write, address.write + length.write, async () => {
2990
3433
  if (model.writeMultipleRegisters) {
2991
3434
  await model.writeMultipleRegisters(address.write, value);
2992
3435
  }
@@ -2999,8 +3442,12 @@ class ModbusSlave extends EventEmitter {
2999
3442
  const registers = await model.readHoldingRegisters(address.read, length.read);
3000
3443
  const pdu = Buffer.allocUnsafe(length.read * 2 + 1);
3001
3444
  pdu[0] = length.read * 2;
3445
+ // Inline big-endian write — see handleFC3 for the rationale.
3002
3446
  for (let i = 0; i < length.read; i++) {
3003
- pdu.writeUInt16BE(registers[i], 1 + i * 2);
3447
+ const v = registers[i];
3448
+ const off = 1 + i * 2;
3449
+ pdu[off] = (v >>> 8) & 0xff;
3450
+ pdu[off + 1] = v & 0xff;
3004
3451
  }
3005
3452
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
3006
3453
  }
@@ -3159,7 +3606,11 @@ class ModbusSlave extends EventEmitter {
3159
3606
  return Promise.resolve();
3160
3607
  }
3161
3608
  return new Promise((resolve) => {
3162
- appLayer.connection.write(data, () => resolve());
3609
+ // Pass `resolve` directly as the write cb (vs `() => resolve()`)
3610
+ // saves one closure allocation per response. `resolve` may receive
3611
+ // the write's err arg, but our Promise<void> contract ignores any
3612
+ // resolved value and the response handler doesn't check success.
3613
+ appLayer.connection.write(data, resolve);
3163
3614
  });
3164
3615
  };
3165
3616
  // Hot path: unicast to a known unit dispatches to a single model.
@@ -3208,24 +3659,66 @@ class ModbusSlave extends EventEmitter {
3208
3659
  return true;
3209
3660
  }
3210
3661
  }
3211
- async _withAddressLock(addresses, fn) {
3212
- const sorted = [...new Set(addresses)].sort((a, b) => a - b);
3213
- const previous = sorted.map((addr) => this._locks.get(addr) ?? Promise.resolve());
3214
- const work = Promise.all(previous).then(() => fn());
3215
- const cleanup = work.catch(() => {
3216
- /* ignore */
3217
- });
3218
- for (const addr of sorted) {
3219
- this._locks.set(addr, cleanup);
3662
+ /**
3663
+ * Serialize a code block over the half-open address interval `[lo, hi)`.
3664
+ * The block runs after all previously-installed locks whose intervals
3665
+ * overlap with this one have completed. Two non-overlapping intervals
3666
+ * execute in parallel.
3667
+ *
3668
+ * Locks are tracked in a flat array (`_intervalLocks`); the typical depth
3669
+ * is 0-2 entries, so the linear overlap scan is sub-µs. Compare with the
3670
+ * old per-address Map design, where FC23 writing 121 registers allocated
3671
+ * ~125 objects per request (one Promise.resolve / address + Set + sort +
3672
+ * Promise.all); this version allocates 1-3.
3673
+ */
3674
+ async _withIntervalLock(lo, hi, fn) {
3675
+ // Find overlapping active entries. Two half-open intervals [a, b) and
3676
+ // [c, d) overlap iff a < d && c < b.
3677
+ let overlap = null;
3678
+ const locks = this._intervalLocks;
3679
+ for (let i = 0; i < locks.length; i++) {
3680
+ const l = locks[i];
3681
+ if (l.lo < hi && lo < l.hi) {
3682
+ if (overlap === null)
3683
+ overlap = [];
3684
+ overlap.push(l.promise);
3685
+ }
3686
+ }
3687
+ // Install our entry BEFORE awaiting so any concurrent caller arriving
3688
+ // after this point sees us and waits. Promise field is filled in below.
3689
+ const entry = { lo, hi, promise: undefined };
3690
+ locks.push(entry);
3691
+ let work;
3692
+ if (overlap === null) {
3693
+ work = fn();
3694
+ }
3695
+ else if (overlap.length === 1) {
3696
+ // Skip Promise.all when there's exactly one prior — avoids one
3697
+ // intermediate Promise allocation in the most common contention case.
3698
+ // `.then(fn)` (vs `.then(() => fn())`) saves a closure: fn's signature
3699
+ // is () => Promise<T>, and the resolved value `.then` passes in is
3700
+ // silently discarded by JS's loose-arity rule.
3701
+ work = overlap[0].then(fn);
3220
3702
  }
3703
+ else {
3704
+ work = Promise.all(overlap).then(fn);
3705
+ }
3706
+ // We never want the cleanup latch to reject — swallow errors so
3707
+ // downstream `.then` chains don't see an unhandled rejection. Using the
3708
+ // shared `NOOP` singleton avoids allocating a fresh arrow function on
3709
+ // every locked request.
3710
+ entry.promise = work.catch(NOOP);
3221
3711
  try {
3222
3712
  return await work;
3223
3713
  }
3224
3714
  finally {
3225
- for (const addr of sorted) {
3226
- if (this._locks.get(addr) === cleanup) {
3227
- this._locks.delete(addr);
3228
- }
3715
+ const i = locks.indexOf(entry);
3716
+ if (i !== -1) {
3717
+ // O(1) swap-and-pop since lock order doesn't matter for correctness.
3718
+ const last = locks.length - 1;
3719
+ if (i !== last)
3720
+ locks[i] = locks[last];
3721
+ locks.pop();
3229
3722
  }
3230
3723
  }
3231
3724
  }
@@ -3356,6 +3849,7 @@ exports.MasterSession = MasterSession;
3356
3849
  exports.ModbusError = ModbusError;
3357
3850
  exports.ModbusMaster = ModbusMaster;
3358
3851
  exports.ModbusSlave = ModbusSlave;
3852
+ exports.NOOP = NOOP;
3359
3853
  exports.RtuApplicationLayer = RtuApplicationLayer;
3360
3854
  exports.SerialPhysicalLayer = SerialPhysicalLayer;
3361
3855
  exports.TcpApplicationLayer = TcpApplicationLayer;