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 "); 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 "); 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 "); 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 "); 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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); 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: 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 */ } // endregion /** * Get tag list tags that are not reserved tags * * @returns Array of tag list items */ get tagList(): tagListTag[] { return this.state.tagList.tags.filter(tag => (!tag.type.reserved)); } /** * Get tag list templates * * @returns List of templates indexed by tag name hash */ get templateList(): tagListTemplates { return this.state.tagList.templates; } /** * Gets an arrays size (Not optimized) * * @param tag - Tag instance * @returns */ async getTagArraySize(tag: Tag | Structure) { let i = 1; do { tag.state.read_size = i; try { await this.readTag(tag); i++; } catch { } } while (tag.state.read_size === (i-1)); tag.state.tag.value = null; tag.state.tag.controllerValue = null; return tag.state.read_size; } /** * Helper function to add new tag to PLC tag group * * @param tagname - Tag or Structure name * @param program - PLC program name. null = Controller scope * @param subscribe - enable read and write when scanning * @param arrayDims - array dimension number * @param arraySize - array size * @returns tag or structure instance */ newTag(tagname: string, program: string = null, subscribe: boolean = true, arrayDims: number = 0, arraySize: number = 0x01): Tag | Structure { let template = this.state.tagList.getTemplateByTag(tagname, program); let tag = null; if (template) { tag = new Structure(tagname, this.state.tagList, program, null, 0, arrayDims, arraySize); if (subscribe) this.subscribe(tag); return tag; } else { tag = new Tag(tagname, program, null, 0, arrayDims, arraySize); if (subscribe) this.subscribe(tag); return tag; } } /** * Get tag or structure instance by name * * @param name * @returns */ getTagByName(name: string): Tag | Structure { return Object.values(this.state.subs.state.tags).find((tag) => tag.state.tag.name === name); } } function getRandomInt(max) { return Math.floor(Math.random() * Math.floor(max)); } export default Controller;