UNPKG

ethernet-ip

Version:

A feature-complete EtherNet/IP client for Rockwell ControlLogix/CompactLogix PLCs

480 lines (346 loc) 14.8 kB
<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.