import { ENIP, CIP, enipConnection, enipError, enipTCP, enipSession } from "../../enip"; import dateFormat from "dateformat"; import TagGroup from "../../tag-group"; import { promiseTimeout } from "../../utilities"; import Queue from "task-easy"; const compare = (obj1, obj2) => { 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 { OTconnectionID: number; TOconnectionID: number; configInstance: any; outputInstance: any; inputInstance: any; tryingToConnect: boolean; 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, } workers: { read: any; write: any; group: any; } constructor(connectedMessaging: boolean = true, configInstance: any, outputInstance: any, inputInstance: any) { super(); this.OTconnectionID = 0; this.TOconnectionID = 0; this.configInstance = configInstance; this.outputInstance = outputInstance; this.inputInstance = inputInstance; this.tryingToConnect = false; 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, }; this.workers = { read: new Queue(compare), write: new Queue(compare), group: new Queue(compare), }; } // region Property Definitions /** * Returns the Scan Rate of Subscription Tags * * @memberof Controller * @returns {number} ms */ get scan_rate() { return this.state.scan_rate; } /** * Returns the Timeout Setpoint * * @memberof Controller * @returns {number} ms */ get timeout_sp() { return this.state.timeout_sp; } /** * Sets the Timeout Setpoint * * @memberof Controller */ set timeout_sp(sp) { if (typeof sp !== "number") throw new Error("timeout_sp must be of Type "); this.state.timeout_sp = Math.trunc(sp); } /** * Returns the Rpi * * @memberof Controller * @returns {number} ms */ get rpi() { return this.state.rpi; } /** * Sets the Rpi * * @memberof Controller */ set rpi(sp) { 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 * * @readonly * @memberof Controller */ get scanning() { return this.state.scanning; } /** * Returns the connected / unconnected messaging mode * * @memberof Controller * @returns {boolean} true, if connected messaging; false, if unconnected messaging */ get connectedMessaging() { return this.state.connectedMessaging; } /** * Sets the Mode to connected / unconnected messaging * * @memberof Controller */ set connectedMessaging(conn) { 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 {object} */ get properties() { return this.state.controller; } /** * Fetches the last timestamp retrieved from the controller * in human readable form * * @readonly * @memberof Controller */ get time() { 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, localAddress: string): 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"); } await super.connect(IP_ADDR, this.timeout_sp, localAddress); this._initializeControllerEventHandlers(); // Connect sendRRData Event this.OTconnectionID = await this.forwardOpen(); return this.OTconnectionID; } /** * 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} */ async disconnect() { 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. * @memberof Controller * @returns {Promise} */ async forwardOpen() { 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 paramsOT = CIP.ConnectionManager.build_connectionParameters(owner.Exclusive, connectionType.PointToPoint, priority.Scheduled, fixedVar.Fixed, 6 + this.outputInstance.size); const paramsTO = CIP.ConnectionManager.build_connectionParameters(owner.Exclusive, connectionType.PointToPoint, priority.Scheduled, fixedVar.Fixed, 2 + this.inputInstance.size); this.state.fwd_open_serial = getRandomInt(32767); const forwardOpenData = CIP.ConnectionManager.build_forwardOpen(this.state.rpi * 1000, paramsOT, 1000 , 32, this.state.fwd_open_serial); const ioPath = this._ioPath(); // This is the Connection Path data unit (Vol.1 Table 3-5.21) const connectionPath = Buffer.concat([ Buffer.from([Math.ceil(ioPath.length/2)]), //Path size in WORDS ioPath ]); // Next 2 lines change the result of CIP.ConnectionManager.build_forwardOpen forwardOpenData.writeUInt8(1, 34); // Change to Class 1 forwardOpenData.writeUInt16LE(paramsTO, 32); // Change T=>O connection parameters 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; this.TOconnectionID = forwardOpenData.readUInt32LE(6); this.OTconnectionID = OTconnID; return OTconnID; } /** * Generates path to IO assembly instances */ _ioPath() { const LOGICAL = CIP.EPATH.segments.LOGICAL; const path = LOGICAL.build(LOGICAL.types.Special, 0x04); const vendorID = Buffer.from([0x00, 0x00]); const deviceType = Buffer.from([0x00, 0x00]); const productCode = Buffer.from([0x00, 0x00]); const compatMajorRevision = Buffer.from([0x00]); const minorRevision = Buffer.from([0x00]); const electronicKey = Buffer.concat([path, vendorID, deviceType, productCode, compatMajorRevision, minorRevision]); //Assembly Object Class const assemblyObjectClass = LOGICAL.build(LOGICAL.types.ClassID, 0x04); // Config Instance const configInstance = LOGICAL.build(LOGICAL.types.InstanceID, this.configInstance.assembly); // Output Instance const pointOT = LOGICAL.build(LOGICAL.types.ConnPoint, this.outputInstance.assembly); // Input Instance const pointTO = LOGICAL.build(LOGICAL.types.ConnPoint, this.inputInstance.assembly); // Config Data let cipPath; if (this.configInstance.size > 0 && Buffer.isBuffer(this.configInstance.data) && this.configInstance.size === this.configInstance.data.length) { const DATA = CIP.EPATH.segments.DATA; cipPath = Buffer.concat([assemblyObjectClass, configInstance, pointOT, pointTO, DATA.build(this.configInstance.data, false)]); } else { cipPath = Buffer.concat([assemblyObjectClass, configInstance, pointOT, pointTO]); } return(Buffer.concat([electronicKey, cipPath])); } /** * Writes a forwardClose Request and retrieves the connection ID used for * connected messages. */ async forwardClose() { 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 * * NOTE: Cant Override Socket Write due to net.Socket.write * implementation. =[. Thus, I am spinning up a new Method to * handle it. Dont Use Enip.write, use this function instead. * * @override * @param {buffer} data - Message Router Packet Buffer * @param {boolean} [connected=false] * @param {number} [timeout=10] - Timeout (sec) * @param {function} [cb=null] - Callback to be Passed to Parent.Write() * @memberof ENIP */ write_cip(data: Buffer, connected?: boolean, timeout: number = 10, cb: any = null) { const { UnconnectedSend } = CIP; let msg: Buffer; if (!connected) { connected = super.established_conn; } if (connected === false) { msg = UnconnectedSend.build(data, this.state.controller.path); } else { msg = data; } super.write_cip(msg, connected, timeout, cb); } /** * Reads Controller Identity Object * * @memberof Controller * @returns {Promise} */ async readControllerProps() { 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.length - 15); 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.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; } // region Private Methods /** * Initialized Controller Specific Event Handlers * * @memberof Controller */ _initializeControllerEventHandlers() { this.on("SendRRData Received", this._handleSendRRDataReceived); this.on("SendUnitData Received", this._handleSendUnitDataReceived); } /** * Remove Controller Specific Event Handlers * * @memberof Controller */ _removeControllerEventHandlers() { this.removeAllListeners("SendRRData Received"); this.removeAllListeners("SendUnitData Received"); } // 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 {Array} srrd - Array of Common Packet Formatted Objects * @memberof Controller */ _handleSendRRDataReceived(srrd) { 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 } = CIP.MessageRouter.services; let error = generalStatusCode !== 0 ? { generalStatusCode, extendedStatus } : null; // Route Incoming Message Responses /* eslint-disable indent */ switch (service - 0x80) { 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 */ } _handleSendUnitDataReceived(sud) { let sudnew = sud[1].data.slice(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 } = CIP.MessageRouter.services; let error = generalStatusCode !== 0 ? { generalStatusCode, extendedStatus } : null; // Route Incoming Message Responses /* eslint-disable indent */ switch (service - 0x80) { 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 */ } // _handleSessionRegistrationFailed(error) { // // TODO: Implement Handler if Necessary // } // endregion } function getRandomInt(max) { return Math.floor(Math.random() * Math.floor(max)); } module.exports = Controller;