<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?&amp;style=social&amp;logo=github&amp;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.
