A feature-complete EtherNet/IP client for Rockwell ControlLogix/CompactLogix PLCs.
- Full TypeScript with strict types
- Dependency injection for testability (MockTransport)
- Connected messaging with Forward Open (Large/Small fallback)
- Complete data type support (all atomics, STRING, SHORT_STRING, STRUCT, arrays)
- Lazy tag type discovery with optional full tag list retrieval
- Auto-reconnect with exponential backoff
- Tag subscriptions with change detection
- Typed error hierarchy with human-readable CIP status codes
- Injectable logger (noop default)
- 383+ unit tests
Node.js >= 18.0.0
npm install ethernet-ip
import { PLC } from 'ethernet-ip';
const plc = new PLC();
// Connect to a CompactLogix at 192.168.1.1, slot 0
await plc.connect('192.168.1.1');
// Connect to a ControlLogix in slot 2
await plc.connect('192.168.1.1', { slot: 2 });
// Connect with full tag discovery (fetches all tags on connect)
await plc.connect('192.168.1.1', { discover: true });
// Connect with auto-reconnect
await plc.connect('192.168.1.1', { autoReconnect: true });| Option | Type | Default | Description |
|---|---|---|---|
slot |
number |
0 |
Controller slot number (0 for CompactLogix) |
discover |
boolean |
false |
Fetch full tag list on connect |
connected |
boolean |
true |
Use connected messaging (Forward Open). Set false for unconnected (UCMM) only |
timeout |
number |
10000 |
Connection timeout in milliseconds |
autoReconnect |
boolean | ReconnectOptions |
false |
Enable auto-reconnect on disconnect |
{
enabled: true,
initialDelay: 1000, // First retry after 1 second
maxDelay: 30000, // Cap at 30 seconds
multiplier: 2, // Double the delay each attempt
maxRetries: Infinity, // Retry forever
}Read a single tag — the type is discovered automatically on first read and cached:
const value = await plc.read('MyDINT');
// value: 42 (number)
const temp = await plc.read('Temperature');
// temp: 72.5 (number)
const running = await plc.read('MotorRunning');
// running: true (boolean)
const name = await plc.read('MachineName');
// name: "Press 1" (string)Read multiple tags — automatically batched into optimal multi-service packets:
const [speed, temp, status] = await plc.read(['Speed', 'Temperature', 'Status']);Read a bit of a word:
// Read bit 5 of a DINT tag
const bit5 = await plc.read('MyDINT.5');
// bit5: true (boolean)Read program-scoped tags:
const value = await plc.read('Program:MainProgram.LocalTag');Read array elements:
const element = await plc.read('MyArray[3]');
const multiDim = await plc.read('Matrix[1,2]');Read UDT members:
const member = await plc.read('MyUDT.Member1');| PLC Type | JavaScript Type |
|---|---|
| BOOL | boolean |
| SINT, INT, DINT, USINT, UINT, UDINT, REAL, LREAL | number |
| LINT, LWORD | bigint |
| STRING, SHORT_STRING | string |
| STRUCT (with template) | object |
| STRUCT (unknown template) | Buffer |
Write a single tag — the type must be known (read the tag first, or use registry.define()):
await plc.write('SetPoint', 72.5);
await plc.write('EnableMotor', true);
await plc.write('MachineName', 'Press 2');Write multiple tags:
await plc.write({
SetPoint: 72.5,
EnableMotor: true,
BatchCount: 0,
});Write a bit of a word:
// Set bit 5 of a DINT tag to true
await plc.write('ControlWord.5', true);Types are discovered lazily — the first read() of a tag discovers its type and caches it. For optimal first-batch performance, you can pre-register types:
import { CIPDataType } from 'ethernet-ip';
plc.registry.define('MyDINT', CIPDataType.DINT, 4);
plc.registry.define('MyString', CIPDataType.STRING, 88);
// Now batch reads can be optimally packed without discovery round trips
const values = await plc.read(['MyDINT', 'MyString']);Or discover all tags on connect:
await plc.connect('192.168.1.1', { discover: true });
// plc.registry now has every tag's type and UDT templatesStruct tags are automatically decoded into JS objects when the template is available:
const motor = await plc.read('MotorStatus');
// motor: { Running: true, Speed: 1750, Current: 12.5 }
await plc.write('MotorControl', { Enable: true, SpeedSP: 1800 });Discover tags and inspect struct shapes:
const tags = await plc.discover();
// tags: [{ name: 'MotorStatus', type: { code: 0x3b2, isStruct: true, arrayDims: 0, dimSizes: [] } }, ...]
// Array tags include dimension sizes
const arr = tags.find((t) => t.name === 'Matrix');
// arr.type.arrayDims = 2, arr.type.dimSizes = [10, 5] → Matrix[10, 5]
const shape = plc.getShape('MotorStatus');
// { name: 'stMotorStatus', members: {
// Running: { type: 'BOOL' },
// Speed: { type: 'REAL' },
// Current: { type: 'REAL' },
// }}
const template = plc.getTemplate('MotorStatus');
// Raw template with byte offsets, member info, structureSize
const dims = plc.getDimensions('Matrix');
// [10, 5] → Matrix[10, 5]
// Returns [] for scalars or unknown tagsMonitor tags for changes. All tags share a single scan rate, set at construction:
import { Scanner } from 'ethernet-ip';
// Create a scanner with 200ms scan rate (default)
const scanner = new Scanner(async (tags) => plc.read(tags), { rate: 200 });
// Inject a logger for scan metrics (logged every ~5 minutes at debug level)
const scannerWithMetrics = new Scanner(async (tags) => plc.read(tags), { rate: 200, logger });
// Subscribe tags — can add/remove while scanning
scanner.subscribe('Temperature');
scanner.subscribe('BatchCount');
// Listen for changes
scanner.on('tagInitialized', (tag, value) => {
console.log(`${tag} initialized: ${value}`);
});
scanner.on('tagChanged', (tag, value, previousValue) => {
console.log(`${tag} changed: ${previousValue} → ${value}`);
});
scanner.on('scanError', (err) => {
console.error('Scan error:', err.message);
});
// Start scanning
scanner.scan();
// Add/remove tags while running — picked up on next tick
scanner.subscribe('NewTag');
scanner.unsubscribe('BatchCount');
// Pause scanning (subscriptions preserved)
scanner.pause();
// Resume
scanner.scan();await plc.connect('192.168.1.1', {
autoReconnect: {
enabled: true,
initialDelay: 1000,
maxDelay: 30000,
multiplier: 2,
maxRetries: Infinity,
},
});
plc.on('disconnected', () => {
console.log('Connection lost');
});
plc.on('reconnecting', (attempt) => {
console.log(`Reconnect attempt ${attempt}...`);
});
plc.on('connected', () => {
console.log('Connected');
// Tag registry is preserved — no re-discovery needed
});
plc.on('error', (err) => {
console.error('Error:', err.message);
});plc.isConnected; // true when connected, false otherwiseInject a logger for observability. Default is noop — no console output unless you provide one:
import { PLC, Logger } from 'ethernet-ip';
const logger: Logger = {
debug: (msg, ctx) => console.log('[DEBUG]', msg, ctx),
info: (msg, ctx) => console.log('[INFO]', msg, ctx),
warn: (msg, ctx) => console.warn('[WARN]', msg, ctx),
error: (msg, ctx) => console.error('[ERROR]', msg, ctx),
};
const plc = new PLC({ logger });Escape hatch for raw CIP requests — specify service, class, instance, and optionally attribute:
import { buildGenericCIPMessage } from 'ethernet-ip';
// Get Attribute Single: service=0x0E, class=0x8B, instance=0x01, attribute=0x05
const request = buildGenericCIPMessage(0x0e, 0x8b, 0x01, 0x05);
// Get Attribute All: service=0x01, class=0x01, instance=0x01
const identityRequest = buildGenericCIPMessage(0x01, 0x01, 0x01);
// Set Attribute Single with data
const data = Buffer.alloc(4);
data.writeUInt32LE(42, 0);
const writeRequest = buildGenericCIPMessage(0x10, 0x01, 0x01, 0x05, data);import {
buildGetControllerPropsRequest,
parseControllerProps,
buildReadWallClockRequest,
parseWallClockResponse,
buildWriteWallClockRequest,
} from 'ethernet-ip';Every layer can be tested without PLC hardware:
import { PLC, MockTransport } from 'ethernet-ip';
const transport = new MockTransport();
const plc = new PLC({ transport });
// transport.sentData contains all packets sent
// transport.injectResponse(buf) simulates PLC responses
// transport.triggerClose() simulates disconnectFluent builder for CIP EPATH construction:
import { EPathBuilder, LogicalType } from 'ethernet-ip';
// CIP object addressing
const path = new EPathBuilder()
.logical(LogicalType.ClassID, 0x06)
.logical(LogicalType.InstanceID, 0x01)
.build();
// Tag path: "MyTag[3].Member"
const tagPath = new EPathBuilder().symbolic('MyTag').element(3).symbolic('Member').build();
// Routing: backplane port 1, slot 2
const routePath = new EPathBuilder().port(1, 2).build();Layer 6 User API PLC · Scanner · Discovery
Layer 5 Session Manager State machine · Auto-reconnect · Forward Open fallback
Layer 4 Request Pipeline Serial queue · Timeout · TCP reassembly · Fragmentation
Layer 3 CIP Protocol EPATH · DataTypeCodec · MessageRouter · BatchBuilder
Layer 2 EIP Encapsulation Headers · CPF · Commands
Layer 1 Transport (DI) ITransport → TCP / UDP / Mock
See architecture.md for the full design document.
npm test # Run all tests
npm run test:watch # Watch mode
npm run test:coverage # With coverage report
npm run lint # ESLint
npm run format # Prettier (write)
npm run format:check # Prettier (check only)
npm run check # All checks: lint + format + tsc + tests| v1 | v2 |
|---|---|
| JavaScript | TypeScript (strict mode) |
new Controller() |
new PLC() |
PLC.connect(ip, slot) |
plc.connect(ip, { slot }) |
new Tag('name'); PLC.readTag(tag) |
plc.read('name') |
tag.value = 42; PLC.writeTag(tag) |
plc.write('name', 42) |
PLC.subscribe(tag); PLC.scan() |
scanner.subscribe('name'); scanner.scan() |
Extends net.Socket |
Composition with ITransport |
Event strings ("Read Tag") |
Typed events ('tagChanged') |
sendUnitData uses SequencedAddrItem (0x8002) |
Uses ConnectionBased (0xA1) per CIP spec |
| No connected messaging | Forward Open with Large/Small fallback |
| Atomic types only | All types including STRING, STRUCT, LINT, LREAL |
const { Controller, Tag, TagGroup } = require('ethernet-ip');
const PLC = new Controller();
await PLC.connect('192.168.1.1', 0);
const tag = new Tag('MyTag');
await PLC.readTag(tag);
console.log(tag.value);
tag.value = 42;
await PLC.writeTag(tag);import { PLC } from 'ethernet-ip';
const plc = new PLC();
await plc.connect('192.168.1.1');
const value = await plc.read('MyTag');
console.log(value);
await plc.write('MyTag', 42);- Canaan Seaton — Owner — GitHub — Website
- Patrick McDonagh — Collaborator — GitHub
- Jeremy Henson — Collaborator — GitHub
- ST-node-ethernet-ip — Fork with connected messaging, structures, and I/O support
- pylogix — Python EtherNet/IP client
- Node-RED CIP — Node-RED integration
Wanna become a contributor? Here's how!
This project is licensed under the MIT License — see the LICENSE file for details.
