ethernet-ip
Version:
A feature-complete EtherNet/IP client for Rockwell ControlLogix/CompactLogix PLCs
480 lines (346 loc) • 14.8 kB
Markdown
<p align="center"><img width="280" src="https://i.imgur.com/HNxhZox.png" alt="ethernet-ip logo"></p>
<div align="center">
<p><a href="https://www.npmjs.com/package/ethernet-ip"><img src="https://img.shields.io/npm/v/ethernet-ip.svg?style=flat-square" alt="npm" /></a>
<a href="https://github.com/cmseaton42/node-ethernet-ip/blob/master/LICENSE"><img src="https://img.shields.io/github/license/cmseaton42/node-ethernet-ip.svg?style=flat-square" alt="license" /></a>
<a href="https://github.com/cmseaton42/node-ethernet-ip"><img src="https://img.shields.io/github/stars/cmseaton42/node-ethernet-ip.svg?&style=social&logo=github&label=Stars" alt="GitHub stars" /></a></p>
</div>
---
# 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](https://nodejs.org/en/) >= 18.0.0
## Install
```
npm install ethernet-ip
```
## The API
### Connecting to a PLC
```typescript
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
```typescript
{
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:
```typescript
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:
```typescript
const [speed, temp, status] = await plc.read(['Speed', 'Temperature', 'Status']);
```
Read a bit of a word:
```typescript
// Read bit 5 of a DINT tag
const bit5 = await plc.read('MyDINT.5');
// bit5: true (boolean)
```
Read program-scoped tags:
```typescript
const value = await plc.read('Program:MainProgram.LocalTag');
```
Read array elements:
```typescript
const element = await plc.read('MyArray[3]');
const multiDim = await plc.read('Matrix[1,2]');
```
Read UDT members:
```typescript
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()`):
```typescript
await plc.write('SetPoint', 72.5);
await plc.write('EnableMotor', true);
await plc.write('MachineName', 'Press 2');
```
Write multiple tags:
```typescript
await plc.write({
SetPoint: 72.5,
EnableMotor: true,
BatchCount: 0,
});
```
Write a bit of a word:
```typescript
// 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:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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
```typescript
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
```typescript
plc.isConnected; // true when connected, false otherwise
```
### Logger
Inject a logger for observability. Default is noop — no console output unless you provide one:
```typescript
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:
```typescript
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
```typescript
import {
buildGetControllerPropsRequest,
parseControllerProps,
buildReadWallClockRequest,
parseWallClockResponse,
buildWriteWallClockRequest,
} from 'ethernet-ip';
```
### Testing with MockTransport
Every layer can be tested without PLC hardware:
```typescript
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:
```typescript
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](./ethernet-ip-v2-docs/architecture.md) for the full design document.
## Testing
```bash
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)
```javascript
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)
```typescript
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 Seaton** — _Owner_ — [GitHub](https://github.com/cmseaton42) — [Website](http://www.canaanseaton.com/)
- **Patrick McDonagh** — _Collaborator_ — [GitHub](https://github.com/patrickjmcd)
- **Jeremy Henson** — _Collaborator_ — [GitHub](https://github.com/jhenson29)
## Related Projects
- [ST-node-ethernet-ip](https://github.com/SerafinTech/ST-node-ethernet-ip) — Fork with connected messaging, structures, and I/O support
- [pylogix](https://github.com/dmroeder/pylogix) — Python EtherNet/IP client
- [Node-RED CIP](https://github.com/netsmarttech/node-red-contrib-cip-ethernet-ip) — Node-RED integration
Wanna _become_ a contributor? [Here's how!](https://github.com/cmseaton42/node-ethernet-ip/blob/master/CONTRIBUTING.md)
## License
This project is licensed under the MIT License — see the [LICENSE](https://github.com/cmseaton42/node-ethernet-ip/blob/master/LICENSE) file for details.