njs-modbus 3.1.0 → 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 -390
  4. package/dist/index.d.ts +96 -30
  5. package/dist/index.mjs +878 -391
  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 +16 -3
  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 -19
  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 -28
  23. package/dist/src/layers/physical/udp-client-physical-layer.d.ts +0 -33
  24. package/dist/src/layers/physical/udp-server-physical-layer.d.ts +0 -50
  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`.
@@ -1192,12 +1301,6 @@ class UdpServerPhysicalLayer extends AbstractPhysicalLayer {
1192
1301
  super();
1193
1302
  this._opts = options ?? {};
1194
1303
  }
1195
- /**
1196
- * Bind the UDP socket and start accepting datagrams.
1197
- *
1198
- * @param options Bind options (port, address, etc.). Defaults to port 502.
1199
- * @param cb Callback invoked when binding completes or fails.
1200
- */
1201
1304
  open(options, cb) {
1202
1305
  // Node-style callback overload: if the first arg is a function, treat it as cb.
1203
1306
  if (typeof options === 'function') {
@@ -1345,14 +1448,16 @@ function createPhysicalLayer(config) {
1345
1448
  * established and discarded when the connection closes. Subclasses implement
1346
1449
  * ASCII, RTU, or TCP framing rules.
1347
1450
  */
1348
- 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;
1349
1456
  flush() {
1350
1457
  // no-op — override in subclasses
1351
1458
  }
1352
- addCustomFunctionCode(cfc) {
1353
- }
1354
- removeCustomFunctionCode(fc) {
1355
- }
1459
+ addCustomFunctionCode(cfc) { }
1460
+ removeCustomFunctionCode(fc) { }
1356
1461
  }
1357
1462
 
1358
1463
  const MAX_FRAME_LENGTH = 256;
@@ -1366,7 +1471,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1366
1471
  _threePointFiveT;
1367
1472
  _onePointFiveT;
1368
1473
  _customFunctionCodes = new Map();
1369
- _cleanupFns = new Set();
1474
+ _cleanupCbs = [];
1370
1475
  get connection() {
1371
1476
  return this._connection;
1372
1477
  }
@@ -1382,7 +1487,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1382
1487
  const onData = (data) => {
1383
1488
  const state = this._state;
1384
1489
  if (state.t15Expired && state.end > state.start) {
1385
- this.emit('framing-error', new Error('Inter-character timeout (t1.5) exceeded'));
1490
+ this.onFramingError(new Error('Inter-character timeout (t1.5) exceeded'));
1386
1491
  state.start = 0;
1387
1492
  state.end = 0;
1388
1493
  }
@@ -1424,7 +1529,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1424
1529
  // flushBuffer freed nothing — the entire pool is unparseable
1425
1530
  // residue (typically a misconfigured poolSize for the wire's
1426
1531
  // frame size). Hard reset; we cannot recover automatically.
1427
- 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'));
1428
1533
  currentState.start = 0;
1429
1534
  currentState.end = 0;
1430
1535
  currentState.t15Expired = false;
@@ -1460,16 +1565,16 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1460
1565
  }
1461
1566
  };
1462
1567
  connection.on('data', onData);
1463
- this._cleanupFns.add(() => connection.off('data', onData));
1568
+ this._cleanupCbs.push(() => connection.off('data', onData));
1464
1569
  const onClose = () => {
1465
- for (const fn of this._cleanupFns) {
1570
+ for (const fn of this._cleanupCbs) {
1466
1571
  fn();
1467
1572
  }
1468
- this._cleanupFns.clear();
1573
+ this._cleanupCbs.length = 0;
1469
1574
  this.clearStateTimers();
1470
1575
  };
1471
1576
  connection.on('close', onClose);
1472
- this._cleanupFns.add(() => connection.off('close', onClose));
1577
+ this._cleanupCbs.push(() => connection.off('close', onClose));
1473
1578
  }
1474
1579
  clearStateTimers() {
1475
1580
  const state = this._state;
@@ -1482,34 +1587,38 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1482
1587
  state.interCharTimer = undefined;
1483
1588
  }
1484
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
+ }
1485
1613
  flushBuffer(strict) {
1486
1614
  const state = this._state;
1487
1615
  const isResponse = this.ROLE === 'MASTER';
1488
1616
  const pool = state.pool;
1489
1617
  const customFCs = this._customFunctionCodes;
1490
- // Shared handler for every "frame is not yet complete" exit. Returns true
1491
- // when the caller should `return` (strict reset), false to `break` the
1492
- // parse loop. Hot path never reaches here — only error/incomplete edges.
1493
- const handleIncomplete = () => {
1494
- if (strict) {
1495
- this.emit('framing-error', new Error(state.t15Expired ? 'Inter-character timeout (t1.5) exceeded' : 'Incomplete frame at t3.5'));
1496
- state.start = 0;
1497
- state.end = 0;
1498
- state.t15Expired = false;
1499
- return true;
1500
- }
1501
- if (state.t15Expired) {
1502
- this.emit('framing-error', new Error('Inter-character timeout (t1.5) exceeded'));
1503
- state.start = 0;
1504
- state.end = 0;
1505
- state.t15Expired = false;
1506
- }
1507
- return false;
1508
- };
1509
1618
  while (state.end - state.start > 0) {
1510
1619
  const available = state.end - state.start;
1511
1620
  if (available < MIN_FRAME_LENGTH) {
1512
- if (handleIncomplete())
1621
+ if (this._handleIncomplete(state, strict))
1513
1622
  return;
1514
1623
  break;
1515
1624
  }
@@ -1518,7 +1627,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1518
1627
  let expected;
1519
1628
  if (cfc) {
1520
1629
  const predictor = isResponse ? cfc.predictResponseLength : cfc.predictRequestLength;
1521
- const predicted = predictor(pool.subarray(state.start, state.end));
1630
+ const predicted = predictor(pool, state.start, state.end);
1522
1631
  // Normalize custom predictor's `null` to the std sentinel so both
1523
1632
  // paths share the same NEED_MORE tail below.
1524
1633
  expected = predicted ?? PREDICT_NEED_MORE;
@@ -1526,9 +1635,9 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1526
1635
  else {
1527
1636
  // Standard FC path. predictRtuFrameLength uses sentinel returns to
1528
1637
  // avoid per-call object allocation on the decode hot path.
1529
- expected = predictRtuFrameLength(pool.subarray(state.start, state.end), isResponse);
1638
+ expected = predictRtuFrameLength(pool, state.start, state.end, isResponse);
1530
1639
  if (expected === PREDICT_UNKNOWN) {
1531
- 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`));
1532
1641
  state.start = 0;
1533
1642
  state.end = 0;
1534
1643
  return;
@@ -1539,12 +1648,12 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1539
1648
  state.start += 1;
1540
1649
  continue;
1541
1650
  }
1542
- if (handleIncomplete())
1651
+ if (this._handleIncomplete(state, strict))
1543
1652
  return;
1544
1653
  break;
1545
1654
  }
1546
1655
  if (expected > MAX_FRAME_LENGTH || expected < MIN_FRAME_LENGTH) {
1547
- this.emit('framing-error', new Error('Invalid data'));
1656
+ this.onFramingError(new Error('Invalid data'));
1548
1657
  state.start = 0;
1549
1658
  state.end = 0;
1550
1659
  return;
@@ -1554,7 +1663,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1554
1663
  state.start += 1;
1555
1664
  continue;
1556
1665
  }
1557
- if (handleIncomplete())
1666
+ if (this._handleIncomplete(state, strict))
1558
1667
  return;
1559
1668
  break;
1560
1669
  }
@@ -1565,7 +1674,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1565
1674
  const actualCrc = crc(pool, crcStart, crcEnd);
1566
1675
  if (expectedCrc !== actualCrc) {
1567
1676
  if (strict) {
1568
- this.emit('framing-error', new Error('CRC mismatch'));
1677
+ this.onFramingError(new Error('CRC mismatch'));
1569
1678
  state.start = 0;
1570
1679
  state.end = 0;
1571
1680
  state.t15Expired = false;
@@ -1586,7 +1695,7 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1586
1695
  data: frameBuf.subarray(2, expected - 2),
1587
1696
  buffer: frameBuf,
1588
1697
  };
1589
- this.emit('framing', frame);
1698
+ this.onFraming(frame);
1590
1699
  }
1591
1700
  if (state.start > 0) {
1592
1701
  if (state.start < state.end) {
@@ -1613,11 +1722,21 @@ class RtuApplicationLayer extends AbstractApplicationLayer {
1613
1722
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1614
1723
  encode(unit, fc, data, transaction) {
1615
1724
  const buffer = Buffer.allocUnsafe(data.length + 4);
1616
- buffer.writeUInt8(unit, 0);
1617
- buffer.writeUInt8(fc, 1);
1618
- 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
+ }
1619
1735
  const crcEnd = buffer.length - 2;
1620
- 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;
1621
1740
  return buffer;
1622
1741
  }
1623
1742
  }
@@ -1648,8 +1767,8 @@ class AsciiApplicationLayer extends AbstractApplicationLayer {
1648
1767
  ROLE;
1649
1768
  lenientHex;
1650
1769
  _connection;
1651
- _state = { status: 'idle', frame: [] };
1652
- _cleanupFns = new Set();
1770
+ _state = { status: 'idle', frame: new Uint8Array(MAX_ASCII_PAYLOAD), frameLen: 0 };
1771
+ _cleanupCbs = [];
1653
1772
  get connection() {
1654
1773
  return this._connection;
1655
1774
  }
@@ -1673,108 +1792,138 @@ class AsciiApplicationLayer extends AbstractApplicationLayer {
1673
1792
  };
1674
1793
  const onData = (data) => {
1675
1794
  const state = this._state;
1676
- data.forEach((value) => {
1795
+ for (let i = 0; i < data.length; i++) {
1796
+ const value = data[i];
1677
1797
  switch (state.status) {
1678
1798
  case 'idle': {
1679
1799
  if (value === CHAR_CODE.COLON) {
1680
1800
  state.status = 'reception';
1681
- state.frame = [];
1801
+ state.frameLen = 0;
1682
1802
  }
1683
1803
  break;
1684
1804
  }
1685
1805
  case 'reception': {
1686
1806
  if (value === CHAR_CODE.COLON) {
1687
- state.frame = [];
1807
+ state.frameLen = 0;
1688
1808
  }
1689
1809
  else if (value === CHAR_CODE.CR) {
1690
1810
  state.status = 'waiting end';
1691
1811
  }
1692
- else if (state.frame.length >= MAX_ASCII_PAYLOAD) {
1812
+ else if (state.frameLen >= MAX_ASCII_PAYLOAD) {
1693
1813
  state.status = 'idle';
1694
- state.frame = [];
1695
- this.emit('framing-error', new Error('Invalid data'));
1814
+ state.frameLen = 0;
1815
+ this.onFramingError(new Error('Invalid data'));
1696
1816
  }
1697
1817
  else if (!isHexChar(value)) {
1698
1818
  state.status = 'idle';
1699
- state.frame = [];
1700
- this.emit('framing-error', new Error('Invalid hex character'));
1819
+ state.frameLen = 0;
1820
+ this.onFramingError(new Error('Invalid hex character'));
1701
1821
  }
1702
1822
  else {
1703
- state.frame.push(value);
1823
+ state.frame[state.frameLen++] = value;
1704
1824
  }
1705
1825
  break;
1706
1826
  }
1707
1827
  case 'waiting end': {
1708
1828
  if (value === CHAR_CODE.COLON) {
1709
1829
  state.status = 'reception';
1710
- state.frame = [];
1830
+ state.frameLen = 0;
1711
1831
  }
1712
1832
  else {
1713
1833
  state.status = 'idle';
1714
1834
  if (value === CHAR_CODE.LF) {
1715
- this.framing(Buffer.from(state.frame));
1835
+ this.framing(state.frame, state.frameLen);
1716
1836
  }
1717
1837
  }
1718
1838
  break;
1719
1839
  }
1720
1840
  }
1721
- });
1841
+ }
1722
1842
  };
1723
1843
  connection.on('data', onData);
1724
- this._cleanupFns.add(() => connection.off('data', onData));
1844
+ this._cleanupCbs.push(() => connection.off('data', onData));
1725
1845
  const onClose = () => {
1726
- for (const fn of this._cleanupFns) {
1846
+ for (const fn of this._cleanupCbs) {
1727
1847
  fn();
1728
1848
  }
1729
- this._cleanupFns.clear();
1849
+ this._cleanupCbs.length = 0;
1730
1850
  };
1731
1851
  connection.on('close', onClose);
1732
- this._cleanupFns.add(() => connection.off('close', onClose));
1852
+ this._cleanupCbs.push(() => connection.off('close', onClose));
1733
1853
  }
1734
- framing(hexChars) {
1735
- if (hexChars.length < 6) {
1736
- 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'));
1737
1872
  return;
1738
1873
  }
1739
- if (hexChars.length % 2 !== 0) {
1740
- 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'));
1741
1881
  return;
1742
1882
  }
1743
- const decoded = Buffer.allocUnsafe(hexChars.length / 2);
1744
- for (let i = 0; i < hexChars.length; i += 2) {
1745
- const hi = HEX_DECODE[hexChars[i]];
1746
- const lo = HEX_DECODE[hexChars[i + 1]];
1747
- // Defensive: the FSM should already have filtered non-hex characters,
1748
- // 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]];
1749
1891
  if (hi === 0xff || lo === 0xff) {
1750
- this.emit('framing-error', new Error('Invalid hex character'));
1892
+ this.onFramingError(new Error('Invalid hex character'));
1751
1893
  return;
1752
1894
  }
1753
- decoded[i / 2] = (hi << 4) | lo;
1895
+ data[i] = (hi << 4) | lo;
1754
1896
  }
1755
- const frame = {
1756
- unit: decoded[0],
1757
- fc: decoded[1],
1758
- data: decoded.subarray(2, decoded.length - 1),
1759
- buffer: hexChars,
1760
- };
1761
- const lrcPassed = decoded[decoded.length - 1] === lrc(decoded.subarray(0, decoded.length - 1));
1762
- if (!lrcPassed) {
1763
- 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'));
1764
1905
  return;
1765
1906
  }
1766
- 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);
1767
1914
  }
1768
1915
  flush() {
1769
- this._state = { status: 'idle', frame: [] };
1916
+ this._state.status = 'idle';
1917
+ this._state.frameLen = 0;
1770
1918
  }
1771
1919
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1772
1920
  encode(unit, fc, data, transaction) {
1773
1921
  const buffer = Buffer.allocUnsafe(data.length + 3);
1774
- buffer.writeUInt8(unit, 0);
1775
- 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;
1776
1925
  buffer.set(data, 2);
1777
- buffer.writeUInt8(lrc(buffer.subarray(0, -1)), buffer.length - 1);
1926
+ buffer[buffer.length - 1] = lrc(buffer.subarray(0, -1));
1778
1927
  const out = Buffer.allocUnsafe(1 + buffer.length * 2 + 2);
1779
1928
  out[0] = CHAR_CODE.COLON;
1780
1929
  for (let i = 0; i < buffer.length; i++) {
@@ -1795,7 +1944,7 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1795
1944
  _connection;
1796
1945
  _transactionId = 1;
1797
1946
  _buffer = EMPTY_BUFFER;
1798
- _cleanupFns = new Set();
1947
+ _cleanupCbs = [];
1799
1948
  get connection() {
1800
1949
  return this._connection;
1801
1950
  }
@@ -1804,6 +1953,17 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1804
1953
  this.ROLE = role;
1805
1954
  this._connection = connection;
1806
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
+ }
1807
1967
  let buffer = this._buffer;
1808
1968
  if (buffer.length === 0) {
1809
1969
  buffer = data;
@@ -1821,7 +1981,7 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1821
1981
  break;
1822
1982
  }
1823
1983
  else {
1824
- this.emit('framing-error', result.error);
1984
+ this.onFramingError(result.error);
1825
1985
  buffer = EMPTY_BUFFER;
1826
1986
  break;
1827
1987
  }
@@ -1841,15 +2001,15 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1841
2001
  }
1842
2002
  };
1843
2003
  connection.on('data', onData);
1844
- this._cleanupFns.add(() => connection.off('data', onData));
2004
+ this._cleanupCbs.push(() => connection.off('data', onData));
1845
2005
  const onClose = () => {
1846
- for (const fn of this._cleanupFns) {
2006
+ for (const fn of this._cleanupCbs) {
1847
2007
  fn();
1848
2008
  }
1849
- this._cleanupFns.clear();
2009
+ this._cleanupCbs.length = 0;
1850
2010
  };
1851
2011
  connection.on('close', onClose);
1852
- this._cleanupFns.add(() => connection.off('close', onClose));
2012
+ this._cleanupCbs.push(() => connection.off('close', onClose));
1853
2013
  }
1854
2014
  tryExtract(buffer) {
1855
2015
  if (buffer.length < 8) {
@@ -1858,7 +2018,7 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1858
2018
  if (buffer[2] !== 0 || buffer[3] !== 0) {
1859
2019
  return { kind: 'error', error: new Error('Invalid data') };
1860
2020
  }
1861
- const length = buffer.readUInt16BE(4);
2021
+ const length = (buffer[4] << 8) | buffer[5]; // inline BE read
1862
2022
  const total = 6 + length;
1863
2023
  if (total > MAX_TCP_FRAME || length < 2) {
1864
2024
  return { kind: 'error', error: new Error('Invalid data') };
@@ -1866,29 +2026,47 @@ class TcpApplicationLayer extends AbstractApplicationLayer {
1866
2026
  if (buffer.length < total) {
1867
2027
  return { kind: 'insufficient' };
1868
2028
  }
1869
- 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) };
1870
2030
  }
1871
2031
  processFrame(buffer) {
1872
2032
  const frame = {
1873
- 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],
1874
2037
  unit: buffer[6],
1875
2038
  fc: buffer[7],
1876
2039
  data: buffer.subarray(8),
1877
2040
  buffer,
1878
2041
  };
1879
- this.emit('framing', frame);
2042
+ this.onFraming(frame);
1880
2043
  }
1881
2044
  flush() {
1882
2045
  this._buffer = EMPTY_BUFFER;
1883
2046
  }
1884
2047
  encode(unit, fc, data, transaction) {
1885
2048
  const buffer = Buffer.allocUnsafe(data.length + 8);
1886
- buffer.writeUInt16BE(transaction ?? this._transactionId, 0);
1887
- buffer.writeUInt16BE(0, 2);
1888
- buffer.writeUInt16BE(data.length + 2, 4);
1889
- buffer.writeUInt8(unit, 6);
1890
- buffer.writeUInt8(fc, 7);
1891
- 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
+ }
1892
2070
  if (transaction === undefined) {
1893
2071
  this._transactionId = (this._transactionId + 1) % 65536 || 1;
1894
2072
  }
@@ -1967,14 +2145,31 @@ class ModbusMaster extends EventEmitter {
1967
2145
  _queueDatas = [];
1968
2146
  _queueTimeouts = [];
1969
2147
  _queueBroadcasts = [];
1970
- _queueResolves = [];
1971
- _queueRejects = [];
2148
+ _queueCallbacks = [];
1972
2149
  _queueHead = 0;
1973
2150
  _queueLen = 0;
1974
2151
  _draining = false;
1975
2152
  _nextTid = 1;
1976
2153
  _cleanupFns = new Set();
1977
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
+ });
1978
2173
  get state() {
1979
2174
  return this._physicalLayer.state;
1980
2175
  }
@@ -2001,17 +2196,21 @@ class ModbusMaster extends EventEmitter {
2001
2196
  for (const cfc of this._customFunctionCodes.values()) {
2002
2197
  appLayer.addCustomFunctionCode(cfc);
2003
2198
  }
2004
- const cleanupFraming = () => appLayer.off('framing', onFraming);
2199
+ const cleanupFraming = () => {
2200
+ appLayer.onFraming = NOOP;
2201
+ };
2005
2202
  const onFraming = (frame) => {
2006
2203
  this._masterSession.handleFrame(frame);
2007
2204
  };
2008
- appLayer.on('framing', onFraming);
2205
+ appLayer.onFraming = onFraming;
2009
2206
  this._cleanupFns.add(cleanupFraming);
2010
- const cleanupFramingError = () => appLayer.off('framing-error', onFramingError);
2207
+ const cleanupFramingError = () => {
2208
+ appLayer.onFramingError = NOOP;
2209
+ };
2011
2210
  const onFramingError = (error) => {
2012
2211
  this._masterSession.handleError(error);
2013
2212
  };
2014
- appLayer.on('framing-error', onFramingError);
2213
+ appLayer.onFramingError = onFramingError;
2015
2214
  this._cleanupFns.add(cleanupFramingError);
2016
2215
  const cleanupClose = () => connection.off('close', onClose);
2017
2216
  const onClose = () => {
@@ -2067,31 +2266,23 @@ class ModbusMaster extends EventEmitter {
2067
2266
  }
2068
2267
  return new AsciiApplicationLayer('MASTER', connection, this._protocol.opts);
2069
2268
  }
2070
- send(unit, fc, data, timeout, broadcast) {
2269
+ _send(unit, fc, data, timeout, broadcast, callback) {
2071
2270
  if (this._physicalLayer.state !== PhysicalState.OPEN) {
2072
- return Promise.reject(new Error('Master is not open'));
2271
+ callback(new Error('Master is not open'));
2272
+ return;
2073
2273
  }
2074
2274
  if (this.concurrent) {
2075
- return new Promise((resolve, reject) => {
2076
- this._exchange(unit, fc, data, timeout, broadcast, (err, frame) => {
2077
- if (err)
2078
- reject(err);
2079
- else
2080
- resolve(frame);
2081
- });
2082
- });
2275
+ this._exchange(unit, fc, data, timeout, broadcast, callback);
2276
+ return;
2083
2277
  }
2084
- return new Promise((resolve, reject) => {
2085
- this._queueUnits.push(unit);
2086
- this._queueFcs.push(fc);
2087
- this._queueDatas.push(data);
2088
- this._queueTimeouts.push(timeout);
2089
- this._queueBroadcasts.push(broadcast);
2090
- this._queueResolves.push(resolve);
2091
- this._queueRejects.push(reject);
2092
- this._queueLen++;
2093
- this._drain();
2094
- });
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();
2095
2286
  }
2096
2287
  _drain() {
2097
2288
  if (this._draining || this._physicalLayer.state !== PhysicalState.OPEN) {
@@ -2112,14 +2303,12 @@ class ModbusMaster extends EventEmitter {
2112
2303
  const data = this._queueDatas[h];
2113
2304
  const timeout = this._queueTimeouts[h];
2114
2305
  const broadcast = this._queueBroadcasts[h];
2115
- const resolve = this._queueResolves[h];
2116
- const reject = this._queueRejects[h];
2117
- // 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
2118
2308
  // closures while the rest of the queue is still draining. Primitives
2119
2309
  // (unit/fc/timeout/broadcast) need no clearing.
2120
2310
  this._queueDatas[h] = undefined;
2121
- this._queueResolves[h] = undefined;
2122
- this._queueRejects[h] = undefined;
2311
+ this._queueCallbacks[h] = undefined;
2123
2312
  this._queueHead = h + 1;
2124
2313
  this._queueLen--;
2125
2314
  if (this._queueLen === 0) {
@@ -2130,15 +2319,11 @@ class ModbusMaster extends EventEmitter {
2130
2319
  this._queueDatas.length = 0;
2131
2320
  this._queueTimeouts.length = 0;
2132
2321
  this._queueBroadcasts.length = 0;
2133
- this._queueResolves.length = 0;
2134
- this._queueRejects.length = 0;
2322
+ this._queueCallbacks.length = 0;
2135
2323
  this._queueHead = 0;
2136
2324
  }
2137
2325
  this._exchange(unit, fc, data, timeout, broadcast, (err, frame) => {
2138
- if (err)
2139
- reject(err);
2140
- else
2141
- resolve(frame);
2326
+ callback(err, frame);
2142
2327
  this._processNext();
2143
2328
  });
2144
2329
  }
@@ -2158,30 +2343,35 @@ class ModbusMaster extends EventEmitter {
2158
2343
  if (!this.concurrent) {
2159
2344
  appLayer.flush();
2160
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);
2161
2354
  if (broadcast) {
2162
- // Broadcast: no response expected. Skip the session entirely — registering
2163
- // a waiter under FIFO_KEY would race with any concurrent FIFO request (the
2164
- // broadcast's stop() would clear the other request's slot/timer).
2165
- let settled = false;
2166
- const timer = setTimeout(() => {
2167
- if (settled)
2168
- return;
2169
- settled = true;
2170
- callback(new Error('Timeout'));
2171
- }, timeout);
2355
+ // Broadcast: no response expected. Skip the session entirely.
2356
+ this._timerHeap.add(exchangeId, timeout);
2172
2357
  connection.write(appLayer.encode(unit, fc, data), (writeErr) => {
2173
- if (settled)
2358
+ const p = this._pendingExchanges.get(exchangeId);
2359
+ if (!p || p.settled)
2360
+ return;
2361
+ const cb = p.callback;
2362
+ if (!cb)
2174
2363
  return;
2175
- clearTimeout(timer);
2176
- settled = true;
2364
+ p.settled = true;
2365
+ p.callback = null;
2366
+ this._pendingExchanges.delete(exchangeId);
2177
2367
  if (writeErr) {
2178
- callback(writeErr);
2368
+ cb(writeErr);
2179
2369
  }
2180
2370
  else if (this._physicalLayer.state !== PhysicalState.OPEN) {
2181
- callback(new Error('Master is not open'));
2371
+ cb(new Error('Master is not open'));
2182
2372
  }
2183
2373
  else {
2184
- callback(null);
2374
+ cb(null);
2185
2375
  }
2186
2376
  });
2187
2377
  return;
@@ -2195,62 +2385,82 @@ class ModbusMaster extends EventEmitter {
2195
2385
  }
2196
2386
  const key = tid ?? FIFO_KEY;
2197
2387
  const payload = appLayer.encode(unit, fc, data, tid);
2198
- // Timeout starts before write (covers write + response phases).
2199
- // The session waiter is registered only after write succeeds.
2200
- // settled guard prevents double-invocation when timeout fires during write.
2201
- let settled = false;
2202
- const timer = setTimeout(() => {
2203
- if (settled)
2204
- return;
2205
- settled = true;
2206
- this._masterSession.stop(key);
2207
- callback(new Error('Timeout'));
2208
- }, timeout);
2388
+ pending.sessionKey = key;
2389
+ this._timerHeap.add(exchangeId, timeout);
2209
2390
  connection.write(payload, (writeErr) => {
2391
+ const p = this._pendingExchanges.get(exchangeId);
2392
+ if (!p || p.settled)
2393
+ return;
2210
2394
  if (writeErr) {
2211
- if (!settled) {
2212
- clearTimeout(timer);
2213
- settled = true;
2214
- 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);
2215
2401
  }
2216
2402
  return;
2217
2403
  }
2218
2404
  if (this._physicalLayer.state !== PhysicalState.OPEN) {
2219
- if (!settled) {
2220
- clearTimeout(timer);
2221
- settled = true;
2222
- 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'));
2223
2411
  }
2224
2412
  return;
2225
2413
  }
2226
2414
  // Write succeeded — register in session for frame matching only.
2227
- // Timeout is managed by the local timer above.
2415
+ // Timeout is managed by the global timer heap above.
2228
2416
  this._masterSession.start(key, (err, frame) => {
2229
- if (settled)
2417
+ const p2 = this._pendingExchanges.get(exchangeId);
2418
+ if (!p2 || p2.settled)
2230
2419
  return;
2231
- clearTimeout(timer);
2232
- settled = true;
2233
- 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
+ }
2234
2427
  });
2235
2428
  });
2236
2429
  }
2237
2430
  writeFC1Or2(unit, fc, address, length, timeout) {
2238
2431
  const byteCount = Math.ceil(length / 8);
2239
2432
  const bufferTx = Buffer.allocUnsafe(4);
2240
- bufferTx.writeUInt16BE(address, 0);
2241
- bufferTx.writeUInt16BE(length, 2);
2242
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2243
- if (!frame)
2244
- return;
2245
- validateByteCountResponse(frame, unit, fc, byteCount);
2246
- const data = new Array(length);
2247
- for (let i = 0; i < length; i++) {
2248
- data[i] = (frame.data[1 + ~~(i / 8)] & (1 << i % 8)) > 0;
2249
- }
2250
- // Mutate the frame in place rather than spread-copying — `frame` is freshly
2251
- // allocated per request and not retained anywhere else.
2252
- frame.data = data;
2253
- 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
+ });
2254
2464
  });
2255
2465
  }
2256
2466
  writeFC1;
@@ -2264,19 +2474,41 @@ class ModbusMaster extends EventEmitter {
2264
2474
  writeFC3Or4(unit, fc, address, length, timeout) {
2265
2475
  const byteCount = length * 2;
2266
2476
  const bufferTx = Buffer.allocUnsafe(4);
2267
- bufferTx.writeUInt16BE(address, 0);
2268
- bufferTx.writeUInt16BE(length, 2);
2269
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2270
- if (!frame)
2271
- return;
2272
- validateByteCountResponse(frame, unit, fc, byteCount);
2273
- const bufferRx = frame.data.subarray(1);
2274
- const data = new Array(length);
2275
- for (let i = 0; i < length; i++) {
2276
- data[i] = bufferRx.readUInt16BE(i * 2);
2277
- }
2278
- frame.data = data;
2279
- 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
+ });
2280
2512
  });
2281
2513
  }
2282
2514
  writeFC3;
@@ -2291,28 +2523,61 @@ class ModbusMaster extends EventEmitter {
2291
2523
  writeSingleCoil(unit, address, value, timeout = this.timeout) {
2292
2524
  const fc = FunctionCode.WRITE_SINGLE_COIL;
2293
2525
  const bufferTx = Buffer.allocUnsafe(4);
2294
- bufferTx.writeUInt16BE(address, 0);
2295
- bufferTx.writeUInt16BE(value ? COIL_ON : COIL_OFF, 2);
2296
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2297
- if (!frame)
2298
- return;
2299
- validateEchoResponse(frame, unit, fc, bufferTx);
2300
- frame.data = value;
2301
- 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
+ });
2302
2551
  });
2303
2552
  }
2304
2553
  writeFC6;
2305
2554
  writeSingleRegister(unit, address, value, timeout = this.timeout) {
2306
2555
  const fc = FunctionCode.WRITE_SINGLE_REGISTER;
2307
2556
  const bufferTx = Buffer.allocUnsafe(4);
2308
- bufferTx.writeUInt16BE(address, 0);
2309
- bufferTx.writeUInt16BE(value, 2);
2310
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2311
- if (!frame)
2312
- return;
2313
- validateEchoResponse(frame, unit, fc, bufferTx);
2314
- frame.data = value;
2315
- 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
+ });
2316
2581
  });
2317
2582
  }
2318
2583
  writeFC15;
@@ -2320,20 +2585,36 @@ class ModbusMaster extends EventEmitter {
2320
2585
  const fc = FunctionCode.WRITE_MULTIPLE_COILS;
2321
2586
  const byteCount = Math.ceil(value.length / 8);
2322
2587
  const bufferTx = Buffer.alloc(5 + byteCount);
2323
- bufferTx.writeUInt16BE(address, 0);
2324
- bufferTx.writeUInt16BE(value.length, 2);
2325
- bufferTx.writeUInt8(byteCount, 4);
2326
- value.forEach((v, i) => {
2327
- if (v) {
2328
- 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;
2329
2597
  }
2330
- });
2331
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2332
- if (!frame)
2333
- return;
2334
- validateEchoResponse(frame, unit, fc, bufferTx.subarray(0, 4));
2335
- frame.data = value;
2336
- 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
+ });
2337
2618
  });
2338
2619
  }
2339
2620
  writeFC16;
@@ -2341,53 +2622,102 @@ class ModbusMaster extends EventEmitter {
2341
2622
  const fc = FunctionCode.WRITE_MULTIPLE_REGISTERS;
2342
2623
  const byteCount = value.length * 2;
2343
2624
  const bufferTx = Buffer.allocUnsafe(5 + byteCount);
2344
- bufferTx.writeUInt16BE(address, 0);
2345
- bufferTx.writeUInt16BE(value.length, 2);
2346
- bufferTx.writeUInt8(byteCount, 4);
2347
- value.forEach((v, i) => {
2348
- bufferTx.writeUInt16BE(v, 5 + i * 2);
2349
- });
2350
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2351
- if (!frame)
2352
- return;
2353
- validateEchoResponse(frame, unit, fc, bufferTx.subarray(0, 4));
2354
- frame.data = value;
2355
- 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
+ });
2356
2656
  });
2357
2657
  }
2358
2658
  handleFC17;
2359
2659
  reportServerId(unit, serverIdLength = 1, timeout = this.timeout) {
2360
2660
  const fc = FunctionCode.REPORT_SERVER_ID;
2361
- return this.send(unit, fc, EMPTY_BUFFER, timeout, unit === 0).then((frame) => {
2362
- if (!frame)
2363
- return;
2364
- validateResponse(frame, unit, fc);
2365
- if (frame.data.length < 2 + serverIdLength)
2366
- throw new Error('Insufficient data length');
2367
- if (frame.data.length !== 1 + frame.data[0])
2368
- throw new Error('Invalid response');
2369
- const runStatusIndex = 1 + serverIdLength;
2370
- frame.data = {
2371
- serverId: Array.from(frame.data.subarray(1, runStatusIndex)),
2372
- runIndicatorStatus: frame.data[runStatusIndex] === 0xff,
2373
- additionalData: Array.from(frame.data.subarray(runStatusIndex + 1)),
2374
- };
2375
- 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
+ });
2376
2689
  });
2377
2690
  }
2378
2691
  handleFC22;
2379
2692
  maskWriteRegister(unit, address, andMask, orMask, timeout = this.timeout) {
2380
2693
  const fc = FunctionCode.MASK_WRITE_REGISTER;
2381
2694
  const bufferTx = Buffer.allocUnsafe(6);
2382
- bufferTx.writeUInt16BE(address, 0);
2383
- bufferTx.writeUInt16BE(andMask, 2);
2384
- bufferTx.writeUInt16BE(orMask, 4);
2385
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2386
- if (!frame)
2387
- return;
2388
- validateEchoResponse(frame, unit, fc, bufferTx);
2389
- frame.data = { andMask, orMask };
2390
- 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
+ });
2391
2721
  });
2392
2722
  }
2393
2723
  handleFC23;
@@ -2396,71 +2726,115 @@ class ModbusMaster extends EventEmitter {
2396
2726
  const byteCount = write.value.length * 2;
2397
2727
  const readByteCount = read.length * 2;
2398
2728
  const bufferTx = Buffer.allocUnsafe(9 + byteCount);
2399
- bufferTx.writeUInt16BE(read.address, 0);
2400
- bufferTx.writeUInt16BE(read.length, 2);
2401
- bufferTx.writeUInt16BE(write.address, 4);
2402
- bufferTx.writeUInt16BE(write.value.length, 6);
2403
- bufferTx.writeUInt8(byteCount, 8);
2404
- write.value.forEach((v, i) => {
2405
- bufferTx.writeUInt16BE(v, 9 + i * 2);
2406
- });
2407
- return this.send(unit, fc, bufferTx, timeout, unit === 0).then((frame) => {
2408
- if (!frame)
2409
- return;
2410
- validateByteCountResponse(frame, unit, fc, readByteCount);
2411
- const bufferRx = frame.data.subarray(1);
2412
- frame.data = Array.from({ length: read.length }, (_, index) => bufferRx.readUInt16BE(index * 2));
2413
- 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
+ });
2414
2773
  });
2415
2774
  }
2416
2775
  handleFC43_14;
2417
2776
  readDeviceIdentification(unit, readDeviceIDCode, objectId, timeout = this.timeout) {
2418
2777
  const fc = FunctionCode.READ_DEVICE_IDENTIFICATION;
2419
- return this.send(unit, fc, Buffer.from([MEI_READ_DEVICE_ID, readDeviceIDCode, objectId]), timeout, unit === 0).then((frame) => {
2420
- if (!frame)
2421
- return;
2422
- validateResponse(frame, unit, fc);
2423
- if (frame.data.length < 6)
2424
- throw new Error('Insufficient data length');
2425
- if (frame.data[0] !== MEI_READ_DEVICE_ID || frame.data[1] !== readDeviceIDCode)
2426
- throw new Error('Invalid response');
2427
- const objects = [];
2428
- let object = [];
2429
- let totalBytes = 0;
2430
- for (const v of frame.data.subarray(6)) {
2431
- switch (object.length) {
2432
- case 0:
2433
- case 1: {
2434
- object.push(v);
2435
- break;
2436
- }
2437
- case 2: {
2438
- object.push([v]);
2439
- break;
2440
- }
2441
- case 3: {
2442
- object[2].push(v);
2443
- if (object[1] === object[2].length) {
2444
- objects.push({ id: object[0], value: Buffer.from(object[2]).toString() });
2445
- totalBytes += 2 + object[1];
2446
- 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;
2447
2819
  }
2448
- break;
2449
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);
2450
2833
  }
2451
- }
2452
- if (objects.length !== frame.data[5])
2453
- throw new Error('Invalid response');
2454
- if (frame.data.length !== 6 + totalBytes)
2455
- throw new Error('Invalid response');
2456
- frame.data = {
2457
- readDeviceIDCode,
2458
- conformityLevel: frame.data[2],
2459
- moreFollows: frame.data[3] === 0xff,
2460
- nextObjectId: frame.data[4],
2461
- objects,
2462
- };
2463
- return frame;
2834
+ catch (e) {
2835
+ reject(e);
2836
+ }
2837
+ });
2464
2838
  });
2465
2839
  }
2466
2840
  addCustomFunctionCode(cfc) {
@@ -2473,11 +2847,24 @@ class ModbusMaster extends EventEmitter {
2473
2847
  }
2474
2848
  sendCustomFC(unit, fc, data, timeout = this.timeout) {
2475
2849
  const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
2476
- return this.send(unit, fc, payload, timeout, unit === 0).then((frame) => {
2477
- if (!frame)
2478
- return;
2479
- validateResponse(frame, unit, fc);
2480
- 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
+ });
2481
2868
  });
2482
2869
  }
2483
2870
  /**
@@ -2503,9 +2890,24 @@ class ModbusMaster extends EventEmitter {
2503
2890
  const end = this._queueHead + this._queueLen;
2504
2891
  this._queueLen = 0;
2505
2892
  for (let i = this._queueHead; i < end; i++) {
2506
- this._queueRejects[i](rejectErr);
2893
+ this._queueCallbacks[i](rejectErr);
2507
2894
  }
2508
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();
2509
2911
  let err = null;
2510
2912
  try {
2511
2913
  await promisifyCb((cb) => this._physicalLayer.close(cb));
@@ -2542,7 +2944,9 @@ class ModbusSlave extends EventEmitter {
2542
2944
  _protocol;
2543
2945
  _appLayers = new Map();
2544
2946
  _customFunctionCodes = new Map();
2545
- _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 = [];
2546
2950
  _cleanupFns = new Set();
2547
2951
  _closePromise = null;
2548
2952
  get state() {
@@ -2569,7 +2973,9 @@ class ModbusSlave extends EventEmitter {
2569
2973
  for (const cfc of this._customFunctionCodes.values()) {
2570
2974
  appLayer.addCustomFunctionCode(cfc);
2571
2975
  }
2572
- const cleanupFraming = () => appLayer.off('framing', onFraming);
2976
+ const cleanupFraming = () => {
2977
+ appLayer.onFraming = NOOP;
2978
+ };
2573
2979
  const onFraming = (frame) => {
2574
2980
  if (this._physicalLayer.state !== PhysicalState.OPEN) {
2575
2981
  return;
@@ -2584,7 +2990,7 @@ class ModbusSlave extends EventEmitter {
2584
2990
  appLayerData.queues.frames.push(frame);
2585
2991
  this._drain(appLayer, appLayerData.queues);
2586
2992
  };
2587
- appLayer.on('framing', onFraming);
2993
+ appLayer.onFraming = onFraming;
2588
2994
  this._cleanupFns.add(cleanupFraming);
2589
2995
  const cleanupClose = () => connection.off('close', onClose);
2590
2996
  const onClose = () => {
@@ -2651,12 +3057,23 @@ class ModbusSlave extends EventEmitter {
2651
3057
  const byteCount = (length + 7) >> 3;
2652
3058
  const pdu = Buffer.allocUnsafe(byteCount + 1);
2653
3059
  pdu[0] = byteCount;
2654
- 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;
2655
3067
  for (let i = 0; i < length; i++) {
2656
- if (coils[i]) {
2657
- 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;
2658
3073
  }
2659
3074
  }
3075
+ if ((length & 7) !== 0)
3076
+ pdu[out] = acc;
2660
3077
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
2661
3078
  }
2662
3079
  catch (error) {
@@ -2687,12 +3104,19 @@ class ModbusSlave extends EventEmitter {
2687
3104
  const byteCount = (length + 7) >> 3;
2688
3105
  const pdu = Buffer.allocUnsafe(byteCount + 1);
2689
3106
  pdu[0] = byteCount;
2690
- pdu.fill(0, 1, byteCount + 1);
3107
+ // Accumulator-based bit pack — see handleFC1 for the rationale.
3108
+ let acc = 0;
3109
+ let out = 1;
2691
3110
  for (let i = 0; i < length; i++) {
2692
- if (discreteInputs[i]) {
2693
- 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;
2694
3116
  }
2695
3117
  }
3118
+ if ((length & 7) !== 0)
3119
+ pdu[out] = acc;
2696
3120
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
2697
3121
  }
2698
3122
  catch (error) {
@@ -2722,8 +3146,14 @@ class ModbusSlave extends EventEmitter {
2722
3146
  const registers = await model.readHoldingRegisters(address, length);
2723
3147
  const pdu = Buffer.allocUnsafe(length * 2 + 1);
2724
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.
2725
3152
  for (let i = 0; i < length; i++) {
2726
- 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;
2727
3157
  }
2728
3158
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
2729
3159
  }
@@ -2754,8 +3184,12 @@ class ModbusSlave extends EventEmitter {
2754
3184
  const registers = await model.readInputRegisters(address, length);
2755
3185
  const pdu = Buffer.allocUnsafe(length * 2 + 1);
2756
3186
  pdu[0] = length * 2;
3187
+ // Inline big-endian write — see handleFC3 for the rationale.
2757
3188
  for (let i = 0; i < length; i++) {
2758
- 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;
2759
3193
  }
2760
3194
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
2761
3195
  }
@@ -2835,6 +3269,10 @@ class ModbusSlave extends EventEmitter {
2835
3269
  }
2836
3270
  const value = new Array(length);
2837
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).
2838
3276
  value[i] = (frame.data[5 + Math.floor(i / 8)] & (1 << i % 8)) > 0;
2839
3277
  }
2840
3278
  try {
@@ -2939,7 +3377,7 @@ class ModbusSlave extends EventEmitter {
2939
3377
  return;
2940
3378
  }
2941
3379
  try {
2942
- await this._withAddressLock([address], async () => {
3380
+ await this._withIntervalLock(address, address + 1, async () => {
2943
3381
  if (model.maskWriteRegister) {
2944
3382
  await model.maskWriteRegister(address, andMask, orMask);
2945
3383
  }
@@ -2989,8 +3427,7 @@ class ModbusSlave extends EventEmitter {
2989
3427
  value[i] = (frame.data[9 + i * 2] << 8) | frame.data[10 + i * 2];
2990
3428
  }
2991
3429
  try {
2992
- const writeAddresses = Array.from({ length: length.write }, (_, i) => address.write + i);
2993
- await this._withAddressLock(writeAddresses, async () => {
3430
+ await this._withIntervalLock(address.write, address.write + length.write, async () => {
2994
3431
  if (model.writeMultipleRegisters) {
2995
3432
  await model.writeMultipleRegisters(address.write, value);
2996
3433
  }
@@ -3003,8 +3440,12 @@ class ModbusSlave extends EventEmitter {
3003
3440
  const registers = await model.readHoldingRegisters(address.read, length.read);
3004
3441
  const pdu = Buffer.allocUnsafe(length.read * 2 + 1);
3005
3442
  pdu[0] = length.read * 2;
3443
+ // Inline big-endian write — see handleFC3 for the rationale.
3006
3444
  for (let i = 0; i < length.read; i++) {
3007
- 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;
3008
3449
  }
3009
3450
  await response(appLayer.encode(frame.unit, frame.fc, pdu, frame.transaction));
3010
3451
  }
@@ -3163,7 +3604,11 @@ class ModbusSlave extends EventEmitter {
3163
3604
  return Promise.resolve();
3164
3605
  }
3165
3606
  return new Promise((resolve) => {
3166
- 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);
3167
3612
  });
3168
3613
  };
3169
3614
  // Hot path: unicast to a known unit dispatches to a single model.
@@ -3212,24 +3657,66 @@ class ModbusSlave extends EventEmitter {
3212
3657
  return true;
3213
3658
  }
3214
3659
  }
3215
- async _withAddressLock(addresses, fn) {
3216
- const sorted = [...new Set(addresses)].sort((a, b) => a - b);
3217
- const previous = sorted.map((addr) => this._locks.get(addr) ?? Promise.resolve());
3218
- const work = Promise.all(previous).then(() => fn());
3219
- const cleanup = work.catch(() => {
3220
- /* ignore */
3221
- });
3222
- for (const addr of sorted) {
3223
- 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);
3224
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);
3225
3709
  try {
3226
3710
  return await work;
3227
3711
  }
3228
3712
  finally {
3229
- for (const addr of sorted) {
3230
- if (this._locks.get(addr) === cleanup) {
3231
- this._locks.delete(addr);
3232
- }
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();
3233
3720
  }
3234
3721
  }
3235
3722
  }
@@ -3346,4 +3833,4 @@ class ModbusSlave extends EventEmitter {
3346
3833
  }
3347
3834
  }
3348
3835
 
3349
- 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 };