st-ethernet-ip
Version:
A simple node interface for Ethernet/IP.
1,388 lines (1,195 loc) • 55.5 kB
text/typescript
import { CIP } from "../enip";
import {ENIP, enipConnection, enipError, enipTCP, enipSession} from "../enip";
import dateFormat from "dateformat";
import TagGroup from "../tag-group";
import { delay, promiseTimeout } from "../utilities";
import TagList, { tagListTag, tagListTemplates } from "../tag-list";
import { Structure } from "../structure";
import Queue from "task-easy";
import Tag from "../tag";
import type {CommonPacketData} from '../enip/encapsulation'
const compare = (obj1: any, obj2: any) => {
if (obj1.priority > obj2.priority) return true;
else if (obj1.priority < obj2.priority) return false;
else return obj1.timestamp.getTime() < obj2.timestamp.getTime();
};
type controllerState = {
name: string,
serial_number: number,
slot: number,
time: Date,
path: Buffer,
version: string,
status: number,
run: boolean,
program: boolean,
faulted: boolean,
minorRecoverableFault: boolean,
minorUnrecoverableFault: boolean,
majorRecoverableFault: boolean,
majorUnrecoverableFault: boolean,
io_faulted: boolean
}
class Controller extends ENIP {
state: {
TCP: enipTCP,
session: enipSession,
connection: enipConnection,
error: enipError
controller: controllerState,
subs: TagGroup,
scanning: boolean,
scan_rate: number,
connectedMessaging: boolean,
timeout_sp: number,
rpi: number,
fwd_open_serial: number,
unconnectedSendTimeout: number,
tagList: TagList,
}
workers: {
read: any;
write: any;
group: any;
}
/**
* PLC Controller class
*
* @param connectedMessaging whether to use connected or unconnected messaging
* @param opts future options
* @param opts.unconnectedSendTimeout unconnected send timeout option
*/
constructor(connectedMessaging: boolean = true, opts: any = {}) {
super();
this.state = {
...this.state,
controller: {
name: null,
serial_number: null,
slot: null,
time: null,
path: null,
version: null,
status: null,
run: false,
program: false,
faulted: false,
minorRecoverableFault: false,
minorUnrecoverableFault: false,
majorRecoverableFault: false,
majorUnrecoverableFault: false,
io_faulted: false
},
subs: new TagGroup(),
scanning: false,
scan_rate: 200, //ms,
connectedMessaging,
timeout_sp: 10000, //ms
rpi: 10,
fwd_open_serial: 0,
unconnectedSendTimeout: opts.unconnectedSendTimeout || 2000,
tagList: new TagList(),
};
this.workers = {
read: new Queue(compare),
write: new Queue(compare),
group: new Queue(compare),
};
}
// region Property Definitions
/**
* Returns the Scan Rate of Subscription Tags
*
* @returns scan rate in ms
*/
get scan_rate(): number {
return this.state.scan_rate;
}
/**
* Sets the Subsciption Group Scan Rate
*
*/
set scan_rate(rate: number) {
if (typeof rate !== "number") throw new Error("scan_rate must be of Type <number>");
this.state.scan_rate = Math.trunc(rate);
}
/**
* Returns the Timeout Setpoint
*
* @returns Timeout setpoint in ms
*/
get timeout_sp(): number {
return this.state.timeout_sp;
}
/**
* Sets the Timeout Setpoint
*
*/
set timeout_sp(sp: number) {
if (typeof sp !== "number") throw new Error("timeout_sp must be of Type <number>");
this.state.timeout_sp = Math.trunc(sp);
}
/**
* Returns the Rpi
*
* @returns rpi setpoint in ms
*/
get rpi() {
return this.state.rpi;
}
/**
* Sets the Rpi
*
*/
set rpi(sp: number) {
if (typeof sp !== "number") throw new Error("Rpi must be of Type <number>");
if (sp < 8) throw new Error("Rpi a minimum of 8ms");
this.state.rpi = Math.trunc(sp);
}
/**
* Get the status of Scan Group
*
* @returns true or false
*/
get scanning(): boolean {
return this.state.scanning;
}
/**
* Returns the connected / unconnected messaging mode
*
* @returns true, if connected messaging; false, if unconnected messaging
*/
get connectedMessaging(): boolean {
return this.state.connectedMessaging;
}
/**
* Sets the Mode to connected / unconnected messaging
*
*/
set connectedMessaging(conn: boolean) {
if (typeof conn !== "boolean") throw new Error("connectedMessaging must be of type <boolean>");
this.state.connectedMessaging= conn;
}
/**
* Gets the Controller Properties Object
*
* @readonly
* @memberof Controller
* @returns Controller properties object
*/
get properties(): controllerState {
return this.state.controller;
}
/**
* Fetches the last timestamp retrieved from the controller
* in human readable form
*
* @readonly
*/
get time(): string {
return dateFormat(this.state.controller.time, "mmmm dd, yyyy - hh:MM:ss TT");
}
// endregion
// region Public Method Definitions
/**
* Initializes Session with Desired IP Address
* and Returns a Promise with the Established Session ID
*
* @param IP_ADDR - IPv4 Address (can also accept a FQDN, provided port forwarding is configured correctly.)
* @param SLOT - Controller Slot Number (0 if CompactLogix), or a Buffer representing the whole routing path
* @returns Promise that resolves after connection
*/
async connect(IP_ADDR: string, SLOT: number | Buffer = 0, SETUP: boolean = true): Promise<void> {
const { PORT } = CIP.EPATH.segments;
const BACKPLANE = 1;
if (typeof SLOT === "number") {
this.state.controller.slot = SLOT;
this.state.controller.path = PORT.build(BACKPLANE, SLOT);
} else if (Buffer.isBuffer(SLOT)) {
this.state.controller.path = SLOT;
} else {
throw new Error("Invalid slot parameter type, must be either a number or a Buffer");
}
const sessid = await super.connect(IP_ADDR);
if (!sessid) throw new Error("Failed to Register Session with Controller");
this._initializeControllerEventHandlers(); // Connect sendRRData Event
if (this.state.connectedMessaging === true) {
const connid = await this.forwardOpen();
if(!connid) throw new Error("Failed to Establish Forward Open Connection with Controller");
}
if (SETUP) {
await this.readControllerProps();
await this.getControllerTagList(this.state.tagList);
}
}
/**
* Run a GET_ATTRIBUTE_SINGLE on any class, instance, attribute.
* For attribute of a class set instance to 0x00.
*
* @param classID
* @param instance
* @param attribute
* @param attData buffer with additional data to append to request (optional)
* @returns attribute buffer
*/
async getAttributeSingle(classID: number, instance: number, attribute: number, attData: Buffer = Buffer.alloc(0)): Promise<Buffer> {
const { GET_ATTRIBUTE_SINGLE } = CIP.MessageRouter.services;
const { LOGICAL } = CIP.EPATH.segments;
const identityPath = Buffer.concat([
LOGICAL.build(LOGICAL.types.ClassID, classID),
LOGICAL.build(LOGICAL.types.InstanceID, instance),
LOGICAL.build(LOGICAL.types.AttributeID, attribute)
]);
const MR = CIP.MessageRouter.build(GET_ATTRIBUTE_SINGLE, identityPath, attData);
super.write_cip(MR, super.established_conn);
const readPropsErr = new Error("TIMEOUT occurred while reading Param.");
// Wait for Response
try {
const data = await promiseTimeout(
new Promise((resolve, reject) => {
this.on("Get Attribute Single", (err, data) => {
if (err) reject(err);
resolve(data);
});
}),
this.state.timeout_sp,
readPropsErr
);
return data
} finally {
this.removeAllListeners("Get Attribute Single");
}
}
/**
* Run a SET_ATTRIBUTE_SINGLE on any class, instance, attribute. You have to know the size of the buffer
* of the data you are setting attribute to. For attribute of a class set instance to 0x00.
*
* @param classID - CIP Class ID
* @param instance - CIP Instance ID
* @param attribute - Attribute Number
* @param newValue - New value to set to as a Buffer
* @returns
*/
async setAttributeSingle(classID: number, instance: number, attribute: number, newValue: Buffer): Promise<void> {
const { SET_ATTRIBUTE_SINGLE } = CIP.MessageRouter.services;
const { LOGICAL } = CIP.EPATH.segments;
const identityPath = Buffer.concat([
LOGICAL.build(LOGICAL.types.ClassID, classID),
LOGICAL.build(LOGICAL.types.InstanceID, instance),
LOGICAL.build(LOGICAL.types.AttributeID, attribute)
]);
const MR = CIP.MessageRouter.build(SET_ATTRIBUTE_SINGLE, identityPath, newValue);
super.write_cip(MR, super.established_conn);
const readPropsErr = new Error("TIMEOUT occurred while setting Param.");
// Wait for Response
const data = await promiseTimeout(
new Promise((resolve, reject) => {
this.on("Set Attribute Single", (err, data) => {
if (err) reject(err);
resolve(data);
});
}),
this.state.timeout_sp,
readPropsErr
);
this.removeAllListeners("Set Attribute Single");
return data
}
/**
* Gets file data block used for retrieving eds file from some devices
*
* @param classID - CIP Class ID
* @param instance - CIP Instance ID
* @param blockNum - Block Number
* @returns File data
*/
async getFileData(classID: number, instance: number, blockNum: number): Promise<Buffer> {
const { GET_FILE_DATA } = CIP.MessageRouter.services;
const { LOGICAL } = CIP.EPATH.segments;
const identityPath = Buffer.concat([
LOGICAL.build(LOGICAL.types.ClassID, classID),
LOGICAL.build(LOGICAL.types.InstanceID, instance),
]);
const MR = CIP.MessageRouter.build(GET_FILE_DATA, identityPath, Buffer.from([blockNum]));
super.write_cip(MR, super.established_conn);
const readPropsErr = new Error("TIMEOUT occurred while getting file data.");
// Wait for Response
const data = await promiseTimeout(
new Promise((resolve, reject) => {
this.on("Get File Data", (err, data) => {
if (err) reject(err);
resolve(data);
});
}),
this.state.timeout_sp,
readPropsErr
);
this.removeAllListeners("Get File Data");
return data
}
/**
* Disconnects the PLC instance gracefully by issuing forwardClose, UnregisterSession
* and then destroying the socket
* and Returns a Promise indicating a success or failure or the disconnection
*
* @memberof Controller
* @returns Promise that is resolved after disconnection
*/
async disconnect(): Promise<string> {
if (super.established_conn === true) {
const closeid = await this.forwardClose();
if(!closeid) throw new Error("Failed to End Connected EIP Session with Forward Close Request");
}
super.destroy();
this._removeControllerEventHandlers();
return "disconnected";
}
/**
* Writes a forwardOpen Request and retrieves the connection ID used for
* connected messages.
* @returns Promise resolving to OT connection ID
*/
async forwardOpen(): Promise<number> {
const { FORWARD_OPEN } = CIP.MessageRouter.services;
const { LOGICAL } = CIP.EPATH.segments;
const { owner, connectionType, fixedVar, priority} = CIP.ConnectionManager;
// Build Connection Manager Object Logical Path Buffer
const cmPath = Buffer.concat([
LOGICAL.build(LOGICAL.types.ClassID, 0x06), // Connection Manager Object (0x01)
LOGICAL.build(LOGICAL.types.InstanceID, 0x01) // Instance ID (0x01)
]);
// Message Router to Embed in UCMM
const MR = CIP.MessageRouter.build(FORWARD_OPEN, cmPath, Buffer.from([]));
// Create connection parameters
const params = CIP.ConnectionManager.build_connectionParameters(owner["Exclusive"], connectionType["PointToPoint"],priority["Low"],fixedVar["Variable"],500);
this.state.fwd_open_serial = getRandomInt(32767);
const forwardOpenData = CIP.ConnectionManager.build_forwardOpen(this.state.rpi * 3000, params, 1000 , 512, this.state.fwd_open_serial);
// Build MR Path in order to send the message to the CPU
const mrPath = Buffer.concat([
LOGICAL.build(LOGICAL.types.ClassID, 0x02), // Message Router Object (0x02)
LOGICAL.build(LOGICAL.types.InstanceID, 0x01) // Instance ID (0x01)
]);
// Concatenate path to CPU and how to reach the message router
const portPath = Buffer.concat([
this.state.controller.path,
mrPath
]);
// This is the Connection Path data unit (Vol.1 Table 3-5.21)
const connectionPath = Buffer.concat([
Buffer.from([Math.ceil(portPath.length/2)]), //Path size in WORDS
portPath
]);
const forwardOpenPacket = Buffer.concat([
MR,
forwardOpenData,
connectionPath
]);
super.establishing_conn = true;
super.established_conn = false;
super.write_cip(forwardOpenPacket); // We need to bypass unconnected send for now
const readPropsErr = new Error("TIMEOUT occurred while trying forwardOpen Request.");
// Wait for Response
const data = await promiseTimeout(
new Promise((resolve, reject) => {
this.on("Forward Open", (err, data) => {
if (err) reject(err);
resolve(data);
});
}),
this.state.timeout_sp,
readPropsErr
);
this.removeAllListeners("Forward Open");
const OTconnID = data.readUInt32LE(0); // first 4 Bytes are O->T connection ID
super.id_conn = OTconnID;
super.established_conn = true;
super.establishing_conn = false;
return OTconnID;
}
/**
* Writes a forwardClose Request and retrieves the connection ID used for
* connected messages.
*
* @returns Promise resolving OT connection ID
*/
async forwardClose(): Promise<number> {
const { FORWARD_CLOSE } = CIP.MessageRouter.services;
const { LOGICAL } = CIP.EPATH.segments;
// Build Connection Manager Object Logical Path Buffer
const cmPath = Buffer.concat([
LOGICAL.build(LOGICAL.types.ClassID, 0x06), // Connection Manager Object (0x01)
LOGICAL.build(LOGICAL.types.InstanceID, 0x01) // Instance ID (0x01)
]);
// Message Router to Embed in UCMM
const MR = CIP.MessageRouter.build(FORWARD_CLOSE, cmPath, Buffer.from([]));
const forwardCloseData = CIP.ConnectionManager.build_forwardClose(1000 , 0x3333, 0x1337, this.state.fwd_open_serial);
// Build MR Path in order to send the message to the CPU
const mrPath = Buffer.concat([
LOGICAL.build(LOGICAL.types.ClassID, 0x02), // Message Router Object (0x02)
LOGICAL.build(LOGICAL.types.InstanceID, 0x01) // Instance ID (0x01)
]);
// Concatenate path to CPU and how to reach the message router
const portPath = Buffer.concat([
this.state.controller.path,
mrPath
]);
// This is the Connection Path data unit (Vol.1 Table 3-5.21)
const connectionPath = Buffer.concat([
Buffer.from([Math.ceil(portPath.length/2)]), //Path size in WORDS
Buffer.from([0x00]), // Padding
portPath
]);
// Fully assembled packet
const forwardClosePacket = Buffer.concat([
MR,
forwardCloseData,
connectionPath
]);
super.write_cip(forwardClosePacket); // We need to bypass unconnected send for now
const readPropsErr = new Error("TIMEOUT occurred while trying forwardClose Request.");
// Wait for Response
const data = await promiseTimeout(
new Promise((resolve, reject) => {
this.on("Forward Close", (err, data) => {
if (err) reject(err);
resolve(data);
});
}),
this.state.timeout_sp,
readPropsErr
);
this.removeAllListeners("Forward Close");
const OTconnID = data.readUInt32LE(0); // first 4 Bytes are O->T connection ID
super.id_conn = OTconnID;
super.established_conn = false;
super.establishing_conn = true;
return OTconnID;
}
/**
* Writes Ethernet/IP Data to Socket as an Unconnected Message
* or a Transport Class 1 Datagram
*
* @param data - Message Router Packet Buffer
* @param connected - Use Connected Messaging
* @param timeout - Timeout (sec)
* @param cb - Callback to be Passed to Parent.Write()
*/
write_cip(data: Buffer, connected?: boolean, timeout: number = 10, cb: any = null): void {
const { UnconnectedSend } = CIP;
let msg: Buffer;
if (!connected) {
connected = super.established_conn;
}
if (connected === false) {
msg = UnconnectedSend.build(data, this.state.controller.path, this.state.unconnectedSendTimeout);
} else {
msg = data;
}
super.write_cip(msg, connected, timeout, cb);
}
/**
* Reads Controller Identity Object
*
* @returns Promise resolved when completed reading and storing controller properties
*/
async readControllerProps(): Promise<void> {
const { GET_ATTRIBUTE_ALL } = CIP.MessageRouter.services;
const { LOGICAL } = CIP.EPATH.segments;
// Build Identity Object Logical Path Buffer
const identityPath = Buffer.concat([
LOGICAL.build(LOGICAL.types.ClassID, 0x01), // Identity Object (0x01)
LOGICAL.build(LOGICAL.types.InstanceID, 0x01) // Instance ID (0x01)
]);
// Message Router to Embed in UCMM
const MR = CIP.MessageRouter.build(GET_ATTRIBUTE_ALL, identityPath, Buffer.from([]));
this.write_cip(MR);
const readPropsErr = new Error("TIMEOUT occurred while reading Controller Props.");
// Wait for Response
const data = await promiseTimeout(
new Promise((resolve, reject) => {
this.on("Get Attribute All", (err, data) => {
if (err) reject(err);
resolve(data);
});
}),
this.state.timeout_sp,
readPropsErr
);
this.removeAllListeners("Get Attribute All");
// Parse Returned Buffer
this.state.controller.serial_number = data.readUInt32LE(10);
const nameBuf = Buffer.alloc(data.readUInt8(14));
data.copy(nameBuf, 0, 15);
this.state.controller.name = nameBuf.toString("utf8");
const major = data.readUInt8(6);
const minor = data.readUInt8(7);
this.state.controller.version = `${major}.${minor}`;
let status = data.readUInt16LE(8);
this.state.controller.status = status;
status &= 0x0ff0;
this.state.controller.run = (status & 0x00f0) === 0x0060 ? true : false;
this.state.controller.program = (status & 0x00f0) === 0x0070 ? true : false;
this.state.controller.faulted = (status & 0x0f00) === 0 ? false : true;
this.state.controller.minorRecoverableFault = (status & 0x0100) === 0 ? false : true;
this.state.controller.minorUnrecoverableFault = (status & 0x0200) === 0 ? false : true;
this.state.controller.majorRecoverableFault = (status & 0x0400) === 0 ? false : true;
this.state.controller.majorUnrecoverableFault = (status & 0x0800) === 0 ? false : true;
status &= 0x0f00;
this.state.controller.io_faulted = status >> 4 === 2 ? true : false;
this.state.controller.faulted = status >> 4 === 2 ? true : this.state.controller.faulted;
}
/**
* Reads the Controller Wall Clock Object (L8 Named Controllers Only)
*
* @returns Promise resolved when completed reading wall clock
*/
async readWallClock(): Promise<void> {
if (this.state.controller.name.search("L8") === -1)
throw new Error("WallClock Utilities are not supported by this controller type");
const { GET_ATTRIBUTE_SINGLE } = CIP.MessageRouter.services;
const { LOGICAL } = CIP.EPATH.segments;
// Build Identity Object Logical Path Buffer
const identityPath = Buffer.concat([
LOGICAL.build(LOGICAL.types.ClassID, 0x8b), // WallClock Object (0x8B)
LOGICAL.build(LOGICAL.types.InstanceID, 0x01), // Instance ID (0x01)
LOGICAL.build(LOGICAL.types.AttributeID, 0x05) // Local Time Attribute ID
]);
// Message Router to Embed in UCMM
const MR = CIP.MessageRouter.build(GET_ATTRIBUTE_SINGLE, identityPath, Buffer.from([]));
this.write_cip(MR);
const readPropsErr = new Error("TIMEOUT occurred while reading Controller Clock.");
// Wait for Response
const data = await promiseTimeout(
new Promise((resolve, reject) => {
this.on("Get Attribute Single", (err, data) => {
if (err) reject(err);
resolve(data);
});
}),
this.state.timeout_sp,
readPropsErr
);
this.removeAllListeners("Get Attribute Single");
// Parse Returned Buffer
let wallClockArray = [];
for (let i = 0; i < 7; i++) {
wallClockArray.push(data.readUInt32LE(i * 4));
}
// Massage Data to JS Date Friendly Format
wallClockArray[6] = Math.trunc(wallClockArray[6] / 1000); // convert to ms from us
wallClockArray[1] -= 1; // month is 0-based
const date = new Date(wallClockArray[0], wallClockArray[1],wallClockArray[2],wallClockArray[3],wallClockArray[4],wallClockArray[5],wallClockArray[6]);
this.state.controller.time = date;
}
/**
* Write to PLC Wall Clock
*
* @param date - Date Object
* @returns Promise resolved after writing new Date to controller
*/
async writeWallClock(date: Date = new Date()): Promise<void> {
if (this.state.controller.name.search("L8") === -1)
throw new Error("WallClock Utilities are not supported by this controller type");
const { SET_ATTRIBUTE_SINGLE } = CIP.MessageRouter.services;
const { LOGICAL } = CIP.EPATH.segments;
const arr = [];
arr.push(date.getFullYear());
arr.push(date.getMonth() + 1);
arr.push(date.getDate());
arr.push(date.getHours());
arr.push(date.getMinutes());
arr.push(date.getSeconds());
arr.push(date.getMilliseconds() * 1000);
let buf = Buffer.alloc(28);
for (let i = 0; i < 7; i++) {
buf.writeUInt32LE(arr[i], 4 * i);
}
// Build Identity Object Logical Path Buffer
const identityPath = Buffer.concat([
LOGICAL.build(LOGICAL.types.ClassID, 0x8b), // WallClock Object (0x8B)
LOGICAL.build(LOGICAL.types.InstanceID, 0x01), // Instance ID (0x01)
LOGICAL.build(LOGICAL.types.AttributeID, 0x05) // Local Time Attribute ID
]);
// Message Router to Embed in UCMM
const MR = CIP.MessageRouter.build(SET_ATTRIBUTE_SINGLE, identityPath, buf);
this.write_cip(MR);
const writeClockErr = new Error("TIMEOUT occurred while writing Controller Clock.");
// Wait for Response
await promiseTimeout(
new Promise((resolve, reject) => {
this.on("Set Attribute Single", (err, data) => {
if (err) reject(err);
resolve(data);
});
}),
this.state.timeout_sp,
writeClockErr
);
this.removeAllListeners("Set Attribute Single");
this.state.controller.time = date;
}
/**
* Reads Value of Tag and Type from Controller
*
* @param tag - Tag Object to Write
* @param size - Size used for writing array
* @returns Promise resolved after read completed
*/
readTag(tag: Tag | Structure, size = null): Promise<void> {
return this.workers.read.schedule(this._readTag.bind(this), [tag, size], {
priority: 1,
timestamp: new Date()
});
}
/**
* Writes value to Tag
*
* @param tag - Tag Object to Write
* @param value - If Omitted, Tag.value will be used
* @param size - Used for writing arrays
* @returns Promise resolved after complete writing
*/
writeTag(tag: Tag | Structure, value = null, size = 0x01) {
if(tag instanceof Structure) { tag.writeObjToValue(); }
return this.workers.write.schedule(this._writeTag.bind(this), [tag, value, size], {
priority: 1,
timestamp: new Date()
});
}
/**
* Reads All Tags in the Passed Tag Group
*
* @param group - Tag Group instance
* @returns Promise resolved on completion of reading group
*/
readTagGroup(group: TagGroup): Promise<void> {
return this.workers.group.schedule(this._readTagGroup.bind(this), [group], {
priority: 1,
timestamp: new Date()
});
}
/**
* Writes to Tag Group Tags
*
* @param group - Tag Group instance
* @returns Promise resolved after reading tag group
*/
writeTagGroup(group: TagGroup): Promise<void> {
return this.workers.group.schedule(this._writeTagGroup.bind(this), [group], {
priority: 1,
timestamp: new Date()
});
}
/**
* Adds Tag to Subscription Group
*
* @param tag - Tag instance
*/
subscribe(tag: Tag | Structure): void {
this.state.subs.add(tag);
}
/**
* Begin Scanning Subscription Group
*
* @returns Promise resolved after scanning state goes to false
*/
async scan(): Promise<void> {
this.state.scanning = true;
while (this.state.scanning) {
await this.workers.group
.schedule(this._readTagGroup.bind(this), [this.state.subs], {
priority: 10,
timestamp: new Date()
})
.catch(e => {
return Promise.reject(e);
});
await this.workers.group
.schedule(this._writeTagGroup.bind(this), [this.state.subs], {
priority: 10,
timestamp: new Date()
})
.catch(e => {
return Promise.reject(e);
});
await delay(this.state.scan_rate);
}
return Promise.resolve();
}
/**
* Pauses Scanning of Subscription Group
*
*/
pauseScan(): void {
this.state.scanning = false;
}
/**
* Iterates of each tag in Subscription Group
*
* @param callback - Call back function with a Tag instance as a parameter
*/
forEach(callback: (tag: Tag | Structure) => {}): void {
this.state.subs.forEach(callback);
}
/**
*
* @param tagList - Tag list instance to store tagnames from PLC
* @param program - Program name
* @returns Promise resolved when completed
*/
async getControllerTagList(tagList: TagList, program: string = null): Promise<void> {
const getTagListErr = new Error("TIMEOUT occurred while reading tag list");
// Wait for Response
return await promiseTimeout(
tagList.getControllerTags(this, program),
this.state.timeout_sp * 4,
getTagListErr
);
}
// endregion
// region Private Methods
/**
* Initialized Controller Specific Event Handlers
*
*/
_initializeControllerEventHandlers(): void {
this.on("SendRRData Received", this._handleSendRRDataReceived);
this.on("SendUnitData Received", this._handleSendUnitDataReceived);
}
/**
* Remove Controller Specific Event Handlers
*
*/
_removeControllerEventHandlers(): void {
this.removeAllListeners("SendRRData Received");
this.removeAllListeners("SendUnitData Received");
}
/**
* Reads Value of Tag and Type from Controller
*
* @param tag - Tag Object to Write
* @param size - Number of tags to read used for arrays
* @returns Promise resolved when complete
*/
async _readTag(tag: Tag | Structure, size: number = null): Promise<void> {
const MR = tag.generateReadMessageRequest(size);
this.write_cip(MR);
const readTagErr = new Error(`TIMEOUT occurred while writing Reading Tag: ${tag.name}.`);
// Wait for Response
const data = await promiseTimeout(
new Promise((resolve, reject) => {
this.on("Read Tag", async (err, data) => {
if (err && err.generalStatusCode !== 6 && !(err.generalStatusCode === 255 && err.extendedStatus.toString() === [8453].toString())) {
reject(err);
return;
}
if(err && err.generalStatusCode === 255 && err.extendedStatus.toString() === [8453].toString()) {
tag.state.read_size--;
this._readTag(tag).catch(reject);
} else if (err && err.generalStatusCode === 6) {
await this._readTagFragmented(tag, size).catch(reject);
resolve(null);
} else {
resolve(data);
}
});
}),
this.state.timeout_sp,
readTagErr
);
this.removeAllListeners("Read Tag");
if (data) tag.parseReadMessageResponse(data);
}
/**
* Reads Data of Tag from Controller To Big To Fit In One Packet
*
* @param tag - Tag Object to Write
* @param size - Number of tags to read used for arrays
* @returns Promise resolved when complete
*/
async _readTagFragmented(tag: Tag | Structure, size: number = null): Promise<void> {
let offset = 0;
let MR = tag.generateReadMessageRequestFrag(offset, size);
this.write_cip(MR);
const typeSize = (tag.type === "STRUCT") ? 4 : 2;
const readTagErr = new Error(`TIMEOUT occurred while writing Reading Tag: ${tag.name}.`);
let retData = Buffer.alloc(0);
const data = await promiseTimeout(
new Promise((resolve, reject) => {
this.on("Read Tag Fragmented", (err, data) => {
if (err && err.generalStatusCode !== 6) {
reject(err);
return;
}
const dataLength = data.length;
if (offset > 0) data = data.slice(typeSize);
offset += dataLength - typeSize;
if (err && err.generalStatusCode === 6) {
retData = Buffer.concat([retData, data]);
MR = tag.generateReadMessageRequestFrag(offset, size);
this.write_cip(MR);
} else {
retData = Buffer.concat([retData, data]);
resolve(retData);
}
});
}),
this.state.timeout_sp,
readTagErr
);
this.removeAllListeners("Read Tag Fragmented");
tag.parseReadMessageResponse(data);
}
/**
* Writes value to Tag
*
* @param tag - Tag Object to Write
* @param value - If Omitted, Tag.value will be used
* @param size - Number of tags to read used for arrays
* @returns Promise resolved when complete
*/
async _writeTag(tag: Tag | Structure, value: any = null, size: number = 0x01): Promise<void> {
let dataSize = 1
if (tag.state.tag.type >= 0xc1 && tag.state.tag.type <= 0xca) {
dataSize = CIP.DataTypes.TypeSizes[tag.state.tag.type]
}
if (tag.state.tag.value.length * dataSize > (480 - tag.path.length))
return this._writeTagFragmented(tag, value, size);
const MR = tag.generateWriteMessageRequest(value, size);
this.write_cip(MR);
const writeTagErr = new Error(`TIMEOUT occurred while writing Writing Tag: ${tag.name}.`);
// Wait for Response
await promiseTimeout(
new Promise((resolve, reject) => {
// Full Tag Writing
this.on("Write Tag", (err, data) => {
if (err) reject(err);
tag.unstageWriteRequest();
resolve(data);
});
// Masked Bit Writing
this.on("Read Modify Write Tag", (err, data) => {
if (err) reject(err);
tag.unstageWriteRequest();
resolve(data);
});
}),
this.state.timeout_sp,
writeTagErr
);
this.removeAllListeners("Write Tag");
this.removeAllListeners("Read Modify Write Tag");
}
/**
* Writes value to Tag To Big To Fit In One Packet
*
* @param tag - Tag Object to Write. Used only for Structures.
* @param value - If Omitted, Tag.value will be used
* @param size - Number of tags to read used for arrays
* @returns Promise resolved when complete
*/
async _writeTagFragmented(tag: Structure | Tag, value: any = null, size: number = 0x01): Promise<void> {
if(value !== null) tag.value = value;
let offset = 0;
const maxPacket = 480 - tag.path.length;
let dataSize = 1
if (tag.state.tag.type >= 0xc1 && tag.state.tag.type <= 0xca) {
dataSize = CIP.DataTypes.TypeSizes[tag.state.tag.type]
}
let valueFragment = tag.state.tag.value.slice(0, Math.floor(maxPacket / dataSize));
let MR = tag.generateWriteMessageRequestFrag(offset, valueFragment, size);
this.write_cip(MR);
let numWrites = 0;
let totalWrites = Math.ceil(tag.state.tag.value.length * dataSize / maxPacket);
const writeTagErr = new Error(`TIMEOUT occurred while writing Writing Tag: ${tag.name}.`);
// Wait for Response
await promiseTimeout(
new Promise((resolve, reject) => {
// Full Tag Writing
this.on("Write Tag Fragmented", (err, data) => {
if (err) return reject(err);
offset += Math.floor(maxPacket / dataSize) * dataSize;
numWrites ++;
if (numWrites < totalWrites) {
valueFragment = tag.state.tag.value.slice(Math.floor(offset / dataSize), Math.floor(maxPacket / dataSize) + Math.floor(offset / dataSize));
MR = tag.generateWriteMessageRequestFrag(offset, valueFragment, size);
this.write_cip(MR);
} else {
tag.unstageWriteRequest();
return resolve(data);
}
});
}),
this.state.timeout_sp,
writeTagErr
);
this.removeAllListeners("Write Tag Fragmented");
}
/**
* Reads All Tags in the Passed Tag Group
*
* @param group - Tag group instance
* @returns Promise resolved when complete
*/
async _readTagGroup(group: TagGroup): Promise<void> {
const messages = group.generateReadMessageRequests();
const readTagGroupErr = new Error("TIMEOUT occurred while writing Reading Tag Group.");
// Send Each Multi Service Message
for (let msg of messages) {
this.write_cip(msg.data);
// Wait for Controller to Respond
const data = await promiseTimeout(
new Promise((resolve, reject) => {
this.on("Multiple Service Packet", async (err, data) => {
if (err && err.generalStatusCode !== 6 && err.generalStatusCode !== 4) reject(err);
for (let i = 0; i < data.length; i++) {
if (data[i].generalStatusCode === 6) {
await this._readTagFragmented(group.state.tags[msg.tag_ids[i]]).catch(reject);
}
}
resolve(data);
});
}),
this.state.timeout_sp,
readTagGroupErr
);
this.removeAllListeners("Multiple Service Packet");
// Parse Messages
group.parseReadMessageResponses(data, msg.tag_ids);
}
}
/**
* Writes to Tag Group Tags
*
* @param group - Tag Group instance
* @returns Promise resolved when complete
*/
async _writeTagGroup(group: TagGroup): Promise<void> {
const messages = group.generateWriteMessageRequests();
const writeTagGroupErr = new Error("TIMEOUT occurred while Writing Tag Group.");
// Send Each Multi Service Message
for (let msg of messages) {
if (msg.data) {
this.write_cip(msg.data);
// Wait for Controller to Respond
const data = await promiseTimeout(
new Promise((resolve, reject) => {
this.on("Multiple Service Packet", (err, data) => {
if (err) reject(err);
resolve(data);
});
}),
this.state.timeout_sp,
writeTagGroupErr
);
this.removeAllListeners("Multiple Service Packet");
group.parseWriteMessageRequests(msg.tag_ids);
} else {
await this.writeTag(msg.tag).catch(e => {throw e;});
}
}
}
// endregion
// region Event Handlers
/**
* @typedef EncapsulationData
* @type {Object}
* @property {number} commandCode - Ecapsulation Command Code
* @property {string} command - Encapsulation Command String Interpretation
* @property {number} length - Length of Encapsulated Data
* @property {number} session - Session ID
* @property {number} statusCode - Status Code
* @property {string} status - Status Code String Interpretation
* @property {number} options - Options (Typically 0x00)
* @property {Buffer} data - Encapsulated Data Buffer
*/
/*****************************************************************/
/**
* @typedef MessageRouter
* @type {Object}
* @property {number} service - Reply Service Code
* @property {number} generalStatusCode - General Status Code (Vol 1 - Appendix B)
* @property {number} extendedStatusLength - Length of Extended Status (In 16-bit Words)
* @property {Array} extendedStatus - Extended Status
* @property {Buffer} data - Status Code
*/
/*****************************************************************/
/**
* Handles SendRRData Event Emmitted by Parent and Routes
* incoming Message
*
* @param srrd - Array of Common Packet Formatted Objects
*/
_handleSendRRDataReceived(srrd: CommonPacketData[]): void {
const { service, generalStatusCode, extendedStatus, data } = CIP.MessageRouter.parse(
srrd[1].data
);
const {
GET_INSTANCE_ATTRIBUTE_LIST,
GET_ATTRIBUTES,
GET_ATTRIBUTE_SINGLE,
GET_ATTRIBUTE_ALL,
SET_ATTRIBUTE_SINGLE,
READ_TAG,
READ_TAG_FRAGMENTED,
WRITE_TAG,
WRITE_TAG_FRAGMENTED,
READ_MODIFY_WRITE_TAG,
MULTIPLE_SERVICE_PACKET,
FORWARD_OPEN,
FORWARD_CLOSE,
GET_FILE_DATA
} = CIP.MessageRouter.services;
let error = generalStatusCode !== 0 ? { generalStatusCode, extendedStatus } : null;
// Route Incoming Message Responses
/* eslint-disable indent */
switch (service - 0x80) {
case GET_FILE_DATA:
this.emit("Get File Data", error, data);
break;
case FORWARD_CLOSE:
this.emit("Forward Close", error, data);
this.emit("Read Modify Write Tag", error, data);
break;
case FORWARD_OPEN:
this.emit("Forward Open", error, data);
break;
case GET_INSTANCE_ATTRIBUTE_LIST:
this.emit("Get Instance Attribute List", error, data);
break;
case GET_ATTRIBUTES:
this.emit("Get Attributes", error, data);
break;
case GET_ATTRIBUTE_SINGLE:
this.emit("Get Attribute Single", error, data);
break;
case GET_ATTRIBUTE_ALL:
this.emit("Get Attribute All", error, data);
break;
case SET_ATTRIBUTE_SINGLE:
this.emit("Set Attribute Single", error, data);
break;
case READ_TAG:
this.emit("Read Tag", error, data);
break;
case READ_TAG_FRAGMENTED:
this.emit("Read Tag Fragmented", error, data);
break;
case WRITE_TAG:
this.emit("Write Tag", error, data);
break;
case WRITE_TAG_FRAGMENTED:
this.emit("Write Tag Fragmented", error, data);
break;
case READ_MODIFY_WRITE_TAG:
this.emit("Read Modify Write Tag", error, data);
this.emit("Forward Close", error, data);
break;
case MULTIPLE_SERVICE_PACKET: {
// If service errored then propogate error
if (error && error.generalStatusCode !== 30) {
this.emit("Multiple Service Packet", error, data);
break;
}
// Get Number of Services to be Enclosed
let services = data.readUInt16LE(0);
let offsets = [];
let responses = [];
// Build Array of Buffer Offsets
for (let i = 0; i < services; i++) {
offsets.push(data.readUInt16LE(i * 2 + 2));
}
// Gather Messages within Buffer
for (let i = 0; i < offsets.length - 1; i++) {
const length = offsets[i + 1] - offsets[i];
let buf = Buffer.alloc(length);
data.copy(buf, 0, offsets[i], offsets[i + 1]);
// Parse Message Data
const msgData = CIP.MessageRouter.parse(buf);
if (msgData.generalStatusCode !== 0 && error.generalStatusCode !== 30) {
error = {
generalStatusCode: msgData.generalStatusCode,
extendedStatus: msgData.extendedStatus
};
}
responses.push(msgData);
}
// Handle Final Message
const length = data.length - offsets[offsets.length - 1];
let buf = Buffer.alloc(length);
data.copy(buf, 0, offsets[offsets.length - 1]);
const msgData = CIP.MessageRouter.parse(buf);
if (msgData.generalStatusCode !== 0) {
error = {
generalStatusCode: msgData.generalStatusCode,
extendedStatus: msgData.extendedStatus
};
}
responses.push(msgData);
this.emit("Multiple Service Packet", error, responses);
break;
}
default:
this.emit("Unknown Reply", { generalStatusCode: 0x99, extendedStatus: [] }, data);
break;
}
/* eslint-enable indent */
}
/**
* Handles SendUnitData Event Emmitted by Parent and Routes
* incoming Message
*
* @param sud - Array of Common Packet Formatted Objects
*/
_handleSendUnitDataReceived(sud: CommonPacketData[]) {
let sudnew = sud[1].data.subarray(2); // First 2 bytes are Connection sequence number
const { service, generalStatusCode, extendedStatus, data } = CIP.MessageRouter.parse(
sudnew
);
const {
GET_ATTRIBUTE_SINGLE,
GET_ATTRIBUTES,
GET_ATTRIBUTE_ALL,
GET_INSTANCE_ATTRIBUTE_LIST,
SET_ATTRIBUTE_SINGLE,
READ_TAG,
READ_TAG_FRAGMENTED,
WRITE_TAG,
WRITE_TAG_FRAGMENTED,
READ_MODIFY_WRITE_TAG,
MULTIPLE_SERVICE_PACKET,
FORWARD_OPEN,
FORWARD_CLOSE,
GET_FILE_DATA
} = CIP.MessageRouter.services;
let error = generalStatusCode !== 0 ? { generalStatusCode, extendedStatus } : null;
// Route Incoming Message Responses
/* eslint-disable indent */
switch (service - 0x80) {
case GET_FILE_DATA:
this.emit("Get File Data", error, data);
break;
case FORWARD_CLOSE:
this.emit("Forward Close", error, data);
this.emit("Read Modify Write Tag", error, data);
break;
case FORWARD_OPEN:
this.emit("Forward Open", error, data);
break;
case GET_ATTRIBUTES:
this.emit("Get Attributes", error, data);
break;
case GET_ATTRIBUTE_SINGLE:
this.emit("Get Attribute Single", error, data);
break;
case GET_ATTRIBUTE_ALL:
this.emit("Get Attribute All", error, data);
break;
case SET_ATTRIBUTE_SINGLE:
this.emit("Set Attribute Single", error, data);
break;
case GET_INSTANCE_ATTRIBUTE_LIST:
this.emit("Get Instance Attribute List", error, data);
break;
case READ_TAG:
this.emit("Read Tag", error, data);