ethernet-ip
Version:
A feature-complete EtherNet/IP client for Rockwell ControlLogix/CompactLogix PLCs
356 lines • 17 kB
JavaScript
"use strict";
/**
* PLC — top-level user API composing all layers.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.PLC = void 0;
const typed_event_emitter_1 = require("../util/typed-event-emitter");
const logger_1 = require("../util/logger");
const tcp_transport_1 = require("../transport/tcp-transport");
const session_manager_1 = require("../session/session-manager");
const tag_registry_1 = require("../registry/tag-registry");
const discovery_1 = require("../registry/discovery");
const encapsulation_1 = require("../encapsulation/encapsulation");
const header_1 = require("../encapsulation/header");
const common_packet_format_1 = require("../encapsulation/common-packet-format");
const cpf_utils_1 = require("./cpf-utils");
const MessageRouter = __importStar(require("../cip/message-router"));
const batch_builder_1 = require("../cip/batch-builder");
const data_types_1 = require("../cip/data-types");
const services_1 = require("../cip/services");
const errors_1 = require("../errors");
const types_1 = require("./types");
const read_1 = require("./read");
const write_1 = require("./write");
const tag_path_1 = require("./tag-path");
const template_fetcher_1 = require("../registry/template-fetcher");
const struct_helpers_1 = require("./struct-helpers");
const struct_codec_1 = require("./struct-codec");
const serialized_promise_queue_1 = require("../util/serialized-promise-queue");
/** InterfaceHandle(4) + Timeout(2) prefix before CPF data */
const CPF_PREFIX_SIZE = 6;
/** Atomic type param is 2 bytes, struct type param is 4 bytes (0xA0 0x02 + handle) */
const ATOMIC_TYPE_PARAM_SIZE = 2;
const STRUCT_TYPE_PARAM_SIZE = 4;
/** Max usable packet size for unconnected messaging (UCMM) */
const UCMM_MAX_SIZE = 508;
class PLC extends typed_event_emitter_1.TypedEventEmitter {
constructor(options) {
super();
this._registry = new tag_registry_1.TagRegistry();
this._discoveredOnce = false;
this._queue = new serialized_promise_queue_1.SerializedPromiseQueue();
this.log = options?.logger ?? logger_1.noopLogger;
const transport = options?.transport ?? new tcp_transport_1.TCPTransport();
this.session = new session_manager_1.SessionManager(transport, this.log);
this.session.on('connected', () => this.emit('connected'));
this.session.on('disconnected', () => this.emit('disconnected'));
this.session.on('reconnecting', (n) => this.emit('reconnecting', n));
this.session.on('error', (e) => this.emit('error', e));
}
get registry() {
return this._registry;
}
get isConnected() {
return this.session.state === 'connected';
}
async connect(ip, options) {
const opts = (0, types_1.resolveConnectOptions)(options);
await this.session.connect(ip, {
slot: opts.slot,
timeoutMs: opts.timeoutMs,
connected: opts.connected,
reconnect: opts.reconnect,
});
if (opts.discover) {
await this.populateRegistry(opts.timeoutMs);
}
}
async disconnect() {
await this.session.disconnect();
}
/** Discover all user tags and fetch UDT templates. Returns the discovered tag list. */
async discover() {
return this._queue.enqueue(() => this.populateRegistry(10000));
}
getTemplate(tagName) {
const entry = this._registry.lookup(tagName);
if (!entry?.isStruct)
return undefined;
return (this._registry.lookupTemplateByHandle(entry.type) ?? this._registry.lookupTemplate(entry.type));
}
getShape(tagName) {
const tmpl = this.getTemplate(tagName);
if (!tmpl)
return undefined;
return (0, struct_helpers_1.buildShape)(tmpl, this._registry);
}
/** Get array dimension sizes, e.g. [10, 5] for a 10×5 2D array. Empty if not an array. */
getDimensions(tagName) {
return this._registry.lookup(tagName)?.dimSizes ?? [];
}
async read(tagOrTags) {
return this._queue.enqueue(() => {
if (Array.isArray(tagOrTags)) {
if (tagOrTags.length === 1)
return this.readSingle(tagOrTags[0]).then((v) => [v]);
return this.readBatch(tagOrTags);
}
return this.readSingle(tagOrTags);
});
}
async write(tagOrTags, value) {
return this._queue.enqueue(() => {
if (typeof tagOrTags === 'string')
return this.writeSingle(tagOrTags, value);
const entries = Object.entries(tagOrTags);
if (entries.length === 1)
return this.writeSingle(entries[0][0], entries[0][1]);
return this.writeBatch(tagOrTags);
});
}
// ── Read ──────────────────────────────────────────────────
async readSingle(tagName) {
this.log.debug('Read single', { tag: tagName });
const cipRequest = (0, read_1.buildReadRequest)(tagName);
const cipResponse = await this.sendCIP(cipRequest);
const mr = MessageRouter.parse(cipResponse);
if (mr.generalStatusCode !== 0) {
throw new errors_1.CIPError(mr.generalStatusCode, mr.extendedStatus);
}
const { type, isStruct, value } = (0, read_1.parseReadResponse)(mr.data, tagName);
// Lazy registration: cache type under the base tag name
const baseName = (0, tag_path_1.extractBitIndex)(tagName) !== null ? tagName.substring(0, tagName.lastIndexOf('.')) : tagName;
if (!this._registry.has(baseName)) {
this._registry.register(baseName, {
type,
size: data_types_1.TYPE_SIZES.get(type) ?? 0,
isStruct,
arrayDims: 0,
});
}
// Lazy template fetch: on first struct encounter, discover all templates once
if (isStruct && type !== data_types_1.STRING_STRUCT_HANDLE && !this._discoveredOnce) {
if (!this._registry.lookupTemplateByHandle(type)) {
await this.populateRegistry(10000);
}
}
return this.decodeValue(type, isStruct, value);
}
async readBatch(tags) {
// Discover unknown types first (sequential)
for (const tag of tags) {
if (!this._registry.has(tag))
await this.readSingle(tag);
}
const requests = tags.map((tag) => {
const entry = this._registry.lookup(tag);
return {
serviceData: (0, read_1.buildReadRequest)(tag),
estimatedResponseSize: this.responseSize(entry),
};
});
const isConnected = this.session.connectionId !== 0;
const maxSize = this.session.connectionSize || UCMM_MAX_SIZE;
const batches = (0, batch_builder_1.buildBatches)(requests, maxSize, isConnected);
this.log.debug('Batch read', { tags: tags.length, batches: batches.length, maxSize });
const results = [];
let tagIdx = 0;
for (const batch of batches) {
const cipResponse = await this.sendCIP(batch.data);
const mr = MessageRouter.parse(cipResponse);
if (mr.generalStatusCode !== 0) {
throw new errors_1.CIPError(mr.generalStatusCode, mr.extendedStatus);
}
const isMultiService = (mr.service & 0x7f) === services_1.CIPService.MULTIPLE_SERVICE_PACKET;
if (isMultiService) {
const replies = (0, batch_builder_1.parseMultiServiceResponse)(mr.data);
for (const reply of replies) {
if (reply.generalStatusCode !== 0) {
throw new errors_1.CIPError(reply.generalStatusCode, reply.extendedStatus);
}
const { type, isStruct, value } = (0, read_1.parseReadResponse)(reply.data, tags[tagIdx]);
results.push(this.decodeValue(type, isStruct, value));
tagIdx++;
}
}
else {
const { type, isStruct, value } = (0, read_1.parseReadResponse)(mr.data, tags[tagIdx]);
results.push(this.decodeValue(type, isStruct, value));
tagIdx++;
}
}
return results;
}
// ── Write ─────────────────────────────────────────────────
async writeSingle(tagName, value) {
if (!this._registry.has(tagName))
await this.readSingle(tagName);
const entry = this._registry.lookup(tagName);
const bitIndex = (0, tag_path_1.extractBitIndex)(tagName);
const encoded = (0, struct_helpers_1.encodeIfStruct)(value, entry, this._registry);
const cipRequest = bitIndex !== null
? (0, write_1.buildBitWriteRequest)(tagName, value, this._registry.lookupParent(tagName).type)
: (0, write_1.buildWriteRequest)(tagName, encoded, entry.type, 1, entry.isStruct ? entry.type : undefined);
const cipResponse = await this.sendCIP(cipRequest);
const mr = MessageRouter.parse(cipResponse);
if (mr.generalStatusCode !== 0) {
throw new errors_1.CIPError(mr.generalStatusCode, mr.extendedStatus);
}
}
async writeBatch(tags) {
const entries = Object.entries(tags);
for (const [tag] of entries) {
if (!this._registry.has(tag))
await this.readSingle(tag);
}
const requests = entries.map(([tag, val]) => {
const entry = this._registry.lookup(tag);
const bitIndex = (0, tag_path_1.extractBitIndex)(tag);
const encoded = (0, struct_helpers_1.encodeIfStruct)(val, entry, this._registry);
const serviceData = bitIndex !== null
? (0, write_1.buildBitWriteRequest)(tag, val, entry.type)
: (0, write_1.buildWriteRequest)(tag, encoded, entry.type, 1, entry.isStruct ? entry.type : undefined);
return { serviceData, estimatedResponseSize: 4 };
});
const isConnected = this.session.connectionId !== 0;
const maxSize = this.session.connectionSize || UCMM_MAX_SIZE;
const batches = (0, batch_builder_1.buildBatches)(requests, maxSize, isConnected);
for (const batch of batches) {
const cipResponse = await this.sendCIP(batch.data);
const mr = MessageRouter.parse(cipResponse);
if (mr.generalStatusCode !== 0) {
throw new errors_1.CIPError(mr.generalStatusCode, mr.extendedStatus);
}
if (batch.requests.length > 1) {
const replies = (0, batch_builder_1.parseMultiServiceResponse)(mr.data);
for (const reply of replies) {
if (reply.generalStatusCode !== 0) {
throw new errors_1.CIPError(reply.generalStatusCode, reply.extendedStatus);
}
}
}
}
}
// ── Decode (single path for all reads) ────────────────────
/**
* Decode a read response value. Uses the wire handle (from parseReadResponse)
* to make all decode decisions. One function, one path, deterministic.
*/
decodeValue(wireType, isStruct, value) {
if (!isStruct || !Buffer.isBuffer(value))
return value;
// Built-in STRING: decode directly, no template needed
if (wireType === data_types_1.STRING_STRUCT_HANDLE) {
return (0, data_types_1.getCodec)(data_types_1.CIPDataType.STRING).decode(value, 0);
}
// UDT: decode if template is cached
const tmpl = this._registry.lookupTemplateByHandle(wireType);
if (tmpl) {
return (0, struct_codec_1.decodeStruct)(tmpl, value, (0, struct_helpers_1.templateLookup)(this._registry));
}
// No template — return raw Buffer
return value;
}
// ── Discovery & Templates ─────────────────────────────────
async populateRegistry(timeoutMs) {
if (!this.session.pipeline)
return [];
const tags = await (0, discovery_1.discoverUserTags)(this.session.pipeline, this.session.sessionId, timeoutMs);
const structInstanceIds = new Set();
for (const tag of tags) {
const fullName = tag.program ? `Program:${tag.program}.${tag.name}` : tag.name;
this._registry.register(fullName, {
type: tag.type.code,
size: data_types_1.TYPE_SIZES.get(tag.type.code) ?? 0,
isStruct: tag.type.isStruct,
arrayDims: tag.type.arrayDims,
dimSizes: tag.type.dimSizes.length > 0 ? tag.type.dimSizes : undefined,
});
if (tag.type.isStruct)
structInstanceIds.add(tag.type.code);
}
for (const id of structInstanceIds) {
if (!this._registry.lookupTemplate(id)) {
await (0, template_fetcher_1.fetchTemplate)((req) => this.sendCIP(req), this._registry, id);
}
}
for (const tag of tags) {
if (tag.type.isStruct) {
tag.template = this._registry.lookupTemplate(tag.type.code);
}
}
this._discoveredOnce = true;
this.log.info('Discover completed', { tagCount: tags.length });
return tags;
}
// ── Transport ─────────────────────────────────────────────
async sendCIP(cipRequest) {
if (!this.session.pipeline)
throw new Error('Not connected');
const isConnected = this.session.connectionId !== 0;
const eipPacket = isConnected
? (0, encapsulation_1.sendUnitData)(this.session.sessionId, cipRequest, this.session.connectionId, this.session.nextSequence())
: (0, encapsulation_1.sendRRData)(this.session.sessionId, cipRequest);
this.log.debug('sendCIP', { reqSize: eipPacket.length, connected: isConnected });
const response = await this.session.pipeline.send(eipPacket);
this.log.debug('recvCIP', {
respSize: response.length,
respHdr: response.subarray(0, 8).toString('hex'),
});
const parsed = (0, header_1.parseHeader)(response);
const cpf = (0, common_packet_format_1.parseCPF)(parsed.data.subarray(CPF_PREFIX_SIZE));
return (0, cpf_utils_1.extractCIPData)(cpf);
}
/** Calculate the exact response payload size for a tag read. */
responseSize(entry) {
if (entry.isStruct) {
const tmpl = this._registry.lookupTemplateByHandle(entry.type) ??
this._registry.lookupTemplate(entry.type);
if (!tmpl) {
throw new Error(`Missing template for struct type 0x${entry.type.toString(16)}`);
}
return STRUCT_TYPE_PARAM_SIZE + tmpl.attributes.structureSize;
}
const dataSize = data_types_1.TYPE_SIZES.get(entry.type);
if (dataSize === undefined) {
throw new Error(`Unknown atomic type 0x${entry.type.toString(16)}`);
}
return ATOMIC_TYPE_PARAM_SIZE + dataSize;
}
}
exports.PLC = PLC;
//# sourceMappingURL=plc.js.map