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