npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

modbus-connect

v4.5.0

Published

Modbus RTU/TCP over Web Serial

Downloads

606

Readme

banner

modbus-connect

TypeScript npm downloads PRs Welcome Contributors License MIT GitHub stars

modbus-connect is a cross-platform library for Modbus RTU/TCP communication in both Node.js and modern browsers

Features

  • Isomorphism: Works in Node.js and modern browsers
  • Security (Mutex): Eliminates collisions between background polling and manual commands
  • Polling Manager: A queue of tasks with priorities, delays and exponential backoff
  • Smart reconnect: Automatic connection recovery for Serial and TCP/IP
  • Emulator: Full-fiedged TCP-slave and RTU-slave for testing without hardware
  • Auto Discovery (Scanner): Ultra-fast device discovery with adaptive mathematical timeouts and parallel TCP scanning.

Documentation

Install

Using NPM:

$ npm install modbus-connect

Using YARN:

$ yarn add modbus-connect

Usage

// Types library
import { _type_ } from 'modbus-connect/types';

// Main Modbus Client
import ModbusClient from 'modbus-client/client';

// Transport Controller for managing connections
import TransportController from 'modbus-connect/transport';

Node RTU connection Example

import TransportController from 'modbus-connect/transport';
import ModbusClient from 'modbus-connect/client';

const SLAVE_ID = 92;
const TRANSPORT_ID = 'TEST_RTU';

async function main() {
  const controller = new TransportController();
  await controller.addTransport(TRANSPORT_ID, 'node-rtu', {
    path: '/dev/tty.usbserial-01AB5F6D',
    baudRate: 9600,
    dataBits: 8,
    stopBits: 1,
    parity: 'none',
    writeTimeout: 500,
    readTimeout: 500,
    slaveIds: [SLAVE_ID],
  });

  await controller.connectTransport(TRANSPORT_ID);

  const client = new ModbusClient(controller, SLAVE_ID, {
    framing: 'rtu',
    timeout: 3000,
  });

  await new Promise(r => setTimeout(r, 250));

  const pollingTask = {
    id: 'task-read-holding-registers',
    interval: 1000,
    fn: async () => {
      return await client.readHoldingRegisters(0, 2);
    },
    onData: data => {
      console.log(data);
    },
    onError: err => {
      console.error(err.message ?? err);
    },
  };

  controller.addPollingTask(TRANSPORT_ID, pollingTask);
}

Expected result:

phk_mvn@MacBook-Air-Danila modbus-connect % node test-rtu.js
[14:20:01] INFO: [Transport Controller] Transport "TEST_RTU" added with PollingManager
[14:20:01] INFO: [Transport Controller] Transport "TEST_RTU" connected
[14:20:01] INFO: [Polling Manager] Task added -> task-read-holding-registers
[ [ 1024, 2048 ] ]
[14:20:02] INFO: [ModbusClient][ID:92] Response received 45ms
[ [ 1024, 2048 ] ]
[14:20:03] INFO: [ModbusClient][ID:92] Response received 42ms
...

Node TCP connection Example

import TransportController from 'modbus-connect/transport';
import ModbusClient from 'modbus-connect/client';

const SLAVE_ID = 92;
const TRANSPORT_ID = 'TEST_TCP';

async function main() {
  const controller = new TransportController();

  await controller.addTransport(TRANSPORT_ID, 'node-tcp', {
    host: '10.59.43.96',
    port: 502,
    readTimeout: 2000,
    writeTimeout: 1000,
    maxBufferSize: 4096,
    reconnectInterval: 5000,
    maxReconnectAttempts: Infinity,
    slaveIds: [SLAVE_ID],
  });

  await controller.connectTransport(TRANSPORT_ID);

  const client = new ModbusClient(controller, SLAVE_ID, {
    framing: 'tcp',
    timeout: 3000,
  });

  await new Promise(r => setTimeout(r, 250));

  const pollingTask = {
    id: 'task-read-holding-registers',
    interval: 1000,
    fn: async () => {
      return await client.readHoldingRegisters(0, 4);
    },
    onData: data => {
      console.log(data);
    },
    onError: err => {
      console.error(err.message ?? err);
    },
  };

  controller.addPollingTask(TRANSPORT_ID, pollingTask);
}

Expected result:

phk_mvn@MacBook-Air-Danila modbus-connect % node test.js
[04:04:57] INFO: [Transport Controller] Transport "TEST_TCP" added with PollingManager
[04:04:57] INFO: [Node TCP] Connecting to 10.59.43.96:502...
[04:04:57] INFO: [Transport Controller] Transport "TEST_TCP" connected
[04:04:57] INFO: [Node TCP] SUCCESS: Connected to 10.59.43.96:502
[04:04:57] INFO: [Polling Manager] Task added -> task-read-holding-registers
[ [ 4114, 35714, 1986, 0 ] ]
[04:04:57] INFO: [ModbusClient][ID:92] Response received 13ms
[ [ 4114, 35714, 1986, 0 ] ]
[04:04:58] INFO: [ModbusClient][ID:92] Response received 11ms
[ [ 4114, 35714, 1986, 0 ] ]
[04:04:59] INFO: [ModbusClient][ID:92] Response received 12ms
...

Web RTU (browser) connection Example

To use Modbus in the browser, you must first obtain a port using the Web Serial API. Note that this code must be triggered by a user gesture (e.g., a button click).

import TransportController from 'modbus-connect/transport';
import ModbusClient from 'modbus-connect/client';

const SLAVE_ID = 1;
const TRANSPORT_ID = 'WEB_SERIAL_RTU';

async function startModbus() {
  // 1. Request port from user
  const port = await navigator.serial.requestPort();

  const controller = new TransportController();

  // 2. Add transport with 'web-rtu' type
  await controller.addTransport(TRANSPORT_ID, 'web-rtu', {
    port, // Pass the native WebSerial port object
    baudRate: 9600,
    dataBits: 8,
    stopBits: 1,
    parity: 'none',
    slaveIds: [SLAVE_ID],
  });

  await controller.connectTransport(TRANSPORT_ID);

  const client = new ModbusClient(controller, SLAVE_ID, {
    framing: 'rtu',
    timeout: 2000,
  });

  // 3. Setup Polling
  controller.addPollingTask(TRANSPORT_ID, {
    id: 'web-task-coils',
    interval: 2000,
    fn: async () => {
      return await client.readCoils(0, 8);
    },
    onData: data => {
      console.log('Coils status:', data[0]);
    },
    onError: err => {
      console.error('Web Serial Error:', err.message);
    },
  });
}

Expected result:

[14:25:10] INFO: [Transport Controller] Transport "WEB_SERIAL_RTU" added with PollingManager
[14:25:10] INFO: [Web RTU] Port opened successfully
[14:25:10] INFO: [Transport Controller] Transport "WEB_SERIAL_RTU" connected
[14:25:10] INFO: [Polling Manager] Task added -> web-task-coils
Coils status: [true, false, true, true, false, false, false, true]
[14:25:11] INFO: [ModbusClient][ID:1] Response received 85ms
...

Emulator Node RTU / TCP connection

import TransportController from 'modbus-connect/transport';
import ModbusClient from 'modbus-connect/client';

const SLAVE_ID = 92;
const TRANSPORT_ID = 'TEST_TCP';

async function main() {
  const controller = new TransportController();
  await controller.addTransport(
    'emulator-1',
    'rtu-emulator', // or 'tcp-emulator'
    {
      slaveId: 1,
      responseLatencyMs: 30,
      initialRegisters: {
        holding: [
          { start: 100, value: 1234 },
          { start: 101, value: 5678 },
        ],
        coils: [{ start: 0, value: true }],
      },
    }
  );

  await controller.connectTransport('emulator-1');

  // Client configuration depending on the type of emulator
  const client = new ModbusClient(controller, 1, {
    framing: 'rtu', // or 'tcp'
    timeout: 3000,
    retryCount: 1,
  });

  // Polling task (will work with any emulator)
  controller.addPollingTask('emulator-1', {
    id: 'task1',
    interval: 1000,
    fn: async () => {
      return await client.readHoldingRegisters(100, 2);
    },
    onData: data => {
      console.log(data);
    },
    onError: err => {
      console.error('Polling error:', err.message);
    },
  });
}

Expected result:

phk_mvn@MacBook-Air-Danila modbus-connect % node test.js
[04:04:52] INFO: [Polling Manager] Task added -> task1
[04:04:52] INFO: [Transport Controller] Transport "emulator-1" added with PollingManager
[04:04:52] INFO: [ModbusSlaveCore] ModbusSlaveCore initialized successfully (Slave ID: 1)
[04:04:52] INFO: [RTU Emulator] RTU Emulator connected
[04:04:52] INFO: [ModbusSlaveCore] Registers added successfully: {"coils":1,"discrete":0,"holding":2,"input":0}
[04:04:52] INFO: [Transport Controller] Transport "emulator-1" connected
[ [ 1234, 5678 ] ]
[04:04:52] INFO: [ModbusClient][ID:1] Response received 32ms
[ [ 1234, 5678 ] ]
[04:04:53] INFO: [ModbusClient][ID:1] Response received 32ms
[ [ 1234, 5678 ] ]
[04:04:54] INFO: [ModbusClient][ID:1] Response received 32ms

TransportController

The TransportController is the central link of the library that manages the lifecycle of all connections (Node.js Serial, WebSerial, TCP, Emulators). It is responsible for routing Modbus requests to the required ports, load balancing, device health monitoring, and background polling management.

Subtleties and features of the work

  • Thread Safety (Mutex): All operations to change the transport registry (add, remove, reload) are protected by an internal mutex. This eliminates "data races" with simultaneous asynchronous calls.
  • Mode Restrictions (RSMode):
  • RS485/ TCP/IP: Allows you to connect an unlimited number of Slave IDs to a single transport.
    • RS232: Strictly limited to one device per port. When trying to bind a second Slave ID, the controller will throw an RSModeConstraintError.
  • Automatic deletion: If you delete the last Slave ID from the transport using removeSlaveIdFromTransport, the controller will automatically stop and delete this transport from memory.
  • Secure Events: When port or device events occur (connection/disconnection), the controller updates the status trackers under the mutex, but calls your callback functions outside of it. This ensures that there are no Deadlocks.
  • Async Routing: assignSlaveIdToTransport and removeSlaveIdFromTransport are now properly async — always await them to avoid race conditions.

Transport management methods

addTransport()

Adds and initializes a new transport. Creates a personal PollingManager for it.

Note: The options object also accepts slaveIds: number[] to auto-assign devices, and for node-rtu you can use path as an alias for port. These are convenience options extracted at runtime even though they aren't in the TypeScript transport option interfaces.

Example:

await controller.addTransport('RS485_BUS', 'node-rtu', {
  path: '/dev/ttyUSB0',
  baudRate: 9600,
  RSMode: 'RS485',
  slaveIds: [1, 2],
});

Expected result:

[14:00:01] INFO: [Transport Controller] Transport "RS485_BUS" added with PollingManager

connectTransport()

Opens a physical connection for a specific transport and starts polling tasks.

Example:

await controller.connectTransport('RS485_BUS');

Expected result:

[14:00:02] INFO: [Node RTU] Serial port /dev/ttyUSB0 opened
[14:00:02] INFO: [Transport Controller] Transport "RS485_BUS" connected

reloadTransport()

"Hot" replacement of transport. It is useful for changing settings (for example, IP addresses) without losing the associated Slave ID and event handlers.

Example:

await controller.reloadTransport('RS485_BUS', {
  path: '/dev/ttyUSB0',
  baudRate: 115200, // Changing the speed
});

Expected result:

[14:05:10] INFO: [Transport Controller] Transport "RS485_BUS" disconnected
[14:05:10] INFO: [Transport Controller] Transport "RS485_BUS" reloaded with new options
[14:05:11] INFO: [Transport Controller] Transport "RS485_BUS" connected

Routing control methods (Slave ID's)

assignSlaveIdToTransport()

Binds an additional Slave ID to an existing transport.

Example:

await controller.assignSlaveIdToTransport('RS485_BUS', 10);

Expected result:

[14:10:00] INFO: [Transport Controller] Assigned slaveId 10 to transport "RS485_BUS"

removeSlaveIdFromTransport()

Unlinks the device from the communication channel.

Example:

// If there was only a slave 10 on the RS485_BUS transport:
await controller.removeSlaveIdFromTransport('RS485_BUS', 10);

Expected result:

[14:15:00] INFO: [Transport Controller] Removed slaveId 10 from transport "RS485_BUS"
[14:15:00] INFO: [Transport Controller] Transport "RS485_BUS" is empty. Auto-removing...
[14:15:00] INFO: [Transport Controller] Transport "RS485_BUS" disconnected
[14:15:00] INFO: [Transport Controller] Transport "RS485_BUS" fully removed and cleaned up

Background Polling Management (Proxy)

addPollingTask()

Adds the task of cyclic register reading for a specific transport.

Example:

controller.addPollingTask('RS485_BUS', {
  id: 'read-holding',
  interval: 2000,
  fn: () => client.readHoldingRegisters(100, 5),
  onData: data => console.log('Data:', data),
});

Expected result:

[14:20:00] INFO: [Polling Manager] Task added -> read-holding

controlTask() / controlPolling()

Starts, stops, pauses, or resumes polling tasks (IMP-7). Accepts plain strings or EPollingAction/EPollingBulkAction enums. Methods now throw an error if the transport is not found (BUG-5 fix).

Using plain strings (no import needed):

// Single task control
controller.controlTask('RS485_BUS', 'read-holding', 'pause');
controller.controlTask('RS485_BUS', 'read-holding', 'resume');
controller.controlTask('RS485_BUS', 'read-holding', 'stop');
controller.controlTask('RS485_BUS', 'read-holding', 'start');

// Bulk control for all tasks on a transport
controller.controlPolling('RS485_BUS', 'pauseAll');
controller.controlPolling('RS485_BUS', 'resumeAll');
controller.controlPolling('RS485_BUS', 'stopAll');
controller.controlPolling('RS485_BUS', 'startAll');

Using typed enums (optional, for IDE autocomplete):

const { EPollingAction, EPollingBulkAction } = require('modbus-connect/types');

controller.controlTask('RS485_BUS', 'read-holding', EPollingAction.Pause);
controller.controlPolling('RS485_BUS', EPollingBulkAction.ResumeAll);

executeImmediate()

A method for executing extraordinary commands (for example, recording at the touch of a button). Ensures that the request does not "collide" with background polling.

Example:

await controller.executeImmediate('RS485_BUS', async () => {
  return await client.writeSingleRegister(10, 1);
});

Expected result:

[14:22:05] INFO: [ModbusClient][ID:1] Response received 50ms

Diagnostics and Status

getStatus()

Returns the current status of one or all transports.

Example:

const status = controller.getStatus('RS485_BUS');
console.log(status);

Expected result:

{
    id: 'RS485_BUS',
    connected: true,
    lastError: undefined,
    connectedSlaveIds: [1, 2, 10],
    uptime: 125000,
    reconnectAttempts: 0
}

getActiveTransportCount()

Returns the number of transports that are currently successfully connected.

Example:

console.log('Active lines:', controller.getActiveTransportCount());

writeToPort()

Low-level method for writing raw bytes directly to a transport's port and optionally reading the response. The operation is protected by the polling mutex, so it won't collide with background tasks.

Example:

const response = await controller.writeToPort('RS485_BUS', rawAduBytes, 10, 3000);

destroy()

Gracefully shuts down the entire controller: stops all polling, disconnects all transports, and releases all resources.

Example:

await controller.destroy();

Event tracking

setDeviceStateHandlerForTransport()

Lets you know when a particular Slave ID on the line stopped responding (or reappeared). It uses a debounce mechanism to eliminate false alarms in case of single interference.

Example:

await controller.setDeviceStateHandlerForTransport('RS485_BUS', (slaveId, connected, error) => {
  const status = connected ? 'online' : `disabled (${error.message})`;
  console.log(`[Event] Device ${slaveId} is now ${status}`);
});

Expected result (In case of disconnection):

[14:30:05] WARN: [DeviceConnectionTracker] Device 1: OFFLINE (Timeout)
[Event] Device 1 is now disabled (Modbus request timed out)

Modbus Scanner

The ModbusScanner is a high-performance tool built into the TransportController that allows you to discover Modbus devices on a line or network without knowing their exact settings.

Key Features

  • Scan Profiles: Mandatory profile parameter ('quick', 'deep', 'custom') that presets baud rates, parities, concurrency, and timeouts. User options override profile defaults.
  • Adaptive Turbo Mode: Automatically calculates the minimum physical timeout based on the Baud Rate. (e.g., ~14ms for 115200 baud).
  • Isomorphic RTU Scanning: Automatically detects if you are in Node.js or a Browser environment.
  • High-Concurrency TCP Scanning: Scans multiple Unit IDs in parallel with configurable concurrency and timeout.
  • Lifecycle Control: Ability to pause, resume, stop, and reset the scanning process programmatically. Supports AbortSignal for external cancellation.
  • Scan Statistics: Returns IScanReport with discovered devices and detailed IScanStats (duration, probes sent, timeouts, CRC errors, exception responses).
  • Traffic Sniffer Integration: When sniffer is enabled on the controller, scan traffic is automatically captured for analysis.
  • Multi-Baud Discovery: Optional multiBaud flag allows reporting devices found on multiple baud rates (default deduplicates by slaveId).
  • Per-Unit Progress: TCP scan reports progress for each individual Unit ID, not just per batch.

Scan Profiles

The profile parameter is mandatory and provides sensible defaults. You can still override any field by providing it explicitly.

| Profile | Baud Rates | Parities | Stop Bits | Concurrency | TCP Timeout | Padding | | -------- | ------------------------------------------ | ---------------------------- | --------- | ----------- | ----------- | ------- | | quick | 115200, 57600, 38400, 19200, 9600 | none, even | 1, 2 | 100 | 200ms | 5ms | | deep | 115200, 57600, 38400, 19200, 9600, ...1200 | none, even, odd, mark, space | 1, 2 | 25 | 500ms | 10ms | | custom | — (you must provide all options) | — | — | — | — | — |


Callback System

The scanner uses a reactive callback system to provide real-time feedback, making it ideal for building smooth User Interfaces.

  • onDeviceFound(device): Triggered immediately when a device is verified. You can use this to populate a list in your UI as the scan progresses.
  • onProgress(current, total, info): Triggered on every request attempt.
    • current: Current attempt index.
    • total: Total planned attempts.
    • info: Object containing current scan parameters (RTU: { baud, parity, stopBits, slaveId }, TCP: { host, port, unitId }).
  • onFinish(results): Triggered when the entire scan process is complete. Returns an array of all discovered IScanResult objects.
  • onStats(stats): Triggered when scan finishes with detailed statistics (durationMs, probesSent, timeouts, crcErrors, exceptionResponses).
  • onRegisterRead(slaveId, registerAddress, value): Triggered on every successful register read during scanning. Returns the slave ID, the register address being probed, and the raw 16-bit value read from the device. Only fires on successful responses — timeouts, CRC errors, and exception responses do not trigger this callback.

Scanning Options (IScanOptions)

| Property | Type | Description | | ----------------- | ------------------------------------------- | -------------------------------------------------------------------------------------- | | profile | 'quick' \| 'deep' \| 'custom' | Required. Scan profile that provides default values. | | path | string or IWebSerialPort | Serial port path (Node) or SerialPort object (Web). | | bauds | number[] | List of baud rates to check. Overridden by profile defaults unless custom. | | parities | TParityType[] | List of parities to check. Overridden by profile defaults unless custom. | | slaveIds | number[] | Range of Slave IDs to check (1-247). | | unitIds | number[] | Range of Unit IDs to check for TCP scan. | | registerAddress | number | The register address to read for verification. Default: 0. | | controller | ScanController | An instance of ScanController to manage the scan externally. | | concurrency | number | TCP parallel request count. Default from profile. | | tcpTimeout | number | TCP response timeout in ms. Default from profile. | | multiBaud | boolean | Report devices on multiple baud rates (default: false, dedup by slaveId). | | signal | AbortSignal | Standard AbortSignal for external scan cancellation. | | stopBitsList | (1 or 2)[] | List of stop bits to scan (RTU only). Default from profile: [1, 2]. | | padding | number | Extra ms padding for RTU timeout. Default from profile. | | onRegisterRead | (slaveId, registerAddress, value) => void | Callback fired on each successful register read during scan. Returns raw 16-bit value. |


Scanning Methods

scanRtuPort(options)

This method iterates through the matrix of Baud Rates and Parities. Once a device is found, it "locks" the port settings and quickly scans the remaining Slave IDs. Returns IScanReport with results and statistics.

Example:

const report = await controller.scanRtuPort({
  profile: 'quick',
  path: '/dev/ttyUSB0', // In Browser, pass the port object here
  bauds: [9600, 115200], // Overrides profile defaults

  onDeviceFound: device => {
    console.log(
      `New device discovered! ID: ${device.slaveId} @ ${device.baudRate}bps parity:${device.parity} stopBits:${device.stopBits}`
    );
  },

  onProgress: (current, total, info) => {
    const percent = Math.round((current / total) * 100);
    process.stdout.write(
      `Scanning ${info.baud}bps | ${info.parity} | ${info.stopBits}SB | ID: ${info.slaveId} [${percent}%]\r`
    );
  },

  onFinish: allDevices => {
    console.log(`\nScan complete. Total devices found: ${allDevices.length}`);
  },

  onRegisterRead: (slaveId, registerAddress, value) => {
    console.log(`Register read: slaveId=${slaveId} addr=${registerAddress} value=${value}`);
  },
});

console.log('Stats:', report.stats);
// Stats: { durationMs: 12450, probesSent: 2470, timeouts: 2465, crcErrors: 0, exceptionResponses: 5 }

scanTcpPort(options)

Uses high-concurrency parallel requests to map a TCP gateway or a subnetwork. Returns IScanReport.

Example:

const report = await controller.scanTcpPort({
  profile: 'deep',
  hosts: ['192.168.1.100'],
  ports: [502],
  unitIds: Array.from({ length: 255 }, (_, i) => i + 1),

  onDeviceFound: device => {
    console.log(`Found TCP Unit: ${device.slaveId} at ${device.host}`);
  },

  onStats: stats => {
    console.log(`Scan took ${stats.durationMs}ms, ${stats.probesSent} probes sent`);
  },
});

console.log('Discovered devices:', report.results);

Scanner Control

If you need to manage the scan process (e.g., from a UI), use these methods:

| Method | Description | | -------------- | -------------------------------------------------------- | | pauseScan() | Suspends the current scan at the next iteration. | | resumeScan() | Resumes a previously paused scan. | | stopScan() | Immediately stops the scan and releases the port/socket. |

You can also use AbortController for external cancellation:

const abortCtrl = new AbortController();

const report = controller.scanRtuPort({
  profile: 'quick',
  path: '/dev/ttyUSB0',
  signal: abortCtrl.signal,
});

// Cancel from outside:
setTimeout(() => abortCtrl.abort(), 5000);

Scan Report (IScanReport)

Both scanRtuPort and scanTcpPort return an IScanReport:

{
  results: IScanResult[];
  stats: IScanStats;
}

Scan Result (IScanResult)

{
  type: 'node-rtu' | 'web-rtu' | 'node-tcp';
  slaveId: number;     // The Modbus address found
  baudRate?: number;   // (RTU only) The working baud rate
  parity?: string;     // (RTU only) none, even, or odd
  stopBits?: 1 | 2;    // (RTU only) Actual stop bits used during scan
  port?: string;       // The physical port path
  host?: string;       // (TCP only) The device IP
  tcpPort?: number;    // (TCP only) The device Port
  discoveredAt: number;// Unix timestamp when device was discovered
}

Scan Statistics (IScanStats)

{
  durationMs: number; // Total scan duration
  probesSent: number; // Total Modbus requests sent
  timeouts: number; // Requests that timed out
  crcErrors: number; // CRC validation failures (RTU)
  exceptionResponses: number; // Modbus exception responses (device exists but refused)
}

Traffic Sniffer

The sniffer instance is available via the controller.sniffer property. It uses an Observer pattern (subscription-based) to deliver data.

Methods:

| Method | Description | Returns | | ------------------------ | --------------------------------------------------------------------- | ----------------------------------- | | onPacket(handler) | Subscribes to the stream of individual packets (TX and RX separately) | () => void (Unsubscribe function) | | onTransaction(handler) | Subscribes to completed transactions (Paired Request + Response) | () => void (Unsubscribe function) |


Transaction Structure (ITransaction)

Unlike individual packets, a transaction represents a full Modbus cycle. This is the best way to monitor device health and latency.

| Property | Type | Description | | ------------- | ---------------------------------- | ------------------------------------------------------------------------- | | id | string | Unique alphanumeric ID for the transaction. | | transportId | string | Identifier of the transport channel. | | protocol | 'rtu' or 'tcp' | The protocol used for this exchange. | | request | ISnifferPacket | The complete request packet (TX). | | response | ISnifferPacket or null | The complete response packet (RX). null if a timeout occurred. | | status | 'ok' or 'error' or 'timeout' | Transaction result: ok (success), error (exception/CRC), timeout. | | durationMs | number | Round-trip time (RTT) from start of TX to end of RX. | | error | string | Error message (e.g., "Response Timeout" or Modbus Exception description). | | timestamp | number | Unix timestamp of when the transaction was completed. |


Packet Structure (ISnifferPacket):

Every packet (tx or rx) captured by the sniffer:

| Property | Type | Description | | ------------- | ---------------- | ----------------------------------------------------------------- | | id | string | Unique alphanumeric ID for the transaction. | | transportId | string | Identifier of the transport (e.g., COM port path or IP:Port). | | direction | 'tx' or 'rx' | Direction: tx (Sent request), rx (Received response). | | timestamp | number | Precise high-resolution timestamp (performance.now()). | | raw | Uint8Array | The actual raw bytes of the packet. | | hex | string | Formatted HEX string (e.g., "7A 03 00 01"). | | ascii | string | ASCII representation (non-printable characters replaced by dots). | | analysis | object | Deep protocol analysis (see below). | | meta | object | Performance metrics and status (see below). |


onTransaction Usage Example

const controller = new TransportController({ sniffer: true });

controller.sniffer.onTransaction(tx => {
  const { status, durationMs, request, response, transportId } = tx;

  if (status === 'timeout') {
    console.log(
      `\x1b[31m[TIMEOUT]\x1b[0m ${transportId} -> Device ${request.analysis.slaveId} didn't respond`
    );
    return;
  }

  const color = status === 'ok' ? '\x1b[32m' : '\x1b[31m'; // Green for OK, Red for Error

  console.log(`${color}===[${status.toUpperCase()}] [${transportId}] [${durationMs}ms]===\x1b[0m`);
  console.log(
    `  Req: Slave ${request.analysis.slaveId} | Func 0x${request.analysis.funcCode.toString(16)}`
  );

  if (response) {
    console.log(`  Res: ${response.analysis.description}`);
    console.log(`  CRC: ${response.analysis.crcValid ? 'VALID' : 'INVALID'}`);
  }
});

Meta Structure (ISnifferPacket.meta):

| Property | Type | Description | | ---------------- | --------- | ----------------------------------------------------------------------- | | latencyMs | number | Time between TX completion and first byte of RX (Device reaction time). | | transferMs | number | Time taken to receive the entire packet (Physical transmission time). | | totalMs | number | Full transaction cycle time (latency + transfer). | | bytesPerSecond | number | Calculated throughput speed of the line. | | isFragment | boolean | true if the packet is part of a larger chunk (internal use). | | error | string | Optional transport-level error message. |


onPacket() Usage Example

const controller = new TransportController({ sniffer: true });

// Subscribe to packets
controller.sniffer.onPacket(packet => {
  // Ignore RX fragments (wait for fully reassembled packets)
  if (packet.direction === 'rx' && packet.meta.isFragment) return;

  const { direction, transportId, analysis, meta, hex, ascii } = packet;

  // Style settings
  const color = direction === 'tx' ? '\x1b[36m' : '\x1b[32m'; // Cyan for TX, Green for RX
  const reset = '\x1b[0m';

  console.log(`\n${color}===[${direction.toUpperCase()}] [${transportId}]===${reset}`);

  // Protocol Details
  if (analysis) {
    console.log(`  Protocol: Slave ${analysis.slaveId} | Func 0x${analysis.funcCode.toString(16)}`);
    console.log(`  Summary:  ${analysis.description}`);
  }

  // Data Representations
  console.log(`  HEX:      ${hex}`);
  console.log(`  ASCII:    ${ascii}`);

  // Performance Metrics (for RX)
  if (direction === 'rx') {
    console.log(`  Metrics:`);
    console.log(`    ⏱  Latency:  ${meta.latencyMs} ms`);
    console.log(`    🚀 Transfer: ${meta.transferMs} ms`);
    console.log(`    📊 Bitrate:  ${meta.bytesPerSecond} B/s`);
    console.log(`    🛡  Checksum: ${analysis.crcValid ? 'VALID' : 'INVALID'}`);
  }
});

Why use the Sniffer?

Unlike standard logging, the TrafficSniffer:

  • Zero Impact: It runs asynchronously and doesn't delay your Modbus requests.
  • Sub-millisecond Precision: Uses performance.now() for ultra-accurate timing.
  • Fragment Reassembly: Automatically glues together packets that arrive in multiple chunks (common in Serial/TCP).
  • Error Debugging: Helps identify if a failure is due to device latency, CRC corruption, or transport issues.

ModbusClient

ModbusClient is a high—level interface for communicating with Modbus devices. It manages thread safety via Mutex, implements the logic of retries, and automatically synchronizes with transports in case they are rebooted.


Constructor and Options (IModbusClientOptions)

Constructor Parameters:

  • transportController: An instance of TransportController through which the client finds the desired port.
  • SlaveID: Device address (0-255). The default is 1.
  • options: Configuration object.

Full list of options options:

| Option | Type | Description | | ------------ | ------------------------------------ | ---------------------------------------------------------------------------------- | | framing | 'rtu' or 'tcp' | The type of encoding of the packet. The default is rtu. | | RSMode | 'RS485' or 'RS232' or 'TCP/IP' | Physical layer mode. By default, it is selected based on framing. | | timeout | number | Waiting time for a response from the device (ms). Default: 1000. | | retryCount | number | How many times to repeat the request in case of a communication error. Default: 0. | | retryDelay | number | Delay between retries (ms). Default: 100. | | plugins | TPluginConstructor[] | An array of plugin classes that will be initialized immediately. |

Example of initialization with all parameters:

const client = new ModbusClient(controller, 122, {
  framing: 'rtu',
  RSMode: 'RS485',
  timeout: 3000,
  retryCount: 3,
  retryDelay: 500,
  plugins: [CustomPlugin],
});

Status management and logging methods

enableLogger() / disableLogger()

Enables or completely disables logging for this client.

client.disableLogger(); // There will be no more logs of this client in the console
client.enableLogger(); // Logs are being output again

connect()

Performs a logical check of transport availability for the given Slave ID.

await client.connect();

Expected result:

[12:00:00] INFO: [ModbusClient][ID:122] Client is ready. Transport is connected and available

disconnect()

Logically disables the client and removes its Slave ID from the transport routes in the controller.

await client.disconnect();

Expected result:

[12:00:05] INFO: [ModbusClient][ID:122] Client disconnected and unregistered from transport

setSlaveId(newSlaveId)

Changes the Slave ID address on the fly. All subsequent requests will be sent to the new address.

await client.setSlaveId(10);

Expected result:

[12:00:10] INFO: [ModbusClient][ID:10] Slave ID changed 122 -> 10 3. Read Methods

readCoils(startAddress, quantity, timeout?) (FC 0x01)

Reads the values of the bit flags (Coils).

const coils = await client.readCoils(0, 5);
console.log(coils);

Expected result:

[ModbusClient][ID:122] Response received 45ms
[true, true, true, true, false]

readDiscreteInputs(startAddress, quantity, timeout?) (FC 0x02)

Reads the values of the digital inputs.

const inputs = await client.readDiscreteInputs(100, 3);
console.log(inputs);

Expected result:

[ModbusClient][ID:122] Response received 40ms
[true, true, false]

readHoldingRegisters(startAddress, quantity) (FC 0x03)

Reads the Holding registers. Returns a RegisterData object (extends Array<number>) that supports type conversion and sub-selection.

const regs = await client.readHoldingRegisters(10, 2);
console.log(regs); // [1500, 240] — works like a regular array

Expected result:

[ModbusClient][ID:122] Response received 55ms
[1500, 240]

readInputRegisters(startAddress, quantity) (FC 0x04)

Reads the Input registers. Returns a RegisterData object (extends Array<number>) that supports type conversion and sub-selection.

const inputs = await client.readInputRegisters(0, 1);
console.log(inputs); // [356]

Expected result:

[ModbusClient][ID:122] Response received 48ms
[356]

RegisterData — Type Conversion & Sub-Selection

readHoldingRegisters and readInputRegisters return a RegisterData object instead of a plain number[]. RegisterData extends Array<number>, so all existing code (index access, .length, .map, etc.) continues to work without changes. The new methods allow you to convert raw 16-bit register values into standard numeric types and select specific registers from a block read.


Conversion Methods

| Method | Registers/value | Returns | Description | | ---------------------- | :-------------: | :--------: | ---------------------- | | asUInt16() | 1 | number[] | 0–65535 (identity) | | asInt16() | 1 | number[] | −32768–32767 | | asUInt32(wordOrder) | 2 | number[] | 0–4294967295 | | asInt32(wordOrder) | 2 | number[] | −2147483648–2147483647 | | asFloat32(wordOrder) | 2 | number[] | IEEE 754 single | | asFloat64(wordOrder) | 4 | number[] | IEEE 754 double |

All multi-register methods accept an optional wordOrder parameter: 'BE' (default, Big-Endian / standard Modbus) or 'LE' (Little-Endian / word-swapped). Some devices store 32-bit values with the low word at the lower address — use 'LE' for those.

Scalar shortcuts return a single number (the first converted value):

asUInt16Scalar(), asInt16Scalar(), asUInt32Scalar(wordOrder), asInt32Scalar(wordOrder), asFloat32Scalar(wordOrder), asFloat64Scalar(wordOrder)

Examples:

const regs = await client.readHoldingRegisters(0, 2);

// Float32 from 2 registers
const temp = regs.asFloat32Scalar(); // 23.5

// Int32 with word-swap (LE device)
const pressure = regs.asInt32Scalar('LE');

// Multiple float32 values from 8 registers
const block = await client.readHoldingRegisters(0, 8);
const temps = block.asFloat32(); // [23.5, 1.025, 12.3, -0.5]

Sub-Selection — .sub() and .pick()

When a device stores different parameters at consecutive addresses in different formats, you can read all registers in one request and then extract individual fields:

// One request for 10 registers, then extract individual fields:
const block = await client.readHoldingRegisters(0, 10);

const temperature = block.sub(0, 2).asFloat32Scalar(); // registers 0–1 → float
const pressure = block.sub(2, 2).asFloat32Scalar(); // registers 2–3 → float
const status = block.sub(4).asUInt16Scalar(); // register 4 → uint16
const counter = block.sub(5, 2).asInt32Scalar(); // registers 5–6 → int32
const flow = block.sub(7, 2).asFloat32Scalar(); // registers 7–8 → float
const errorCode = block.sub(9).asInt16Scalar(); // register 9 → int16

.sub(offset, count?) — selects a contiguous range. count defaults to 1 if omitted.

.pick(...indices) — selects arbitrary registers (non-contiguous):

// Pick specific registers scattered across the block
const flags = block.pick(4, 9).asUInt16(); // [1, 0]

Important: When using .pick() with multi-register conversions (asUInt32, asInt32, asFloat32, asFloat64), the order of indices you pass determines the word order. For example, block.pick(0, 1).asFloat32() treats register 0 as the high word and register 1 as the low word (standard BE). But block.pick(1, 0).asFloat32() reverses the words — register 1 becomes the high word and register 0 becomes the low word. This is equivalent to applying a word-swap. Use this intentionally when your device stores values in a non-standard word order.


Methods of writing data (Write Methods)

writeSingleCoil(address, value, timeout?) (FC 0x05)

Writes one bit.

const res = await client.writeSingleCoil(5, true);
console.log(res);

writeSingleRegister(address, value, timeout?) (FC 0x06)

Writes one 16-bit register.

const res = await client.writeSingleRegister(20, 1000);
console.log(res);

writeMultipleCoils(address, values, timeout?) (FC 0x0F)

Records a group of bits.

const res = await client.writeMultipleCoils(0, [true, false, true]);
console.log(res);

writeMultipleRegisters(address, values, timeout?) (FC 0x10)

Writes a group of registers.

const res = await client.writeMultipleRegisters(10, [100, 200, 300]);
console.log(res); // { startAddress: 10, quantity: 3 }

Service and diagnostic methods

reportSlaveId(timeout?) (FC 0x11)

Requests a description of the device.

const info = await client.reportSlaveId();
console.log(info);

Expected result:

[ModbusClient][ID:122] Response received 35ms
{ slaveId: 122, isRunning: true, data: Uint8Array(...) }

readDeviceIdentification(decoder, timeout?) (FC 0x2B)

Reads the device's passport data (Vendor, Model, etc.).

const id = await client.readDeviceIdentification('utf-8');
console.log(id.objects);

Expected result:

[ModbusClient][ID:122] Response received 110ms
{ 0: "VendorName", 1: "ProductCode", 2: "v1.0" }

Plugins and custom features

The library allows you to extend the standard Modbus with manufacturer-specific functions.

How to write a plugin correctly:

A plugin is a class that should have a name property and a customFunctionCodes object. Each function code must contain the buildRequest (PDU assembly) and parseResponse (response parsing) methods.

// Example of a plugin for working with a non-standard
class MyManufacturerPlugin {
  constructor() {
    this.name = 'ManufacturerExtraFunctions';
    this.customFunctionCodes = {
      // The name of the method that we will call via executeCustomFunction
      getFirmwareHash: {
        // Creating the request body (Function Code + Data)
        buildRequest: subCode => {
          const pdu = new Uint8Array(2);
          pdu[0] = 0x64; // Custom function code
          pdu[1] = subCode; // Additional parameter
          return pdu;
        },
        // Parsing the received response PDU
        parseResponse: responsePdu => {
          // Skip the byte of the function [0] and return the data
          return responsePdu.slice(1);
        },
      },
    };
  }
}

Plugin registration:

Method A: Using the constructor (recommended)

const client = new ModbusClient(controller, 1, {
  plugins: [MyManufacturerPlugin],
});

Method B: Using the use() method

const client = new ModbusClient(controller, 1);
client.use(new MyManufacturerPlugin());

Calling a custom function:

// Calling the function by the name specified in the plugin
const hash = await client.executeCustomFunction('getFirmwareHash', 0x01);
console.log('Firmware hash:', hash);

Getters

currentSlaveId

Returns the current address of the device that the client is working with.

console.log(client.currentSlaveId); // 122

Important Mechanisms (Under the hood)

  • _syncProtocol(): The client is a smart shell. Before each request, it checks whether the transport in the TransportController has been restarted (for example, the port path or IP has changed). If the transport is new, the client instantly updates its internal exchange logic without interrupting the program.
  • Mutex Lock: All methods (read, write, custom) are protected by a single mutex. If you call 5 reading methods at the same time, they will line up in a strict queue. This ensures that packets do not get mixed up in the communication channel.
  • Retry Logic:
    • If the device does not respond or the data is corrupted (CRC Error), the client will automatically retry (retryCount).
    • If the device has responded with a Modbus Exception (for example, Illegal Function), repeated attempts are not performed, as this is a logical error, not a physical failure.

PollingManager

PollingManager is a scheduler that automates polling of Modbus devices. It manages queues based on priorities, handles communication errors through a delay system, and ensures that background tasks do not conflict with manual commands.


Configuration (IPollingManagerConfig)

These parameters are set when creating the manager and are applied to all tasks by default.

| Parameter | Type | Description | | --------------------- | -------- | -------------------------------------------------------------- | | defaultMaxRetries | number | Number of attempts in case of failure (default: 3). | | defaultBackoffDelay | number | Base delay between attempts in ms (default: 1000). | | defaultTaskTimeout | number | The timeout of one operation in ms (default: 5000). | | interTaskDelay | number | Pause between different tasks in the queue in ms (default: 0). |


Task registration (addTask)

The addTask method accepts an IPollingTaskOptions object. All possible parameters are shown here.

manager.addTask({
  // Basic settings
  id: 'main-sensor-poll',
  name: 'Temperature sensor query',
  priority: 10, // High priority (0 - low)
  interval: 2000, // Every 2 seconds
  fn: [
    // Array of functions (executed in turn)
    async () => await client.readHoldingRegisters(0, 2),
    async () => await client.readHoldingRegisters(10, 1),
  ],
  immediate: true, // Start immediately when adding
  shouldRun: () => true, // Check before each cycle (should I run?)

  // Redefining the retray settings for this specific task
  maxRetries: 2,
  backoffDelay: 500,
  taskTimeout: 3000,

  // Life Cycle Callbacks
  onStart: () => console.log('>>> Task started'),
  onStop: () => console.log('>>> Task stopped'),
  onBeforeEach: () => console.log('>>> Preparing for request...'),
  onData: data => console.log('>>> Raw data received:', data),
  onSuccess: results => console.log('>>> Cycle completed successfully:', results),
  onFailure: err => console.error('>>> Critical issue failure:', err.message),
  onRetry: (err, idx, count) => console.warn(`>>> Retry function ${idx}, attempt ${count}`),
  onError: (err, idx, count) => console.error(`>>> Function ${idx} failed after ${count} attempts'),
  onFinish: (success, results) => console.log('>>> Iteration completed. Success:', success),
});

Expected result:

[10:00:00] INFO: [Polling Manager] Task added -> main-sensor-poll
>>> The task is running
[10:00:00] DEBUG: [Task][taskId:main-sensor-poll] Executing task
>>> Preparing for the request...
[10:00:00] INFO: [ModbusClient][ID:1] Response received 50ms
>>> Raw data received: [[123, 456], [1]]
>>> Cycle completed successfully: [[123, 456], [1]]
>>> The iteration is completed. Success: true

PollingManager Method Reference

updateTask(id, newOptions)

Updates any task option and restarts it. Now async — waits for any in-progress execution to complete before replacing the task (RISK-6 fix).

await manager.updateTask('main-sensor-poll', {
  interval: 5000,
  priority: 100,
  maxRetries: 5,
});

Expected result:

[10:05:00] INFO: [Task][taskId:main-sensor-poll] Task stopped
[10:05:00] INFO: [Polling Manager] Task removed
[10:05:00] INFO: [Polling Manager] Task added -> main-sensor-poll

removeTask(id)

Complete removal task from the system.

manager.removeTask('main-sensor-poll');

Expected result:

[10:10:00] INFO: [Task][taskId:main-sensor-poll] Task stopped
[10:10:00] INFO: [Polling Manager] Task removed

pauseTask(id) / resumeTask(id)

Temporary stop of execution. The task remains in memory.

manager.pauseTask('main-sensor-poll');
manager.resumeTask('main-sensor-poll');

Expected result:

[10:15:00] INFO: [Task][taskId:main-sensor-poll] Task paused
[10:15:05] INFO: [Task][taskId:main-sensor-poll] Task resumed

restartTask(id)

Instant restart of task timers.

manager.restartTask('main-sensor-poll');

Expected result:

[10:20:00] INFO: [Task][taskId:main-sensor-poll] Task stopped
[10:20:00] DEBUG: [Task][taskId:main-sensor-poll] Task started

setTaskInterval(id, interval)

Changing the polling frequency without restarting the entire task.

manager.setTaskInterval('main-sensor-poll', 1000);

Expected result:

[10:25:00] INFO: [Task][taskId:main-sensor-poll] Interval updated

executeImmediate(fn)

Executes asynchronous code (for example, writing) by capturing Mutex. The polling queue will freeze until the function is executed.

const result = await manager.executeImmediate(async () => {
  return await client.writeSingleRegister(100, 255);
});

Expected result:

[10:30:00] INFO: [ModbusClient][ID:1] Response received 40ms

getQueueInfo()

Returns information about the current queue for execution.

const info = manager.getQueueInfo();
console.log(info);

Expected result:

{
    queueLength: 1,
    tasks: [
        {
            id: 'main-sensor-poll',
            state: {
                stopped: false,
                paused: false,
                running: true,
                inProgress: false
            }
        }
    ]
}

getSystemStats()

console.log(manager.getSystemStats());

Expected result:

{ totalTasks: 1, totalQueues: 1, queuedTasks: 0 }

clearAll()

Full stop and clean up.

manager.clearAll();

Expected result:

[10:40:00] INFO: [Polling Manager] Clearing all tasks
[10:40:00] INFO: [Task][taskId:main-sensor-poll] Task stopped
[10:40:00] INFO: [Polling Manager] All tasks cleared

disableAllLoggers()

manager.disableAllLoggers(); // Sets the 'silent' level for a total of 4. Bulk Management (Bulk Methods)

| Method | Description | | ------------------- | ----------------------------------------------------------------------------- | | startAllTasks() | Starts all tasks that were in the stopped state. | | stopAllTasks() | Stops all tasks and clears the queue. | | pauseAllTasks() | Puts all tasks in paused mode (timers are running, but no requests are made). | | resumeAllTasks() | Unpauses all tasks and starts the queue processing loop. | | restartAllTasks() | Calls stop and start for each task sequentially. |


Important technical details

  • Mutex Lock: The _processQueue background loop always uses this.mutex.runExclusive. This ensures that if you use executeImmediate, your bytes won't get mixed up with the polled bytes.
  • FIFO + Priority: The queue is sorted by priority. If tasks have the same priority, they are executed in the order they arrive (First-In-First-Out).
  • Zombie Task Protection: If you call removeTask while waiting for a response from the device (e.g., 2 seconds), the manager will intercept the response but will not call the onData callback, preventing the processing of stale data. - CPU Safety: Between tasks in the queue, the manager takes a micro-pause using setImmediate (or setTimeout(0)) to avoid blocking the Event Loop and allow the application to process other asynchronous events.

Modbus Emulators (RTU & TCP)

Emulators allow you to simulate real Modbus devices directly in your code. They support four memory areas (Coils, Discrete Inputs, Holding Registers, Input Registers), can simulate network delays, errors (Exceptions), and automatically change data (sensor simulation).

Adding an Emulator to the Controller

Emulators are registered as regular transports using addTransport.

Options for rtu-emulator and tcp-emulator:

| Option | Type | Description | | ------------------- | ---------- | ---------------------------------------------------------------- | | slaveId | number | Modbus Unit ID of the emulator (0-247). Defaults to 1. | | responseLatencyMs | number | Artificial response delay in ms (simulates line speed). | | initialRegisters | object | An object with initial data for memory. | | loggerEnabled | boolean | Enable/disable internal logging. Defaults to true. | | slaveIds | number[] | List of IDs that the controller should forward to this emulator. | | RSMode | string | For tcp-emulator only: operating mode (default: 'TCP/IP'). |

Example of creating an RTU emulator:

const TransportController = require('modbus-connect/transport');
const controller = new TransportController();

await controller.addTransport('SIM_RTU', 'rtu-emulator', {
  slaveId: 122,
  responseLatencyMs: 50,
  initialRegisters: {
    holding: [{ start: 0, value: 1500 }],
    coils: [{ start: 5, value: true }],
  },
  slaveIds: [122],
});

Example of creating a TCP emulator:

await controller.addTransport('SIM_TCP', 'tcp-emulator', {
  slaveId: 1,
  responseLatencyMs: 10,
  RSMode: 'TCP/IP',
  initialRegisters: {
    input: [{ start: 100, value: 366 }],
  },
  slaveIds: [1],
});

Expected result:

[10:00:00] INFO: [ModbusSlaveCore] ModbusSlaveCore initialized successfully (Slave ID: 122)
[10:00:00] INFO: [Transport Controller] Transport "SIM_RTU" added with PollingManager

Emulator Data Management

To interact with the emulator's internal memory and behavior, you must access its core via the transport's getCore() method.

addRegisters(definitions)

Allows you to bulk add or update data in memory.

const emu = controller.getTransport('SIM_RTU');
const core = emu.getCore();

core.addRegisters({
  coils: [
    { start: 0, value: true },
    { start: 1, value: false },
  ],
  discrete: [{ start: 10, value: true }],
  holding: [
    { start: 0, value: 100 },
    { start: 100, value: 2500 },
  ],
  input: [{ start: 50, value: 36.6 }],
});

Expected result:

[10:05:00] INFO: [ModbusSlaveCore] Registers added successfully: {"coils":2,"discrete":1,"holding":2,"input":1}

infinityChange(options)

Starts automatic register value change. This is the perfect tool for simulating temperature, pressure, and other sensors.

| Parameter | Type | Description | | -------------- | ------------ | ----------------------------------------------- | | typeRegister | string | Type: 'Holding', 'Input', 'Coil', 'Discrete'. | | register | number | Register address. | | range | [min, max] | Random value range (ignored for Coil/Discrete). | | interval | number | Value update period in milliseconds. |

Example:

core.infinityChange({
  typeRegister: 'Holding',
  register: 0,
  range: [100, 200], // The value will randomly jump from 100 to 200
  interval: 1000, // Update every second
});

Expected result:

[10:10:00] INFO: [ModbusSlaveCore] Infinity change started for Holding[0] (interval: 1000ms)
[10:10:01] DEBUG: [ModbusSlaveCore] Infinity change: Holding[0] = 142
[10:10:02] DEBUG: [ModbusSlaveCore] Infinity change: Holding[0] = 187

stopInfinityChange(options)

Stops data generation

core.stopInfinityChange({
  typeRegister: 'Holding',
  register: 0,
});

Expected result:

[10:15:00] DEBUG: [ModbusSlaveCore] Infinity change stopped for Holding:0

setException(functionCode, address, exceptionCode)

Simulates a device error for a specific address and function. Allows you to test how your application handles hardware failures.

  • functionCode: Function code (e.g. 0x03).
  • address: Address.
  • exceptionCode: Error code (0x01 — Illegal Function, 0x02 — Illegal Data Address, etc.).

Example:

// When attempting to read Holding Register 10, return the error "Illegal Data Address"
core.setException(0x03, 10, 0x02);

Expected result (when requested by the client):

[10:20:00] WARN: [ModbusSlaveCore] Throwing exception for function 0x3 at address 10: code 0x2

clearAll()

Full clear: removes all data from tables, resets all errors, and stops all infinityChange tasks.

core.clearAll();

Expected result:

[10:25:00] INFO: [ModbusSlaveCore] All registers, exceptions and infinity tasks cleared

Full example: Emulator + Client + Polling

const TransportController = require('modbus-connect/transport');
const ModbusClient = require('modbus-connect/client');

async function startSystem() {
  const controller = new TransportController();

  // 1. Create and connect the emulator
  await controller.addTransport('DEVICE_SIM', 'rtu-emulator', {
    slaveId: 10,
    responseLatencyMs: 20,
    slaveIds: [10],
  });
  await controller.connectTransport('DEVICE_SIM');

  // 2. Set up dynamic data
  const core = controller.getTransport('DEVICE_SIM').getCore();
  core.infinityChange({
    typeRegister: 'Holding',
    register: 1,
    range: [30, 40],
    interval: 1000,
  });

  // 3. Create a client to work with this emulator
  const client = new ModbusClient(controller, 10, {
    framing: 'rtu',
    timeout: 1000,
  });

  // 4. Start the poll
  controller.addPollingTask('DEVICE_SIM', {
    id: 'poll-emulator',
    interval: 2000,
    fn: () => client.readHoldingRegisters(1, 1),
    onData: val => console.log('Value from the emulator:', val),
  });
}

startSystem();

Expected result:

[12:00:00] INFO: [ModbusSlaveCore] ModbusSlaveCore initialized successfully (Slave ID: 10)
[12:00:00] INFO: [RTU Emulator] RTU Emulator connected
[12:00:00] INFO: [ModbusSlaveCore] Infinity change started for Holding[1] (interval: 1000ms)
[12:00:00] INFO: [Polling Manager] Task added -> poll-emulator
[12:00:02] INFO: [ModbusClient][ID:10] Response received 22ms
Value from emulator: [34]

Types and Interfaces

All interactions in the library are strongly typed. The interfaces are divided into logical blocks: Client, Transport, Polling Manager, and Emulation.

Modbus Client API

IModbusClient

The primary interface for high-level operations.

| Method | Description | | ---------------------------------------------- | ------------------------------------------------------------------- | | readHoldingRegisters(start, qty) | Reads holding registers (FC 0x03). Returns Promise<RegisterData>. | | readInputRegisters(start, qty) | Reads input registers (FC 0x04). Returns Promise<RegisterData>. | | writeSingleRegister(addr, val, timeout?) | Write a single register (FC 0x06). | | writeMultipleRegisters(addr, vals, timeout?) | Write a group of registers (FC 0x10). | | readCoils(start, qty, timeout?) | Reads coils (FC 0x01). Returns boolean[]. | | readDiscreteInputs(start, qty, timeout?) | Reads discrete inputs (FC 0x02). | | writeSingleCoil(addr, val, timeout?) | Writes a single bit (FC 0x05). | | writeMultipleCoils(addr, vals, timeout?) | Writes a group of bits (FC 0x0F). | | reportSlaveId(timeout?) | Reports the device ID (FC 0x11). | | readDeviceIdentification(decoder, timeout?) | Reads the device ID (FC 0x2B). | | executeCustomFunction(name, ...args) | Calls a plugin function. | | setSlaveId(newId) | Changes the device address for the client. | | connect() / disconnect() | Logical state management. | | enableLogger() / disableLogger() | Logging control. | | IModbusClientOptions | (Constructor options) |

{
    framing?: 'rtu' | 'tcp'; // Packet type
    RSMode?: 'RS485' | 'RS232' | 'TCP/IP'; // Physical mode
    timeout?: number; // Request timeout (ms)
    retryCount?: number; // Number of retries if communication error occurs
    retryDelay?: number; // Delay between retries (ms)
    echoEnabled?: boolean; // Clear echo bytes (for RS485)
    plugins?: TPluginConstructor[]; // List of plugin classes
}

Transport Layer

ITransportController

Central manager of all connections.

  • addTransport(id, type, options, reconnect?, polling?): Register a new channel.
  • reloadTransport(id, options): Hot-swapping port settings. Old trackers are properly cleaned up before recreation.
  • removeTransport(id): Complete removal.
  • getTransportForSlave(slaveId, requiredRSMode): Search for a transport by route.
  • assignSlaveIdToTransport(transportId, slaveId): Bind a device to a port. Async — always await.
  • removeSlaveIdFromTransport(transportId, slaveId): Unlink a device. Async — always await. Targets the specific transport tracker only.

ITransport (Common Port Interface)

Any transport (Serial, TCP, WebSerial) must implement these methods:

  • write(buffer): Send bytes.
  • read(length, timeout): Read bytes.
  • flush(): Clear the buffer.
  • getRSMode(): Returns the current operating mode.

EConnectionErrorType (Error Enumeration)

Used in trackers to classify problems:

  • UnknownError: Unspecified error.
  • PortClosed: The physical port is closed.
  • Timeout: The device did not respond.
  • CRCError: Checksum error.
  • ConnectionLost: Connection lost.
  • DeviceOffline: The device went offline.
  • MaxReconnect: The recovery attempt limit has been exceeded.
  • ManualDisconnect: Disconnected by user request.
  • Destroyed: Transport was destroyed.

**Polling Manager (Polling