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