Skip to content

cmseaton42/node-ethernet-ip

ethernet-ip logo

npm license GitHub stars


Node Ethernet/IP

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

Prerequisites

Node.js >= 18.0.0

Install

npm install ethernet-ip

The API

Connecting to a PLC

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 });

Connect Options

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

ReconnectOptions

{
  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
}

Reading Tags

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');

Return Types

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

Writing Tags

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);

Tag Registry

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 templates

UDT / Struct Support

Struct 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 tags

Scanning / Subscriptions

Monitor 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();

Auto-Reconnect

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);
});

Connection State

plc.isConnected; // true when connected, false otherwise

Logger

Inject 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 });

Generic CIP Messaging

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);

Controller Info

import {
  buildGetControllerPropsRequest,
  parseControllerProps,
  buildReadWallClockRequest,
  parseWallClockResponse,
  buildWriteWallClockRequest,
} from 'ethernet-ip';

Testing with MockTransport

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 disconnect

EPATH Builder

Fluent 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();

Architecture

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.

Testing

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

Migration from v1

Breaking Changes

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

Before (v1)

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);

After (v2)

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);

Contributors

  • Canaan SeatonOwnerGitHubWebsite
  • Patrick McDonaghCollaboratorGitHub
  • Jeremy HensonCollaboratorGitHub

Related Projects

Wanna become a contributor? Here's how!

License

This project is licensed under the MIT License — see the LICENSE file for details.

Packages

 
 
 

Contributors