├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── .npmignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── LICENSE ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── TSConfig.json ├── examples ├── array1k.js ├── browser.js ├── controller-manager-test.js ├── getAttributeSingle.js ├── io.js └── strings.js ├── package-lock.json ├── package.json ├── src ├── browser │ └── index.ts ├── config.ts ├── controller-manager │ └── index.ts ├── controller │ ├── __snapshots__ │ │ └── controller.spec.js.snap │ ├── controller.spec.js │ └── index.ts ├── enip │ ├── cip │ │ ├── connection-manager │ │ │ ├── __snapshots__ │ │ │ │ └── connection-manager.spec.js.snap │ │ │ ├── connection-manager.spec.js │ │ │ └── index.ts │ │ ├── data-types │ │ │ ├── data-types.spec.js │ │ │ └── index.ts │ │ ├── epath │ │ │ ├── index.ts │ │ │ └── segments │ │ │ │ ├── data │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── data.spec.js.snap │ │ │ │ ├── data.spec.js │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logical │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── logical.spec.js.snap │ │ │ │ ├── index.ts │ │ │ │ └── logical.spec.js │ │ │ │ └── port │ │ │ │ ├── __snapshots__ │ │ │ │ └── port.spec.js.snap │ │ │ │ ├── index.ts │ │ │ │ └── port.spec.js │ │ ├── index.ts │ │ ├── message-router │ │ │ ├── __snapshots__ │ │ │ │ └── message-router.spec.js.snap │ │ │ ├── index.ts │ │ │ └── message-router.spec.js │ │ └── unconnected-send │ │ │ ├── __snapshots__ │ │ │ └── unconnected-send.spec.js.snap │ │ │ ├── index.ts │ │ │ └── unconnected-send.spec.js │ ├── encapsulation │ │ ├── __snapshots__ │ │ │ └── encapsulation.spec.js.snap │ │ ├── encapsulation.spec.js │ │ └── index.ts │ ├── enip.spec.js │ └── index.ts ├── index.ts ├── io │ ├── fork │ │ ├── child.js │ │ └── parent.js │ ├── index.js │ ├── tcp │ │ └── index.ts │ └── udp │ │ ├── connection │ │ ├── index.js │ │ ├── inputmap │ │ │ └── index.ts │ │ ├── outputmap │ │ │ └── index.ts │ │ └── sna │ │ │ └── index.ts │ │ └── index.js ├── structure │ ├── index.ts │ └── template │ │ └── index.ts ├── tag-group │ ├── __snapshots__ │ │ └── tag-group.spec.js.snap │ ├── index.ts │ └── tag-group.spec.js ├── tag-list │ ├── __snapshots__ │ │ └── tag-list.spec.js.snap │ ├── index.ts │ └── tag-list.spec.js ├── tag │ ├── __snapshots__ │ │ └── tag.spec.js.snap │ ├── index.ts │ └── tag.spec.js └── utilities │ ├── index.ts │ └── utilities.spec.js └── tests ├── plc_comm.js └── plc_comm_tags.json /.eslintignore: -------------------------------------------------------------------------------- 1 | 2 | node_modules/** 3 | coverage/** 4 | scripts/** -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | "jest/globals": true, 4 | es6: true, 5 | node: true 6 | }, 7 | extends: ["eslint:recommended"], 8 | parserOptions: { 9 | ecmaVersion: 8, 10 | sourceType: "module", 11 | ecmaFeatures: { 12 | jsx: true, 13 | experimentalObjectRestSpread: true 14 | } 15 | }, 16 | rules: { 17 | indent: ["error", 4], 18 | "no-console": 0, 19 | quotes: ["error", "double"], 20 | semi: ["error", "always"], 21 | "jest/no-disabled-tests": "warn", 22 | "jest/no-focused-tests": "error", 23 | "jest/no-identical-title": "error", 24 | "jest/prefer-to-have-length": "warn", 25 | "jest/valid-expect": "error" 26 | }, 27 | plugins: ["jest"] 28 | }; 29 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Ignore Node Modules 3 | /node_modules 4 | 5 | # Ignore Editor Settings 6 | /.vscode/* 7 | 8 | # Ignore Jest Coverage Reports 9 | /coverage 10 | 11 | # Ignore Local Script Folder 12 | /scripts 13 | 14 | # Ignore local test 15 | /src/maintest* 16 | 17 | # Test Scripts 18 | test*.js 19 | 20 | # Distribution 21 | /dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/*.spec.js 2 | /coverage 3 | /manuals 4 | /scripts 5 | 6 | **/__snapshots__ 7 | CONTRIBUTE.md -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | notifications: 5 | webhooks: 6 | urls: 7 | - https://webhooks.gitter.im/e/7cce00f2d081132a34c2 8 | on_success: change # options: [always|never|change] default: always 9 | on_failure: always # options: [always|never|change] default: always 10 | on_start: never # options: [always|never|change] default: always -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project Owner at cmseaton42@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | This is the **CONTRIBUTE** section of our project. Great to have you here. Here are a few ways you can help make this project better! 4 | 5 | ## Team Members 6 | 7 | *New Team Members Welcome...* 8 | 9 | * **Canaan Seaton** - *Owner* - [GitHub Profile](https://github.com/cmseaton42) - [Personal Website](http://www.canaanseaton.com/) 10 | * **Patrick McDonagh** - *Collaborator* - [GitHub Profile](https://github.com/patrickjmcd) 11 | 12 | ## Support 13 | 14 | How can I help? 15 | 16 | - Simply use the module. The more people who use it, the better it will become. 17 | - Get Involved (see below for more details) 18 | - A freshly dropped star on the repo is always appreciated ;) 19 | 20 | ## Learn & Listen 21 | 22 | This section includes ways to get started with your open source project. Include links to documentation and to different communication channels: 23 | 24 | * How to Open Source - See this excellent [article](https://opensource.guide/how-to-contribute/) 25 | * To Learn about CIP and Ethernet/IP - See the [Manuals](https://github.com/cmseaton42/node-ethernet-ip/tree/master/manuals) folder 26 | * Ask - See the project [Gitter](https://gitter.im/node-ethernet-ip/Lobby) community 27 | * Blog Posts - Coming Soon.... 28 | 29 | ## Get Involved 30 | 31 | So you say you wanna help? Here's how! 🎉 👍 32 | 33 | **Submitting Issues** 34 | 35 | Contributing to the Code is great but feedback on the project is just as important. 36 | 37 | 1. Research to make sure the issue isn't already being tracked 38 | 2. Submit your issue via the project [issue tracker](https://github.com/cmseaton42/node-ethernet-ip/issues) 39 | - Be Clear 40 | - Be Thorough (too much information >>> not enough information) 41 | - Include Pictures (Screenshots of Errors, gifs, etc) 42 | - Include *Node Version* -> `node --version` 43 | - Include *package version* -> `npm list` 44 | 45 | **Protip:** Use the project [Issue Template](https://github.com/cmseaton42/node-ethernet-ip/blob/master/ISSUE_TEMPLATE.md) as a starting point 46 | 47 | **Feature Requests** 48 | 49 | Have you ever been working with a project and thought *Man, I wish it just did this out of the box*? 50 | 51 | 1. Submit your feature request to the [issue tracker](https://github.com/cmseaton42/node-ethernet-ip/issues) **Be Sure to Mark your Issue as a Feature Request in the title (eg `[FEATURE REQUEST] Some Awesome Idea`) 52 | - Be Clear 53 | - Be Thorough (too much information >>> not enough information) 54 | - Don't submit it and *forget* it, be prepared to answer some follow up questions 55 | 56 | **Protip:** Use the project [Issue Template](https://github.com/cmseaton42/node-ethernet-ip/blob/master/ISSUE_TEMPLATE.md) as a starting point 57 | 58 | **Contributing Code** 59 | 60 | 1. Open an issue if one doesn't already partain to your contribution 61 | 2. Discuss the changes you want to make in the issue tracker to see if someone is already working on it or if there is a reason it shouldn't be added 62 | 1. Assumming the outcome of the above discussion good to go, fork the Repo 63 | 2. Clone your forked copy to your local machine 64 | 3. Add the host repo as upstream -> `git remote add upstream https://github.com/cmseaton42/node-ethernet-ip.git` 65 | 3. Get acclamated with the environment (ask questions if necessary) 66 | 5. Make a new branch for your changes 67 | 6. Start *hacking* away at some crispy new code 68 | 7. Make sure to add tests 69 | 8. Submit you're PR to the [Primary](https://github.com/cmseaton42/node-ethernet-ip) repo 70 | - **Protip:** Use the [Pull Request Template](https://github.com/cmseaton42/node-ethernet-ip/blob/master/PULL_REQUEST_TEMPLATE.md) as a starting point 71 | 9. Wait for feedback 72 | 10. Make changes if necessary and re-submit 73 | 11. Boom, PR accepted 👍 💯 🎉 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Current Behavior 4 | 5 | 6 | 7 | ## Expected Behavior 8 | 9 | 10 | 11 | ## Possible Solution (Optional) 12 | 13 | 14 | 15 | ## Context 16 | 17 | 18 | 19 | ## Steps to Reproduce (for bugs only) 20 | 21 | 22 | 1. 23 | 2. 24 | 3. 25 | 4. 26 | 27 | ## Your Environment 28 | 29 | * Package version (Use `npm list` - e.g. *1.0.6*): 30 | * Node Version (Use `node --version` - e.g. *9.8.0*): 31 | * Operating System and version: 32 | * Controller Type (eg 1756-L83E/B): 33 | * Controller Firmware (eg 30.11): 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### Description, Motivation, and Context 4 | 5 | 6 | 7 | ## How Has This Been Tested? 8 | 9 | 10 | 11 | 12 | ## Screenshots (if appropriate): 13 | 14 | ## Types of changes 15 | 16 | - [ ] Bug fix (non-breaking change which fixes an issue) 17 | - [ ] New feature (non-breaking change which adds functionality) 18 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 19 | 20 | ## Checklist: 21 | 22 | 23 | - [ ] My code follows the code style of this project. 24 | - [ ] My change requires a change to the documentation. 25 | - [ ] I have updated the documentation accordingly. 26 | - [ ] I have read the **CONTRIBUTING** document. 27 | - [ ] I have added tests to cover my changes. 28 | - [ ] All new and existing tests passed. 29 | - [ ] This is a work in progress, and I want some feedback (If yes, please mark it in the title -> e.g. `[WIP] Some awesome PR title`) 30 | 31 | ## Related Issue 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /TSConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "ES2021", 6 | "moduleResolution": "node", 7 | "outDir": "dist", 8 | "allowJs": true, 9 | "declaration": true 10 | }, 11 | "include": ["src/**/*"], 12 | "ignore":["node_modules"] 13 | } -------------------------------------------------------------------------------- /examples/array1k.js: -------------------------------------------------------------------------------- 1 | const {Controller, Tag, EthernetIP, Structure} = require("../dist/index.js") 2 | 3 | let c = new Controller(); 4 | 5 | (async function (){ 6 | await c.connect('192.168.1.10'); 7 | let tag= new Tag('array1kREAL', 'MainProgram', undefined, 0, 1, 1000) 8 | await c.readTag(tag, 1000) 9 | 10 | console.log(tag.value) 11 | tag.value[999] = 1.23 12 | console.log(tag.value) 13 | await c.writeTag(tag) 14 | console.log(c.state.tagList) 15 | 16 | let tag2 = new Structure('BigStruct', c.state.tagList, 'MainProgram') 17 | 18 | await c.readTag(tag2) 19 | tag2.value.STUFF1[0] = 1; 20 | await console.log(tag2.value) 21 | 22 | await c.writeTag(tag2) 23 | })(); -------------------------------------------------------------------------------- /examples/browser.js: -------------------------------------------------------------------------------- 1 | let Browser = require('../dist/index').Browser 2 | 3 | let b = new Browser() 4 | 5 | b.on('New Device', (d) => { 6 | console.log(d) 7 | }) -------------------------------------------------------------------------------- /examples/controller-manager-test.js: -------------------------------------------------------------------------------- 1 | const {extController} = require('../dist/index.js') 2 | 3 | 4 | let c = new extController('192.168.1.10', 0, 50) 5 | 6 | c.connect() 7 | 8 | c.on('TagChanged', (tag, prevValue) => { 9 | console.log(tag.name, 'changed from', prevValue, '=>', tag.value) 10 | }) 11 | 12 | let tagTests = [ 13 | { 14 | name: 'TestUDT2[0]', 15 | program: 'MainProgram' 16 | }, 17 | { 18 | name: 'TestUDT22[0]', 19 | program: 'MainProgram' 20 | }, 21 | { 22 | name: 'string1' 23 | }, 24 | { 25 | name: 'string2' 26 | } 27 | ] 28 | 29 | tagTests.forEach(tagTest => { 30 | c.addTag(tagTest.name, tagTest.program, tagTest.arrayDims, tagTest.arraySize) 31 | }) 32 | 33 | c.on('Connected', thisCont => { 34 | console.log('Connected',thisCont.ipAddress) 35 | }) 36 | 37 | c.on('Disconnected', () => { 38 | console.log('Disconnected') 39 | }) 40 | 41 | c.on('Error', e => { 42 | console.log(e) 43 | }) 44 | 45 | c.on('TagUnknown', t => { 46 | console.log('TagUnknown', t.name) 47 | //Remove Unknown Tag 48 | c.removeTag(t.name, t.program) 49 | }) 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /examples/getAttributeSingle.js: -------------------------------------------------------------------------------- 1 | const {Controller} = require("../dist/index.js") 2 | 3 | let c = new Controller(false); 4 | 5 | //Ethernet IP Device Get Parameter (E1 Plus Overload Relay) 6 | c.connect('192.168.1.11', Buffer.from([]), false).then(async () => { 7 | 8 | let paramNum = 1; 9 | 10 | //Get parameter value 11 | let value = await c.getAttributeSingle(0x0f,paramNum,1).catch(e => {console.log(e)}); 12 | 13 | //Get parameter name 14 | let name = await c.getAttributeSingle(0x0f,paramNum,7).catch(e => {console.log(e)}); 15 | 16 | console.log(name.slice(1).toString(), value); 17 | 18 | 19 | }) -------------------------------------------------------------------------------- /examples/io.js: -------------------------------------------------------------------------------- 1 | const { IO } = require("../dist/index.js") 2 | let scanner = new IO.ForkScanner(); 3 | let testConfigData = '0304010001000000000000000001010001\ 4 | 000000000000000001030001000000000000000001030001000000000\ 5 | 0000000010300010000000000000000010300010000000000000000010\ 6 | 30001000000000000000001030001000000000000000001'; 7 | 8 | let configData = Buffer.from(testConfigData, 'hex') 9 | 10 | let config = { 11 | configInstance: { 12 | assembly: 199, 13 | size: 98, 14 | data: configData 15 | }, 16 | inputInstance: { 17 | assembly: 100, 18 | size: 446 19 | }, 20 | outputInstance: { 21 | assembly: 150, 22 | size: 302 23 | } 24 | }; 25 | 26 | let conn = scanner.addConnection(config, 50, '192.168.1.250') 27 | 28 | setInterval(() => { console.log(conn.connected)}, 1000) -------------------------------------------------------------------------------- /examples/strings.js: -------------------------------------------------------------------------------- 1 | const {Controller, Tag, Structure} = require("../dist/index.js") 2 | 3 | let c = new Controller(true); 4 | 5 | (async function (){ 6 | await c.connect('192.168.1.10'); 7 | //let tag = new Structure('TestString', c.state.tagList, 'MainProgram') 8 | let tag = c.newTag('TestString', 'MainProgram') 9 | await c.readTag(tag) 10 | 11 | console.log(tag.value) 12 | 13 | tag.value = 'America... F*** YEAH!' 14 | 15 | await c.writeTag(tag) 16 | 17 | console.log(tag.value) 18 | })(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "st-ethernet-ip", 3 | "version": "2.7.5", 4 | "description": "A simple node interface for Ethernet/IP.", 5 | "main": "./dist/index.js", 6 | "types": "./dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "test": "npm run lint && jest && npm run test:coverage && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js", 10 | "test:local": "npm run lint && jest && npm run test:coverage", 11 | "test:watch": "jest --watch", 12 | "test:coverage": "jest --coverage", 13 | "test:detailed": "jest --verbose", 14 | "test:update": "jest -u", 15 | "lint": "./node_modules/.bin/eslint . --ext .js", 16 | "lint:fix": "npm run lint -- --fix" 17 | }, 18 | "keywords": [ 19 | "rockwell", 20 | "allen", 21 | "bradley", 22 | "allen-bradley", 23 | "ethernet", 24 | "ethernet-ip", 25 | "ethernet/ip", 26 | "CIP", 27 | "industrial", 28 | "PLC", 29 | "communication", 30 | "controller" 31 | ], 32 | "dependencies": { 33 | "dateformat": "^3.0.3", 34 | "deep-equal": "^1.1.1", 35 | "int64-buffer": "^0.99.1007", 36 | "task-easy": "^0.2.0" 37 | }, 38 | "devDependencies": { 39 | "@types/dateformat": "^5.0.0", 40 | "@types/deep-equal": "^1.0.1", 41 | "@types/node": "^18.16.18", 42 | "colors": "^1.3.2", 43 | "coveralls": "^3.0.2", 44 | "eslint": "^4.19.1", 45 | "eslint-plugin-jest": "^21.22.0", 46 | "jest": "^23.6.0", 47 | "typescript": "^5.1.3" 48 | }, 49 | "author": "Jason Serafin", 50 | "license": "MIT", 51 | "repository": { 52 | "type": "git", 53 | "url": "https://github.com/SerafinTech/ST-node-ethernet-ip" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/browser/index.ts: -------------------------------------------------------------------------------- 1 | import * as EthernetIP from "../enip"; 2 | import dgram, { Socket } from "dgram"; 3 | import { EventEmitter } from "events"; 4 | 5 | const listIdentityRequest = EthernetIP.encapsulation.header.build(EthernetIP.encapsulation.commands.ListIdentity); 6 | 7 | type browserDevice = { 8 | EncapsulationVersion: number, 9 | socketAddress: { 10 | sin_family: number, 11 | sin_port: number, 12 | sin_addr: string, 13 | sin_zero: Buffer 14 | }, 15 | vendorID: number, 16 | deviceType: number, 17 | productCode: number, 18 | revision: string, 19 | status: number, 20 | serialNumber: string, 21 | productName: string, 22 | state: number, 23 | timestamp: number 24 | } 25 | 26 | export declare interface Browser extends EventEmitter { 27 | socket: Socket; 28 | originatorIPaddress: string; 29 | autoBrowse: boolean; 30 | updateRate: number; 31 | disconnectMultiplier: number; 32 | deviceList: browserDevice[]; 33 | updateInterval: NodeJS.Timer; 34 | on(event: 'New Device', listener: (device: browserDevice) => {}): this; 35 | on(event: 'Broadcast Request', listener: () => {}): this; 36 | on(event: 'Device Disconnected', listener: (device: browserDevice) => {}): this; 37 | on(event: 'Device List Updated', listener: (deviceList: browserDevice[]) => {}): this; 38 | on(event: string, listener: Function): this; 39 | } 40 | 41 | export class Browser extends EventEmitter{ 42 | constructor(originatorPort: number=51687, originatorIPaddress: string="0.0.0.0", autoBrowse: boolean=true, updateRate: number=3000, disconnectMultiplier: number=4) { 43 | super(); 44 | this.socket = dgram.createSocket("udp4"); 45 | this.originatorIPaddress = originatorIPaddress; 46 | this.autoBrowse = autoBrowse; 47 | this.updateRate = updateRate; 48 | this.disconnectMultiplier = disconnectMultiplier; 49 | this.deviceList = []; 50 | 51 | this.socket.bind(originatorPort, originatorIPaddress, () => { 52 | this.socket.setBroadcast(true); 53 | if(this.autoBrowse) this.start(); 54 | }); 55 | 56 | this._setupSocketEvents(); 57 | 58 | this.updateInterval = null; 59 | 60 | } 61 | 62 | start() { 63 | if (this.updateInterval) { 64 | clearInterval(this.updateInterval); 65 | } 66 | 67 | this.deviceList.forEach( (dev, i) => { 68 | this.deviceList[i].timestamp = Date.now(); 69 | }); 70 | 71 | this.updateInterval = setInterval(() => { 72 | this.checkStatus(); 73 | this.socket.send(listIdentityRequest, 44818, "255.255.255.255", (e) => { 74 | if (e) throw e; 75 | this.emit("Broadcast Request"); 76 | }); 77 | }, this.updateRate); 78 | } 79 | 80 | stop() { 81 | clearInterval(this.updateInterval); 82 | } 83 | 84 | checkStatus() { 85 | 86 | let deviceDisconnected = false; 87 | this.deviceList.forEach(device => { 88 | if ( (Date.now() - device.timestamp) > (this.updateRate * this.disconnectMultiplier) ) { 89 | this.emit("Device Disconnected", device); 90 | deviceDisconnected = true; 91 | } 92 | }); 93 | 94 | this.deviceList = this.deviceList.filter(device => (Date.now() - device.timestamp) <= (this.updateRate * this.disconnectMultiplier)); 95 | if (deviceDisconnected) this.emit("Device List Updated", this.deviceList); 96 | 97 | } 98 | 99 | _setupSocketEvents() { 100 | this.socket.on("message", msg => { 101 | const device = this._parseListIdentityResponse(msg); 102 | if(Object.keys(device).length !== 0) 103 | this._addDevice(device); // Device is added only if device is not empty object 104 | }); 105 | 106 | } 107 | 108 | _parseListIdentityResponse(msg) { 109 | 110 | let response: browserDevice = { 111 | EncapsulationVersion: undefined, 112 | socketAddress: { 113 | sin_family: undefined, 114 | sin_port: undefined, 115 | sin_addr: undefined, 116 | sin_zero: undefined 117 | }, 118 | vendorID: undefined, 119 | deviceType: undefined, 120 | productCode: undefined, 121 | revision: undefined, 122 | status: undefined, 123 | serialNumber: undefined, 124 | productName: undefined, 125 | state: undefined, 126 | timestamp: undefined 127 | }; 128 | 129 | const messageData = EthernetIP.encapsulation.header.parse(msg); 130 | 131 | // Check if messageData is not undefined 132 | if (messageData !== undefined) { 133 | // Check if messageData.data is not undefined 134 | if (messageData.data !== undefined) { 135 | // Check if messageData.data has a congruent length 136 | if (messageData.data.length >= 2) { 137 | const cpf = EthernetIP.encapsulation.CPF.parse(messageData.data); 138 | // Check if cpf is not undefined 139 | if (cpf !== undefined) { 140 | // Check if cpf is an array 141 | if (Array.isArray(cpf)) { 142 | // Check if cpf[0] is not undefined 143 | if (cpf[0] !== undefined) { 144 | const data = cpf[0].data; 145 | let ptr = 0; 146 | 147 | response.EncapsulationVersion = data.readUInt16LE(ptr); 148 | ptr += 2; 149 | response.socketAddress.sin_family = data.readUInt16BE(ptr); 150 | ptr += 2; 151 | response.socketAddress.sin_port = data.readUInt16BE(ptr); 152 | ptr += 2; 153 | response.socketAddress.sin_addr = data.readUInt8(ptr).toString() + "." + data.readUInt8(ptr + 1).toString() + "." + data.readUInt8(ptr + 2).toString() + "." + data.readUInt8(ptr + 3).toString(); 154 | ptr += 4; 155 | response.socketAddress.sin_zero = data.slice(ptr, ptr + 8); 156 | ptr += 8; 157 | response.vendorID = data.readUInt16LE(ptr); 158 | ptr += 2; 159 | response.deviceType = data.readUInt16LE(ptr); 160 | ptr += 2; 161 | response.productCode = data.readUInt16LE(ptr); 162 | ptr += 2; 163 | response.revision = data.readUInt8(ptr).toString() + "." + data.readUInt8(ptr + 1).toString(); 164 | ptr += 2; 165 | response.status = data.readUInt16LE(ptr); 166 | ptr += 2; 167 | response.serialNumber = "0x" + data.readUInt32LE(ptr).toString(16); 168 | ptr += 4; 169 | response.productName = data.slice(ptr + 1, ptr + 1 + data.readUInt8(ptr)).toString(); 170 | ptr += (1 + data.readUInt8(ptr)); 171 | response.state = data.readUInt8(ptr); 172 | } 173 | } 174 | } 175 | } 176 | } 177 | } 178 | 179 | return response; 180 | } 181 | 182 | _addDevice(device) { 183 | const index = this.deviceList.findIndex(item => item.socketAddress.sin_addr === device.socketAddress.sin_addr); 184 | 185 | device.timestamp = Date.now(); 186 | 187 | if(index > -1) { 188 | this.deviceList[index] = device; 189 | } else { 190 | this.deviceList.push(device); 191 | this.emit("New Device", device); 192 | this.emit("Device List Updated", this.deviceList); 193 | } 194 | } 195 | 196 | } 197 | 198 | export default Browser; 199 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | const EIP_PORT = 44818 2 | 3 | export { 4 | EIP_PORT // Ethernet/IP Explicit Msg Port Number 5 | }; 6 | -------------------------------------------------------------------------------- /src/controller-manager/index.ts: -------------------------------------------------------------------------------- 1 | import net from "net"; 2 | import Controller from "../controller"; 3 | import { EventEmitter } from "events"; 4 | import Tag from "../tag"; 5 | 6 | type cmAllValues = { 7 | [index: string]: any 8 | } 9 | 10 | type cmAllControllersValues = { 11 | [index: string] : cmAllValues; 12 | } 13 | 14 | class ControllerManager{ 15 | controllers: extController[] 16 | /** 17 | * Controller Manager manages PLC connections and tags. Automatically scans and writes tags that have values changed. Reconnects automatically. 18 | */ 19 | constructor() { 20 | this.controllers = []; 21 | } 22 | 23 | /** 24 | * Adds controller to be managed by controller manager 25 | * 26 | * @param ipAddress - controller IP address 27 | * @param slot - Slot number or custom path 28 | * @param rpi - How often to scan tag value in ms 29 | * @param connected - Use connected messaging 30 | * @param retrySP - How long to wait to retry broken connection in ms 31 | * @param opts - custom options for future use 32 | * @returns Extended Controller object 33 | */ 34 | addController(ipAddress: string, slot : number | Buffer = 0, rpi: number = 100, connected: boolean = true, retrySP: number = 3000, opts: any = {}): extController { 35 | const contLength = this.controllers.push(new extController(ipAddress, slot, rpi, connected, retrySP, opts)); 36 | return this.controllers[contLength - 1]; 37 | } 38 | 39 | 40 | /** 41 | * Returns all current controller tags 42 | * 43 | * @returns tag values indexed by controller ip address and tag name 44 | */ 45 | getAllValues(): cmAllControllersValues { 46 | let allTags: cmAllControllersValues = {}; 47 | this.controllers.forEach(controller => { 48 | let tags = {}; 49 | controller.tags.forEach(tag => { 50 | tags[tag.tag.name] = tag.tag.value; 51 | }); 52 | allTags[controller.ipAddress] = tags; 53 | }); 54 | return allTags; 55 | } 56 | } 57 | 58 | 59 | export declare interface extController { 60 | reconnect: boolean; 61 | ipAddress: string; 62 | slot: number | Buffer; 63 | opts: any; 64 | rpi: any; 65 | PLC: Controller; 66 | tags: any[]; 67 | connected: boolean; 68 | conncom: any; 69 | retryTimeSP: number; 70 | on(event: string, listener: Function): this; 71 | on(event: 'Connected', listener: (this: this) => {}): this; 72 | on(event: 'TagChanged', listener: (tag: Tag, previousValue: any) => {}): this; 73 | on(event: 'TagInit', listener: (tag: Tag) => {}): this; 74 | on(event: 'TagUnknown', listener: (tag: Tag) => {}): this; 75 | on(event: 'Disconnected', listener: () => {}): this; 76 | } 77 | 78 | export class extController extends EventEmitter{ 79 | 80 | /** 81 | * Extended Controller Class To Manage Rebuilding Tags after as disconnect / reconnect event 82 | * 83 | * @param ipAddress - controller IP address 84 | * @param slot - Slot number or custom path 85 | * @param rpi - How often to scan tag value in ms 86 | * @param connected - Use connected messaging 87 | * @param retrySP - How long to wait to retry broken connection in ms 88 | * @param opts - custom options for future use 89 | */ 90 | constructor(ipAddress: string, slot: number | Buffer = 0, rpi: number = 100, connCom: boolean = true, retrySP: number = 3000, opts: any = {}) { 91 | super(); 92 | this.reconnect = true; 93 | this.ipAddress = ipAddress; 94 | this.slot = slot; 95 | this.opts = opts; 96 | this.rpi = rpi; 97 | this.PLC = null; 98 | this.tags = []; 99 | this.connected = false; 100 | this.conncom = connCom; 101 | this.retryTimeSP = retrySP; 102 | } 103 | 104 | /** 105 | * Connect To Controller 106 | */ 107 | connect(reconnect = true) { 108 | this.reconnect = reconnect; 109 | this.PLC = new Controller(this.conncom); 110 | this.PLC.connect(this.ipAddress, this.slot).then(async () => { 111 | this.connected = true; 112 | this.PLC.scan_rate = this.rpi; 113 | this.tags.forEach(tag => { 114 | tag.tag = this.PLC.newTag(tag.tagname, tag.program, true, tag.arrayDims, tag.arraySize); 115 | this.addTagEvents(tag.tag); 116 | }); 117 | 118 | this.PLC.scan().catch(e => {this.errorHandle(e);}); 119 | this.emit("Connected", this); 120 | 121 | }).catch((e: Error) => {this.errorHandle(e);}); 122 | } 123 | 124 | /** 125 | * Add Tag Events to emit from controller 126 | * 127 | * @param tag 128 | */ 129 | addTagEvents(tag: Tag) { 130 | tag.on("Changed", (chTag, prevValue) => { 131 | this.emit("TagChanged", tag, prevValue); 132 | }); 133 | tag.on("Initialized", () => { 134 | this.emit("TagInit", tag); 135 | }); 136 | tag.on("Unknown", () => { 137 | this.emit("TagUnknown", tag); 138 | }) 139 | } 140 | 141 | /** 142 | * Handle Controller Error during connect or while scanning 143 | * 144 | * @param e - Error emitted 145 | */ 146 | errorHandle(e: any) { 147 | this.emit("Error", e); 148 | this.connected = false; 149 | this.PLC.destroy(); 150 | this.PLC._removeControllerEventHandlers(); 151 | this.emit("Disconnected"); 152 | if(this.reconnect) {setTimeout(() => {this.connect();}, this.retryTimeSP);} 153 | } 154 | 155 | /** 156 | * Add tag to controller scan list. 157 | * 158 | * @param tagname - Tag Name 159 | * @param program - Program Name 160 | * @param arrayDims - Array Dimensions 161 | * @param arraySize - Array Size 162 | * @returns Tag object 163 | */ 164 | addTag(tagname: string, program: string = null, arrayDims: number = 0, arraySize: number = 0x01):Tag { 165 | let tagItem = this.tags.find(tag => { 166 | return tag.tagname === tagname && tag.program === program; 167 | }) 168 | if (tagItem) { 169 | return tagItem.tag 170 | } else { 171 | let tag; 172 | if (this.connected) { 173 | tag = this.PLC.newTag(tagname, program, true, arrayDims, arraySize); 174 | this.addTagEvents(tag); 175 | } 176 | this.tags.push({ 177 | tagname: tagname, 178 | program: program, 179 | arrayDims: arrayDims, 180 | arraySize: arraySize, 181 | tag: tag 182 | }); 183 | return tag; 184 | } 185 | } 186 | 187 | /** 188 | * Remove tag from controller scan list. 189 | * 190 | * @param tagname - Tag Name 191 | * @param program - Program Name 192 | */ 193 | removeTag(tagname: string, program: string = null) { 194 | tagname = (program) ? tagname.slice(tagname.indexOf(".") + 1) : tagname; 195 | let tagIndex = this.tags.findIndex(tag => { 196 | return tag.tagname === tagname && tag.program === program; 197 | }) 198 | if (tagIndex > -1) { 199 | this.PLC.state.subs.remove(this.tags[tagIndex].tag); 200 | this.tags.splice(tagIndex, 1); 201 | } 202 | } 203 | 204 | /** 205 | * Disconnect Controller Completely 206 | * 207 | * @returns Promise resolved after disconnect of controller 208 | */ 209 | disconnect(): Promise { 210 | return new Promise((resolve) => { 211 | this.connected = false; 212 | this.reconnect = false; 213 | this.PLC.disconnect().then(() => { 214 | this.emit("Disconnected"); 215 | resolve(); 216 | }).catch(() => { 217 | net.Socket.prototype.destroy.call(this.PLC); 218 | this.emit("Disconnected"); 219 | resolve(); 220 | }); 221 | }); 222 | } 223 | 224 | } 225 | 226 | export default ControllerManager; 227 | export {Tag}; 228 | -------------------------------------------------------------------------------- /src/controller/__snapshots__/controller.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Controller Class Properties Accessors Controller Properties 1`] = ` 4 | Object { 5 | "faulted": false, 6 | "io_faulted": false, 7 | "majorRecoverableFault": false, 8 | "majorUnrecoverableFault": false, 9 | "minorRecoverableFault": false, 10 | "minorUnrecoverableFault": false, 11 | "name": null, 12 | "path": null, 13 | "program": false, 14 | "run": false, 15 | "serial_number": null, 16 | "slot": null, 17 | "status": null, 18 | "time": null, 19 | "version": null, 20 | } 21 | `; 22 | 23 | exports[`Controller Class Properties Accessors Time 1`] = `"January 05, 2016 - 12:00:00 AM"`; 24 | -------------------------------------------------------------------------------- /src/controller/controller.spec.js: -------------------------------------------------------------------------------- 1 | const Controller = require("./index"); 2 | 3 | describe("Controller Class", () => { 4 | describe("Properties Accessors", () => { 5 | it("Scan Rate", () => { 6 | const plc = new Controller(); 7 | expect(plc.scan_rate).toBe(200); 8 | 9 | plc.scan_rate = 450; 10 | expect(plc.scan_rate).not.toBe(200); 11 | expect(plc.scan_rate).toBe(450); 12 | 13 | plc.scan_rate = 451.9999999; 14 | expect(plc.scan_rate).not.toBe(450); 15 | expect(plc.scan_rate).toBe(451); 16 | 17 | expect(() => { 18 | plc.scan_rate = null; 19 | }).toThrow(); 20 | 21 | expect(() => { 22 | plc.scan_rate = undefined; 23 | }).toThrow(); 24 | 25 | expect(() => { 26 | plc.scan_rate = "hello"; 27 | }).toThrow(); 28 | }); 29 | 30 | it("Scanning", () => { 31 | const plc = new Controller(); 32 | expect(plc.scanning).toBeFalsy(); 33 | 34 | plc.scan(); 35 | expect(plc.scanning).toBeTruthy(); 36 | 37 | plc.pauseScan(); 38 | expect(plc.scanning).toBeFalsy(); 39 | }); 40 | 41 | it("Connected Messaging", () => { 42 | const plc = new Controller(); 43 | expect(plc.connectedMessaging).toBeTruthy(); 44 | 45 | plc.connectedMessaging = false; 46 | expect(plc.connectedMessaging).toBeFalsy(); 47 | 48 | expect(() => { 49 | plc.connectedMessaging = 3; 50 | }).toThrow(); 51 | 52 | expect(() => { 53 | plc.connectedMessaging = "connected"; 54 | }).toThrow(); 55 | 56 | expect(() => { 57 | plc.connectedMessaging = null; 58 | }).toThrow(); 59 | }); 60 | 61 | it("Controller Properties", () => { 62 | const plc = new Controller(); 63 | expect(plc.properties).toMatchSnapshot(); 64 | }); 65 | 66 | it("Time", () => { 67 | const plc = new Controller(); 68 | plc.state.controller.time = new Date("January 5, 2016"); 69 | 70 | expect(plc.time).toMatchSnapshot(); 71 | }); 72 | 73 | it("Default Unconnected Send timeout", () => { 74 | const plc = new Controller(); 75 | expect(plc.state.unconnectedSendTimeout).toEqual(2000); 76 | }); 77 | 78 | it("Custom Unconnected Send timeout", () => { 79 | const plc = new Controller(true, { unconnectedSendTimeout: 5064 }); 80 | expect(plc.state.unconnectedSendTimeout).toEqual(5064); 81 | }); 82 | }); 83 | 84 | describe("SendRRDataReceived Handler", () => { 85 | it("Forward Open", () => { 86 | const plc = new Controller(); 87 | jest.spyOn(plc, "emit"); 88 | const srrdBuf = Buffer.from([212,0,0,0,65,2,188,0,34,34,34,34,66,66,51,51,55,19,0,0,16,39,0,0,16,39,0,0,0,0]); 89 | const srrd = [{"TypeID":0,"data":Buffer.from([])},{"TypeID":178,"data":srrdBuf}]; 90 | plc._handleSendRRDataReceived(srrd); 91 | const retBuf = Buffer.from([65,2,188,0,34,34,34,34,66,66,51,51,55,19,0,0,16,39,0,0,16,39,0,0,0,0]); 92 | expect(plc.emit).toHaveBeenCalledWith("Forward Open", null, retBuf); 93 | }); 94 | it("Forward Close", () => { 95 | const plc = new Controller(); 96 | jest.spyOn(plc, "emit"); 97 | const srrdBuf = Buffer.from([206,0,0,0,66,66,51,51,55,19,0,0,0,0]); 98 | const srrd = [{"TypeID":0,"data":Buffer.from([])},{"TypeID":178,"data":srrdBuf}]; 99 | plc._handleSendRRDataReceived(srrd); 100 | const retBuf = Buffer.from([66,66,51,51,55,19,0,0,0,0]); 101 | expect(plc.emit).toHaveBeenCalledWith("Forward Close", null, retBuf); 102 | }); 103 | it("Multiple Service Packet", () => { 104 | const plc = new Controller(); 105 | jest.spyOn(plc, "emit"); 106 | const srrdBuf = Buffer.from([138,0,0,0,2,0,6,0,14,0,204,0,0,0,195,0,241,216,204,0,0,0,195,0,64,34]); 107 | const srrd = [{"TypeID":0,"data":Buffer.from([34,34,34,34])},{"TypeID":178,"data":srrdBuf}]; 108 | plc._handleSendRRDataReceived(srrd); 109 | const respObj = [ { service: 204, 110 | generalStatusCode: 0, 111 | extendedStatusLength: 0, 112 | extendedStatus: [], 113 | data: Buffer.from([0xc3,0x00,0xf1,0xd8]) 114 | }, 115 | { service: 204, 116 | generalStatusCode: 0, 117 | extendedStatusLength: 0, 118 | extendedStatus: [], 119 | data: Buffer.from([0xc3,0x00,0x40,0x22]) 120 | }]; 121 | expect(plc.emit).toHaveBeenCalledWith("Multiple Service Packet", null, respObj); 122 | }); 123 | }); 124 | 125 | describe("SendUnitDataReceived Handler", () => { 126 | it("Get Attribute All", () => { 127 | const plc = new Controller(); 128 | jest.spyOn(plc, "emit"); 129 | const sudBuf = Buffer.from([1,0,129,0,0,0,1,0,14,0,77,0,17,4,112,32,232,2,61,64,22,49,55,54,57,45,76,51,50,69,47,65,32,76,79,71,73,88,53,51,51,50,69]); 130 | const sud = [{"TypeID":161,"data":Buffer.from([34,34,34,34])},{"TypeID":177,"data":sudBuf}]; 131 | plc._handleSendUnitDataReceived(sud); 132 | const retBuf = Buffer.from([1,0,14,0,77,0,17,4,112,32,232,2,61,64,22,49,55,54,57,45,76,51,50,69,47,65,32,76,79,71,73,88,53,51,51,50,69]); 133 | expect(plc.emit).toHaveBeenCalledWith("Get Attribute All", null, retBuf); 134 | }); 135 | it("Read Tag", () => { 136 | const plc = new Controller(); 137 | jest.spyOn(plc, "emit"); 138 | const sudBuf = Buffer.from([2,0,204,0,0,0,195,0,241,216]); 139 | const sud = [{"TypeID":161,"data":Buffer.from([34,34,34,34])},{"TypeID":177,"data":sudBuf}]; 140 | plc._handleSendUnitDataReceived(sud); 141 | const retBuf = Buffer.from([195,0,241,216]); 142 | expect(plc.emit).toHaveBeenCalledWith("Read Tag", null, retBuf); 143 | }); 144 | it("Multiple Service Packet", () => { 145 | const plc = new Controller(); 146 | jest.spyOn(plc, "emit"); 147 | const sudBuf = Buffer.from([2,0,138,0,0,0,2,0,6,0,14,0,204,0,0,0,195,0,241,216,204,0,0,0,195,0,64,34]); 148 | const sud = [{"TypeID":161,"data":Buffer.from([34,34,34,34])},{"TypeID":177,"data":sudBuf}]; 149 | plc._handleSendUnitDataReceived(sud); 150 | const respObj = [ { service: 204, 151 | generalStatusCode: 0, 152 | extendedStatusLength: 0, 153 | extendedStatus: [], 154 | data: Buffer.from([0xc3,0x00,0xf1,0xd8]) 155 | }, 156 | { service: 204, 157 | generalStatusCode: 0, 158 | extendedStatusLength: 0, 159 | extendedStatus: [], 160 | data: Buffer.from([0xc3,0x00,0x40,0x22]) 161 | }]; 162 | expect(plc.emit).toHaveBeenCalledWith("Multiple Service Packet", null, respObj); 163 | }); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /src/enip/cip/connection-manager/__snapshots__/connection-manager.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Connection Manager Building Produces the Correct Output Buffer for ForwardClose Request 1`] = ` 4 | Object { 5 | "data": Array [ 6 | 3, 7 | 125, 8 | 66, 9 | 66, 10 | 51, 11 | 51, 12 | 55, 13 | 19, 14 | 0, 15 | 0, 16 | ], 17 | "type": "Buffer", 18 | } 19 | `; 20 | 21 | exports[`Connection Manager Building Produces the Correct Output Buffer for ForwardOpen Request 1`] = ` 22 | Object { 23 | "data": Array [ 24 | 3, 25 | 125, 26 | 0, 27 | 0, 28 | 0, 29 | 0, 30 | 135, 31 | 214, 32 | 18, 33 | 0, 34 | 66, 35 | 66, 36 | 51, 37 | 51, 38 | 55, 39 | 19, 40 | 0, 41 | 0, 42 | 3, 43 | 0, 44 | 0, 45 | 0, 46 | 16, 47 | 39, 48 | 0, 49 | 0, 50 | 244, 51 | 67, 52 | 16, 53 | 39, 54 | 0, 55 | 0, 56 | 244, 57 | 67, 58 | 163, 59 | ], 60 | "type": "Buffer", 61 | } 62 | `; 63 | 64 | exports[`Connection Manager Connection Parameters Produces the Correct Output number for connection parameters 1`] = `17396`; 65 | -------------------------------------------------------------------------------- /src/enip/cip/connection-manager/connection-manager.spec.js: -------------------------------------------------------------------------------- 1 | const manager = require("./index"); 2 | 3 | describe("Connection Manager", () => { 4 | describe("Building", () => { 5 | it("Produces the Correct Output Buffer for ForwardOpen Request", () => { 6 | const { build_forwardOpen } = manager; 7 | const test = build_forwardOpen(10000, undefined, undefined, undefined, undefined, 1234567); 8 | expect(test).toMatchSnapshot(); 9 | }); 10 | it("Produces the Correct Output Buffer for ForwardClose Request", () => { 11 | const { build_forwardClose } = manager; 12 | const test = build_forwardClose(); 13 | expect(test).toMatchSnapshot(); 14 | }); 15 | }); 16 | describe("Connection Parameters", () => { 17 | it("Produces the Correct Output number for connection parameters", () => { 18 | const { build_connectionParameters, owner, priority, fixedVar, connectionType } = manager; 19 | const test = build_connectionParameters(owner["Exclusive"], connectionType["PointToPoint"], priority["Low"], fixedVar["Variable"], 500); 20 | expect(test).toMatchSnapshot(); 21 | }); 22 | it("Error-cases: owner", () => { 23 | const { build_connectionParameters, priority, fixedVar, connectionType } = manager; 24 | expect(function() { 25 | build_connectionParameters("1000", connectionType["PointToPoint"], priority["Low"], fixedVar["Variable"], 500); 26 | }).toThrow(); 27 | }); 28 | it("Error-cases: connectionType", () => { 29 | const { build_connectionParameters, owner, priority, fixedVar } = manager; 30 | expect(function() { 31 | build_connectionParameters(owner["Exclusive"], "1000", priority["Low"], fixedVar["Variable"], 500); 32 | }).toThrow(); 33 | }); 34 | it("Error-cases: priority", () => { 35 | const { build_connectionParameters, owner, fixedVar, connectionType } = manager; 36 | expect(function() { 37 | build_connectionParameters(owner["Exclusive"], connectionType["PointToPoint"], "1000", fixedVar["Variable"], 500); 38 | }).toThrow(); 39 | }); 40 | it("Error-cases: fixedVar", () => { 41 | const { build_connectionParameters, owner, priority, connectionType } = manager; 42 | expect(function() { 43 | build_connectionParameters(owner["Exclusive"], connectionType["PointToPoint"], priority["Low"], "1000", 500); 44 | }).toThrow(); 45 | }); 46 | it("Error-cases: size", () => { 47 | const { build_connectionParameters, owner, priority, fixedVar, connectionType } = manager; 48 | expect(function() { 49 | build_connectionParameters(owner["Exclusive"], connectionType["PointToPoint"], priority["Low"], fixedVar["Variable"], 999999); 50 | }).toThrow(); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/enip/cip/connection-manager/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * lookup for the Redundant Owner (Vol.1 - Table 3-5.8 Field 15) 3 | */ 4 | const owner = { 5 | Exclusive: 0, 6 | Multiple: 1 7 | }; 8 | 9 | /** 10 | * lookup for the Connection Type (Vol.1 - Table 3-5.8 Field 14,13) 11 | */ 12 | const connectionType = { 13 | Null: 0, 14 | Multicast: 1, 15 | PointToPoint: 2, 16 | Reserved: 3 17 | }; 18 | 19 | /** 20 | * lookup for the Connection Priority (Vol.1 - Table 3-5.8 Field 11,10) 21 | */ 22 | const priority = { 23 | Low: 0, 24 | High: 1, 25 | Scheduled: 2, 26 | Urgent: 3 27 | }; 28 | 29 | /** 30 | * lookup for the fixed or variable parameter (Vol.1 - Table 3-5.8 Field 9) 31 | */ 32 | const fixedVar = { 33 | Fixed: 0, 34 | Variable: 1 35 | }; 36 | 37 | /** 38 | * Build for Object specific connection parameters (Vol.1 - Table 3-5.8) 39 | */ 40 | const build_connectionParameters = (owner: number, type: number, priority: number, fixedVar: number, size: number): number => { 41 | if (owner != 0 && owner != 1) throw new Error("Owner can only be exclusive (0) or multiple (1)"); 42 | if (type > 3 || type < 0) throw new Error("Type can only be Null(0), Multicast(1), PointToPoint(2) or Reserved(3)"); 43 | if (priority > 3 || priority < 0) throw new Error("Priority can only be Low(0), High(1), Scheduled(2) or Urgent(3)"); 44 | if (fixedVar != 0 && fixedVar !=1) throw new Error("Fixedvar can only be Fixed(0) or VariableI(1)"); 45 | if (size > 10000 || size <= 1 || typeof size !== "number") throw new Error("Size must be a positive number between 1 and 10000"); 46 | 47 | return owner << 15 | type << 13 | priority << 10 | fixedVar << 9 | size; 48 | }; 49 | 50 | /** 51 | * lookup table for Time Tick Value (Vol.1 - Table 3-5.11) 52 | */ 53 | const timePerTick = { 54 | 1 : 0 55 | 56 | }; 57 | 58 | const connSerial = 0x1337; 59 | 60 | /** 61 | * lookup table for Timeout multiplier (Vol.1 - 3-5.4.1.4) 62 | */ 63 | const timeOutMultiplier = { 64 | 4 : 0, 65 | 8 : 1, 66 | 16: 2, 67 | 32: 3, 68 | 64: 4, 69 | 128: 5, 70 | 256: 6, 71 | 512: 7 72 | }; 73 | 74 | 75 | type UCMMSendTimeout = { 76 | time_tick: number, 77 | ticks: number 78 | } 79 | 80 | /** 81 | * Gets the Best Available Timeout Values 82 | * 83 | * @param timeout - Desired Timeout in ms 84 | * @returns Timeout values 85 | */ 86 | const generateEncodedTimeout = (timeout: number): UCMMSendTimeout => { 87 | if (timeout <= 0 || typeof timeout !== "number") 88 | throw new Error("Timeouts Must be Positive Integers"); 89 | 90 | let diff = Infinity; // let difference be very large 91 | let time_tick = 0; 92 | let ticks = 0; 93 | 94 | // Search for Best Timeout Encoding Values 95 | for (let i = 0; i < 16; i++) { 96 | for (let j = 1; j < 256; j++) { 97 | const newDiff = Math.abs(timeout - Math.pow(2, i) * j); 98 | if (newDiff <= diff) { 99 | diff = newDiff; 100 | time_tick = i; 101 | ticks = j; 102 | } 103 | } 104 | } 105 | 106 | return { time_tick, ticks }; 107 | }; 108 | 109 | /** 110 | * Builds the data portion of a forwardOpen packet 111 | * 112 | * @param timeOutMs - How many ticks until a timeout is thrown 113 | * @param timeOutMult - A multiplier used for the Timeout 114 | * @param otRPI - O->T Request packet interval in milliseconds. 115 | * @param serialOrig - Originator Serial Number (SerNo of the PLC) 116 | * @param netConnParams - Encoded network connection parameters 117 | * @returns Data portion of the forwardOpen packet 118 | */ 119 | const build_forwardOpen = (otRPI:number = 8000, netConnParams:number = 0x43f4, timeOutMs:number = 1000 , timeOutMult:number = 32, connectionSerial:number = 0x4242, TOconnectionID:number = getRandomInt(2147483647)): Buffer => { 120 | if (timeOutMs <= 900 || typeof timeOutMs !== "number") throw new Error("Timeouts Must be Positive Integers and above 500"); 121 | if (!(timeOutMult in timeOutMultiplier) || typeof timeOutMult !== "number") throw new Error("Timeout Multiplier must be a number and a multiple of 4"); 122 | if (otRPI < 8000 || typeof otRPI !== "number") throw new Error("otRPI should be at least 8000 (8ms)"); 123 | if (typeof netConnParams !== "number") throw new Error("ConnectionParams should be created by the builder and result in a number!"); 124 | 125 | const actualMultiplier = timeOutMultiplier[timeOutMult]; 126 | const connectionParams = Buffer.alloc(35); // Normal forward open request 127 | const timeout = generateEncodedTimeout(timeOutMs); 128 | let ptr = 0; 129 | connectionParams.writeUInt8(timeout.time_tick,ptr); // Priority / TimePerTick 130 | ptr+=1; 131 | connectionParams.writeUInt8(timeout.ticks,ptr); // Timeout Ticks 132 | ptr+=1; 133 | connectionParams.writeUInt32LE(0,ptr); // O->T Connection ID 134 | ptr+=4; 135 | connectionParams.writeUInt32LE(TOconnectionID,ptr); // T->O Connection ID 136 | ptr+=4; 137 | connectionParams.writeUInt16LE(connectionSerial,ptr); // Connection Serial Number TODO: Make this unique 138 | ptr+=2; 139 | connectionParams.writeUInt16LE(0x3333,ptr); // Originator VendorID 140 | ptr+=2; 141 | connectionParams.writeUInt32LE(0x1337,ptr); // Originator Serial Number 142 | ptr+=4; 143 | connectionParams.writeUInt32LE(actualMultiplier,ptr); // TimeOut Multiplier 144 | ptr+=4; 145 | connectionParams.writeUInt32LE(otRPI,ptr); // O->T RPI 146 | ptr+=4; 147 | connectionParams.writeUInt16LE(netConnParams,ptr); // O->T Network Connection Params 148 | ptr+=2; 149 | connectionParams.writeUInt32LE(otRPI,ptr); // T->O RPI 150 | ptr+=4; 151 | connectionParams.writeUInt16LE(netConnParams,ptr); // T->O Network Connection Params 152 | ptr+=2; 153 | connectionParams.writeUInt8(0xA3,ptr); // TransportClass_Trigger (Vol.1 - 3-4.4.3) -> Target is a Server, Application object of Transport Class 3. 154 | 155 | return connectionParams; 156 | }; 157 | 158 | /** 159 | * Builds the data portion of a forwardClose packet 160 | * 161 | * @param timeOutMs - How many ms until a timeout is thrown 162 | * @param vendorOrig - Originator vendorID (Vendor of the PLC) 163 | * @param serialOrig - Originator Serial Number (SerNo of the PLC) 164 | * @param connectionSerial - Connection Serial Number 165 | * @returns Data portion of the forwardClose packet 166 | */ 167 | const build_forwardClose = (timeOutMs: number = 1000 , vendorOrig:number = 0x3333, serialOrig:number = 0x1337, connectionSerial:number = 0x4242): Buffer => { 168 | if (timeOutMs <= 900 || typeof timeOutMs !== "number") throw new Error("Timeouts Must be Positive Integers and at least 500"); 169 | if (vendorOrig <= 0 || typeof vendorOrig !== "number") throw new Error("VendorOrig Must be Positive Integers"); 170 | if (serialOrig <= 0 || typeof serialOrig !== "number") throw new Error("SerialOrig Must be Positive Integers"); 171 | 172 | const connectionParams = Buffer.alloc(10); 173 | const timeout = generateEncodedTimeout(timeOutMs); 174 | let ptr = 0; 175 | connectionParams.writeUInt8(timeout.time_tick,ptr); // Priority / TimePerTick 176 | ptr+=1; 177 | connectionParams.writeUInt8(timeout.ticks,ptr); // Timeout Ticks 178 | ptr+=1; 179 | connectionParams.writeUInt16LE(connectionSerial, ptr); // Connection Serial Number TODO: Make this unique 180 | ptr+=2; 181 | connectionParams.writeUInt16LE(vendorOrig,ptr); // Originator VendorID 182 | ptr+=2; 183 | connectionParams.writeUInt32LE(serialOrig,ptr); // Originator Serial Number 184 | 185 | return connectionParams; 186 | }; 187 | 188 | /** 189 | * Creates random integer 190 | * 191 | * @param max - Maximum value of integer 192 | * @returns random number 193 | */ 194 | function getRandomInt(max: number): number { 195 | return Math.floor(Math.random() * Math.floor(max)); 196 | } 197 | 198 | export { 199 | build_forwardOpen, 200 | build_forwardClose, 201 | build_connectionParameters, 202 | connSerial, 203 | timePerTick, 204 | timeOutMultiplier, 205 | priority, 206 | owner, 207 | connectionType, 208 | fixedVar 209 | }; 210 | -------------------------------------------------------------------------------- /src/enip/cip/data-types/data-types.spec.js: -------------------------------------------------------------------------------- 1 | const { Types, isValidTypeCode, getTypeCodeString } = require("./index"); 2 | 3 | describe("CIP Data Types", () => { 4 | describe("Data Type Validator", () => { 5 | it("Responds Appropriately to Inputs", () => { 6 | const fn = num => isValidTypeCode(num); 7 | 8 | expect(fn(0xc1)).toBeTruthy(); 9 | expect(fn(0xcb)).toBeTruthy(); 10 | expect(fn(0xd1)).toBeTruthy(); 11 | expect(fn(213)).toBeTruthy(); 12 | 13 | expect(fn(0xa1)).toBeFalsy(); 14 | expect(fn(0x01)).toBeFalsy(); 15 | expect(fn(0xe1)).toBeFalsy(); 16 | expect(fn(100)).toBeFalsy(); 17 | expect(fn("string")).toBeFalsy(); 18 | }); 19 | }); 20 | 21 | describe("Data Type Retriever", () => { 22 | it("Returns Appropriate Data Type", () => { 23 | const fn = num => getTypeCodeString(num); 24 | 25 | for (let type of Object.keys(Types)) { 26 | expect(fn(Types[type])).toEqual(type); 27 | } 28 | 29 | expect(fn(0)).toEqual(null); 30 | expect(fn("string")).toEqual(null); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/enip/cip/data-types/index.ts: -------------------------------------------------------------------------------- 1 | const Types = { 2 | BOOL: 0xc1, 3 | SINT: 0xc2, 4 | INT: 0xc3, 5 | DINT: 0xc4, 6 | LINT: 0xc5, 7 | USINT: 0xc6, 8 | UINT: 0xc7, 9 | UDINT: 0xc8, 10 | REAL: 0xca, 11 | LREAL: 0xcb, 12 | STIME: 0xcc, 13 | DATE: 0xcd, 14 | TIME_AND_DAY: 0xce, 15 | DATE_AND_STRING: 0xcf, 16 | STRING: 0xd0, 17 | WORD: 0xd1, 18 | DWORD: 0xd2, 19 | BIT_STRING: 0xd3, 20 | LWORD: 0xd4, 21 | STRING2: 0xd5, 22 | FTIME: 0xd6, 23 | LTIME: 0xd7, 24 | ITIME: 0xd8, 25 | STRINGN: 0xd9, 26 | SHORT_STRING: 0xda, 27 | TIME: 0xdb, 28 | EPATH: 0xdc, 29 | ENGUNIT: 0xdd, 30 | STRINGI: 0xde, 31 | STRUCT: 0x02a0 32 | }; 33 | 34 | const TypeSizes = { 35 | 0xc1: 1, 36 | 0xc2: 1, 37 | 0xc3: 2, 38 | 0xc4: 4, 39 | 0xc5: 8, 40 | 0xc6: 1, 41 | 0xc7: 2, 42 | 0xc8: 4, 43 | 0xca: 4, 44 | } 45 | 46 | /** 47 | * Checks if an Inputted Integer is a Valid Type Code (Vol1 Appendix C) 48 | * 49 | * @param num - Integer to be Tested 50 | * @returns true or false 51 | */ 52 | const isValidTypeCode = (num: number): boolean => { 53 | if (!Number.isInteger(num)) return false; 54 | for (let type of Object.keys(Types)) { 55 | if (Types[type] === num) return true; 56 | } 57 | return false; 58 | }; 59 | 60 | /** 61 | * Retrieves Human Readable Version of an Inputted Type Code 62 | * 63 | * @param num - Type Code to Request Human Readable version 64 | * @returns Type Code String Interpretation 65 | */ 66 | const getTypeCodeString = (num: number): string => { 67 | if (!Number.isInteger(num)) return null; 68 | for (let type of Object.keys(Types)) { 69 | if (Types[type] === num) return type; 70 | } 71 | return null; 72 | }; 73 | 74 | export { Types, isValidTypeCode, getTypeCodeString, TypeSizes }; 75 | -------------------------------------------------------------------------------- /src/enip/cip/epath/index.ts: -------------------------------------------------------------------------------- 1 | import * as segments from './segments' 2 | 3 | export { segments }; 4 | -------------------------------------------------------------------------------- /src/enip/cip/epath/segments/data/__snapshots__/data.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`EPATH DATA Segment Build Utility Generates Appropriate Output 1`] = ` 4 | Object { 5 | "data": Array [ 6 | 145, 7 | 10, 8 | 84, 9 | 111, 10 | 116, 11 | 97, 12 | 108, 13 | 67, 14 | 111, 15 | 117, 16 | 110, 17 | 116, 18 | ], 19 | "type": "Buffer", 20 | } 21 | `; 22 | 23 | exports[`EPATH DATA Segment Build Utility Generates Appropriate Output 2`] = ` 24 | Object { 25 | "data": Array [ 26 | 128, 27 | 2, 28 | 1, 29 | 2, 30 | 3, 31 | 0, 32 | ], 33 | "type": "Buffer", 34 | } 35 | `; 36 | 37 | exports[`EPATH DATA Segment Build Utility Generates Appropriate Output 3`] = ` 38 | Object { 39 | "data": Array [ 40 | 145, 41 | 7, 42 | 83, 43 | 111, 44 | 109, 45 | 101, 46 | 84, 47 | 97, 48 | 103, 49 | 0, 50 | ], 51 | "type": "Buffer", 52 | } 53 | `; 54 | 55 | exports[`EPATH DATA Segment Build Utility Generates Appropriate Output 4`] = ` 56 | Object { 57 | "data": Array [ 58 | 40, 59 | 0, 60 | ], 61 | "type": "Buffer", 62 | } 63 | `; 64 | 65 | exports[`EPATH DATA Segment Build Utility Generates Appropriate Output 5`] = ` 66 | Object { 67 | "data": Array [ 68 | 40, 69 | 255, 70 | ], 71 | "type": "Buffer", 72 | } 73 | `; 74 | 75 | exports[`EPATH DATA Segment Build Utility Generates Appropriate Output 6`] = ` 76 | Object { 77 | "data": Array [ 78 | 41, 79 | 0, 80 | 0, 81 | 1, 82 | ], 83 | "type": "Buffer", 84 | } 85 | `; 86 | 87 | exports[`EPATH DATA Segment Build Utility Generates Appropriate Output 7`] = ` 88 | Object { 89 | "data": Array [ 90 | 41, 91 | 0, 92 | 1, 93 | 1, 94 | ], 95 | "type": "Buffer", 96 | } 97 | `; 98 | 99 | exports[`EPATH DATA Segment Build Utility Generates Appropriate Output 8`] = ` 100 | Object { 101 | "data": Array [ 102 | 41, 103 | 0, 104 | 255, 105 | 255, 106 | ], 107 | "type": "Buffer", 108 | } 109 | `; 110 | 111 | exports[`EPATH DATA Segment Build Utility Generates Appropriate Output 9`] = ` 112 | Object { 113 | "data": Array [ 114 | 42, 115 | 0, 116 | 0, 117 | 0, 118 | 1, 119 | 0, 120 | ], 121 | "type": "Buffer", 122 | } 123 | `; 124 | 125 | exports[`EPATH DATA Segment Build Utility Generates Appropriate Output 10`] = ` 126 | Object { 127 | "data": Array [ 128 | 42, 129 | 0, 130 | 1, 131 | 0, 132 | 1, 133 | 0, 134 | ], 135 | "type": "Buffer", 136 | } 137 | `; 138 | -------------------------------------------------------------------------------- /src/enip/cip/epath/segments/data/data.spec.js: -------------------------------------------------------------------------------- 1 | const { build } = require("./index"); 2 | 3 | describe("EPATH", () => { 4 | describe("DATA Segment Build Utility", () => { 5 | it("Generates Appropriate Output", () => { 6 | let test = build("TotalCount"); 7 | expect(test).toMatchSnapshot(); 8 | 9 | test = build(Buffer.from([0x1001, 0x2002, 0x3003]), false); 10 | expect(test).toMatchSnapshot(); 11 | 12 | test = build("SomeTag"); // test symbolic build 13 | expect(test).toMatchSnapshot(); 14 | 15 | test = build("0"); // test element build 16 | expect(test).toMatchSnapshot(); 17 | 18 | test = build("255"); // test 8bit upper boundary 19 | expect(test).toMatchSnapshot(); 20 | 21 | test = build("256"); // test 16 bit lower boundary 22 | expect(test).toMatchSnapshot(); 23 | 24 | test = build("257"); // test 16 bit endian 25 | expect(test).toMatchSnapshot(); 26 | 27 | test = build("65535"); // test 16 bit upper boundary 28 | expect(test).toMatchSnapshot(); 29 | 30 | test = build("65536"); // test 32 bit lower boundary 31 | expect(test).toMatchSnapshot(); 32 | 33 | test = build("65537"); // test 32 bit endian 34 | expect(test).toMatchSnapshot(); 35 | }); 36 | 37 | it("Throws with Bad Input", () => { 38 | const fn = (data, ansi = true) => { 39 | return () => { 40 | build(data, ansi); 41 | }; 42 | }; 43 | 44 | expect(fn("hello")).not.toThrow(); 45 | expect(fn(32)).toThrow(); 46 | expect(fn({ prop: 76 })).toThrow(); 47 | expect(fn(Buffer.from("hello world"))).not.toThrow(); 48 | expect(fn(Buffer.from("hello world"), false)).not.toThrow(); 49 | expect(fn(1, -1)).toThrow(); 50 | expect(fn(1, { hey: "you" })).toThrow(); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/enip/cip/epath/segments/data/index.ts: -------------------------------------------------------------------------------- 1 | const Types = { 2 | Simple: 0x80, 3 | ANSI_EXTD: 0x91 4 | }; 5 | 6 | const ElementTypes = { 7 | UINT8: 0x28, 8 | UINT16: 0x29, 9 | UINT32: 0x2a 10 | }; 11 | 12 | /** 13 | * Builds EPATH Data Segment 14 | * 15 | * @param data 16 | * @param ANSI - Declare if ANSI Extended or Simple 17 | * @returns Segment 18 | */ 19 | const build = (data: string | Buffer, ANSI: boolean = true): Buffer => { 20 | if (!(typeof data === "string" || Buffer.isBuffer(data))) 21 | throw new Error("Data Segment Data Must be a String or Buffer"); 22 | 23 | // Build Element Segment If Int 24 | if (!isNaN(Number(data))) return elementBuild(Number(data)); 25 | 26 | // Build symbolic segment by default 27 | return symbolicBuild(data, ANSI); 28 | }; 29 | 30 | /** 31 | * Builds EPATH Symbolic Segment 32 | * 33 | * @param data 34 | * @param ANSI - Declare if ANSI Extended or Simple 35 | * @returns Segment 36 | */ 37 | const symbolicBuild = (data: string | Buffer, ANSI: boolean): Buffer => { 38 | // Initialize Buffer 39 | let buf = Buffer.alloc(2); 40 | 41 | // Write Appropriate Segment Byte 42 | buf.writeUInt8(ANSI ? Types.ANSI_EXTD : Types.Simple, 0); 43 | 44 | // Write Appropriate Length 45 | buf.writeUInt8(ANSI ? data.length : Math.ceil(data.length / 2), 1); 46 | 47 | // Append Data 48 | buf = Buffer.concat([buf, Buffer.from(data)]); 49 | 50 | // Add Pad Byte if Odd Length 51 | if (buf.length % 2 === 1) buf = Buffer.concat([buf, Buffer.alloc(1)]); // Pad Odd Length Strings 52 | 53 | return buf; 54 | }; 55 | 56 | /** 57 | * Builds EPATH Element Segment 58 | * 59 | * @param data 60 | * @returns Segment 61 | */ 62 | const elementBuild = (data: number): Buffer => { 63 | // Get Element Length - Data Access 2 - IOI Segments - Element Segments 64 | let type: number; 65 | let dataBuf: Buffer; 66 | 67 | if (data < 256) { 68 | type = ElementTypes.UINT8; // UNIT8 x28 xx 69 | dataBuf = Buffer.alloc(1); 70 | dataBuf.writeUInt8(data); 71 | } else if (data < 65536) { 72 | type = ElementTypes.UINT16; // UINT16 x29 00 xx xx 73 | dataBuf = Buffer.alloc(3); 74 | dataBuf.writeUInt16LE(Number(data), 1); 75 | } else { 76 | type = ElementTypes.UINT32; // UINT32 x2a 00 xx xx xx xx 77 | dataBuf = Buffer.alloc(5); 78 | dataBuf.writeUInt32LE(Number(data), 1); 79 | } 80 | 81 | // Initialize Buffer 82 | let buf = Buffer.alloc(1); 83 | 84 | // Write Appropriate Segment Byte 85 | buf.writeUInt8(type, 0); 86 | 87 | // Append Data 88 | buf = Buffer.concat([buf, dataBuf]); 89 | 90 | return buf; 91 | }; 92 | 93 | export { Types, build }; 94 | -------------------------------------------------------------------------------- /src/enip/cip/epath/segments/index.ts: -------------------------------------------------------------------------------- 1 | import * as PORT from './port'; 2 | import * as LOGICAL from './logical'; 3 | import * as DATA from './data'; 4 | 5 | const SegmentTypes = { 6 | PORT: 0 << 5, // Communication Port to Leave Node (Shall be 1 for a Backplane), Link Address of Next Device 7 | LOGICAL: 1 << 5, 8 | NETWORK: 2 << 5, 9 | SYMBOLIC: 3 << 5, 10 | DATA: 4 << 5, 11 | DATATYPE_1: 5 << 5, 12 | DATATYPE_2: 6 << 6 13 | }; 14 | 15 | export { SegmentTypes, PORT, LOGICAL, DATA }; 16 | -------------------------------------------------------------------------------- /src/enip/cip/epath/segments/logical/__snapshots__/logical.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`EPATH LOGICAL Segment Build Utility Generates Appropriate Output 1`] = ` 4 | Object { 5 | "data": Array [ 6 | 32, 7 | 5, 8 | ], 9 | "type": "Buffer", 10 | } 11 | `; 12 | 13 | exports[`EPATH LOGICAL Segment Build Utility Generates Appropriate Output 2`] = ` 14 | Object { 15 | "data": Array [ 16 | 36, 17 | 2, 18 | ], 19 | "type": "Buffer", 20 | } 21 | `; 22 | 23 | exports[`EPATH LOGICAL Segment Build Utility Generates Appropriate Output 3`] = ` 24 | Object { 25 | "data": Array [ 26 | 48, 27 | 1, 28 | ], 29 | "type": "Buffer", 30 | } 31 | `; 32 | 33 | exports[`EPATH LOGICAL Segment Build Utility Generates Appropriate Output 4`] = ` 34 | Object { 35 | "data": Array [ 36 | 37, 37 | 244, 38 | 1, 39 | ], 40 | "type": "Buffer", 41 | } 42 | `; 43 | 44 | exports[`EPATH LOGICAL Segment Build Utility Generates Appropriate Output 5`] = ` 45 | Object { 46 | "data": Array [ 47 | 37, 48 | 0, 49 | 244, 50 | 1, 51 | ], 52 | "type": "Buffer", 53 | } 54 | `; 55 | 56 | exports[`EPATH LOGICAL Segment Build Utility Generates Appropriate Output 6`] = ` 57 | Object { 58 | "data": Array [ 59 | 48, 60 | 1, 61 | ], 62 | "type": "Buffer", 63 | } 64 | `; 65 | 66 | exports[`EPATH LOGICAL Segment Build Utility Generates Appropriate Output 7`] = ` 67 | Object { 68 | "data": Array [ 69 | 36, 70 | 2, 71 | ], 72 | "type": "Buffer", 73 | } 74 | `; 75 | -------------------------------------------------------------------------------- /src/enip/cip/epath/segments/logical/index.ts: -------------------------------------------------------------------------------- 1 | const LOGICAL_SEGMENT = 1 << 5; 2 | 3 | const types = { 4 | ClassID: 0 << 2, 5 | InstanceID: 1 << 2, 6 | MemberID: 2 << 2, 7 | ConnPoint: 3 << 2, 8 | AttributeID: 4 << 2, 9 | Special: 5 << 2, 10 | ServiceID: 6 << 2 11 | }; 12 | 13 | /** 14 | * Determines the Validity of the Type Code 15 | * 16 | * @param type - Logical Segment Type Code 17 | * @returns true or false 18 | */ 19 | const validateLogicalType = (type: number) => { 20 | for (let key of Object.keys(types)) { 21 | if (types[key] === type) return true; 22 | } 23 | return false; 24 | }; 25 | 26 | /** 27 | * Builds Single Logical Segment Buffer 28 | * 29 | * @param type - Valid Logical Segment Type 30 | * @param address - Logical Segment Address 31 | * @param padded - Padded or Packed EPATH format 32 | * @returns segment 33 | */ 34 | const build = (type: number, address: number, padded: boolean = true): Buffer => { 35 | if (!validateLogicalType(type)) 36 | throw new Error("Invalid Logical Type Code Passed to Segment Builder"); 37 | 38 | if (typeof address !== "number" || address < 0) 39 | throw new Error("Passed Address Must be a Positive Integer"); 40 | 41 | let buf = null; // Initialize Output Buffer 42 | 43 | // Determine Size of Logical Segment Value and Build Buffer 44 | let format = null; 45 | if (address <= 255) { 46 | format = 0; 47 | 48 | buf = Buffer.alloc(2); 49 | buf.writeUInt8(address, 1); 50 | } else if (address > 255 && address <= 65535) { 51 | format = 1; 52 | 53 | if (padded) { 54 | buf = Buffer.alloc(4); 55 | buf.writeUInt16LE(address, 2); 56 | } else { 57 | buf = Buffer.alloc(3); 58 | buf.writeUInt16LE(address, 1); 59 | } 60 | } else { 61 | format = 2; 62 | 63 | if (padded) { 64 | Buffer.alloc(6); 65 | buf.writeUInt32LE(address, 2); 66 | } else { 67 | Buffer.alloc(5); 68 | buf.writeUInt32LE(address, 1); 69 | } 70 | } 71 | 72 | // Build Segment Byte 73 | const segmentByte = LOGICAL_SEGMENT | type | format; 74 | buf.writeUInt8(segmentByte, 0); 75 | 76 | return buf; 77 | }; 78 | 79 | export { types, build }; 80 | -------------------------------------------------------------------------------- /src/enip/cip/epath/segments/logical/logical.spec.js: -------------------------------------------------------------------------------- 1 | const { types, build } = require("./index"); 2 | 3 | describe("EPATH", () => { 4 | describe("LOGICAL Segment Build Utility", () => { 5 | it("Generates Appropriate Output", () => { 6 | let test = build(types.ClassID, 5, false); 7 | expect(test).toMatchSnapshot(); 8 | 9 | test = build(types.InstanceID, 2, false); 10 | expect(test).toMatchSnapshot(); 11 | 12 | test = build(types.AttributeID, 1, false); 13 | expect(test).toMatchSnapshot(); 14 | 15 | test = build(types.InstanceID, 500, false); 16 | expect(test).toMatchSnapshot(); 17 | 18 | test = build(types.InstanceID, 500); 19 | expect(test).toMatchSnapshot(); 20 | 21 | test = build(types.AttributeID, 1); 22 | expect(test).toMatchSnapshot(); 23 | 24 | test = build(types.InstanceID, 2); 25 | expect(test).toMatchSnapshot(); 26 | }); 27 | 28 | it("Throws with Bad Input", () => { 29 | const fn = (type, addr) => { 30 | return () => { 31 | build(type, addr); 32 | }; 33 | }; 34 | 35 | expect(fn("hello", 5)).toThrow(); 36 | expect(fn(0, 5)).not.toThrow(); 37 | expect(fn(-5, 5)).toThrow(); 38 | expect(fn(1, 5)).toThrow(); 39 | expect(fn(types.AttributeID, -1)).toThrow(); 40 | expect(fn(types.AttributeID, { hey: "you" })).toThrow(); 41 | 42 | expect(fn(types.ClassID, 5)).not.toThrow(); 43 | expect(fn(types.ClassID, -1)).toThrow(); 44 | expect(fn(types.ClassID, 0)).toThrow(); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/enip/cip/epath/segments/port/__snapshots__/port.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`EPATH PORT Segment Build Utility Generates Appropriate Output 1`] = ` 4 | Object { 5 | "data": Array [ 6 | 2, 7 | 6, 8 | ], 9 | "type": "Buffer", 10 | } 11 | `; 12 | 13 | exports[`EPATH PORT Segment Build Utility Generates Appropriate Output 2`] = ` 14 | Object { 15 | "data": Array [ 16 | 15, 17 | 18, 18 | 0, 19 | 1, 20 | ], 21 | "type": "Buffer", 22 | } 23 | `; 24 | 25 | exports[`EPATH PORT Segment Build Utility Generates Appropriate Output 3`] = ` 26 | Object { 27 | "data": Array [ 28 | 21, 29 | 15, 30 | 49, 31 | 51, 32 | 48, 33 | 46, 34 | 49, 35 | 53, 36 | 49, 37 | 46, 38 | 49, 39 | 51, 40 | 55, 41 | 46, 42 | 49, 43 | 48, 44 | 53, 45 | 0, 46 | ], 47 | "type": "Buffer", 48 | } 49 | `; 50 | 51 | exports[`EPATH PORT Segment Build Utility Generates Appropriate Output 4`] = ` 52 | Object { 53 | "data": Array [ 54 | 1, 55 | 5, 56 | ], 57 | "type": "Buffer", 58 | } 59 | `; 60 | -------------------------------------------------------------------------------- /src/enip/cip/epath/segments/port/index.ts: -------------------------------------------------------------------------------- 1 | const PORT_SEGMENT = 0 << 5; 2 | 3 | /** 4 | * Builds Port Segement for EPATH 5 | * 6 | * @param port - Port to leave Current Node (1 if Backplane) 7 | * @param link - link address to route packet 8 | * @returns EPATH Port Segment 9 | */ 10 | const build = (port: number, link: number | string): Buffer => { 11 | if (typeof port !== "number" || port <= 0) 12 | throw new Error("Port Number must be a Positive Integer"); 13 | if (!(typeof link === "string" || typeof link === "number") || Number(link) < 0) 14 | throw new Error("Link Number must be a Positive Integer or String"); 15 | 16 | let buf = null; 17 | let portIdentifierByte = PORT_SEGMENT; // Set High Byte of Segement (0x00) 18 | 19 | // Check Link Buffer Length 20 | let linkBuf = null; 21 | 22 | /* eslint-disable indent */ 23 | switch (typeof link) { 24 | case "string": 25 | linkBuf = Buffer.from(link); 26 | break; 27 | case "number": 28 | linkBuf = Buffer.from([link]); 29 | break; 30 | } 31 | /* eslint-enable indent */ 32 | 33 | // Build Port Buffer 34 | if (port < 15) { 35 | portIdentifierByte |= port; 36 | 37 | if (linkBuf.length > 1) { 38 | portIdentifierByte |= 0x10; // Set Flag to Identify a link of greater than 1 Byte 39 | buf = Buffer.alloc(2); 40 | buf.writeInt8(linkBuf.length, 1); 41 | } else { 42 | buf = Buffer.alloc(1); 43 | } 44 | } else { 45 | portIdentifierByte |= 0x0f; 46 | 47 | if (linkBuf.length > 1) { 48 | portIdentifierByte |= 0x10; // Set Flag to Identify a link of greater than 1 Byte 49 | buf = Buffer.alloc(4); 50 | buf.writeUInt8(linkBuf.length, 1); 51 | buf.writeUInt16LE(port, 2); 52 | } else { 53 | buf = Buffer.alloc(3); 54 | buf.writeUInt16LE(port, 1); 55 | } 56 | } 57 | 58 | buf.writeUInt8(portIdentifierByte, 0); 59 | 60 | // Add Link to Buffer 61 | buf = Buffer.concat([buf, linkBuf]); // Buffer.from(linkBuf));Buffer.alloc(1)) 62 | return buf.length % 2 === 1 ? Buffer.concat([buf, Buffer.alloc(1)]) : buf; 63 | }; 64 | 65 | export { build }; 66 | -------------------------------------------------------------------------------- /src/enip/cip/epath/segments/port/port.spec.js: -------------------------------------------------------------------------------- 1 | const { build } = require("./index"); 2 | 3 | describe("EPATH", () => { 4 | describe("PORT Segment Build Utility", () => { 5 | it("Generates Appropriate Output", () => { 6 | let test = build(2, 6); 7 | expect(test).toMatchSnapshot(); 8 | 9 | test = build(18, 1); 10 | expect(test).toMatchSnapshot(); 11 | 12 | test = build(5, "130.151.137.105"); 13 | expect(test).toMatchSnapshot(); 14 | 15 | test = build(1, 5); 16 | expect(test).toMatchSnapshot(); 17 | }); 18 | 19 | it("Throws with Bad Input", () => { 20 | const fn = (port, link) => { 21 | return () => { 22 | build(port, link); 23 | }; 24 | }; 25 | 26 | expect(fn("hello", 5)).toThrow(); 27 | expect(fn(0, 5)).toThrow(); 28 | expect(fn(-5, 5)).toThrow(); 29 | expect(fn(1, 5)).not.toThrow(); 30 | expect(fn(1, -1)).toThrow(); 31 | expect(fn(1, { hey: "you" })).toThrow(); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/enip/cip/index.ts: -------------------------------------------------------------------------------- 1 | import * as MessageRouter from './message-router'; 2 | import * as DataTypes from './data-types'; 3 | import * as EPATH from './epath'; 4 | import * as UnconnectedSend from './unconnected-send'; 5 | import * as ConnectionManager from './connection-manager'; 6 | 7 | export { MessageRouter, DataTypes, EPATH, UnconnectedSend, ConnectionManager }; -------------------------------------------------------------------------------- /src/enip/cip/message-router/__snapshots__/message-router.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Message Router Builder Produces the Correct Output Buffer 1`] = ` 4 | Object { 5 | "data": Array [ 6 | 65, 7 | 6, 8 | 72, 9 | 101, 10 | 108, 11 | 108, 12 | 111, 13 | 32, 14 | 87, 15 | 111, 16 | 114, 17 | 108, 18 | 100, 19 | 0, 20 | 72, 21 | 101, 22 | 108, 23 | 108, 24 | 111, 25 | 32, 26 | 87, 27 | 111, 28 | 114, 29 | 108, 30 | 100, 31 | ], 32 | "type": "Buffer", 33 | } 34 | `; 35 | 36 | exports[`Message Router Parser Parses MR Object Correctly 1`] = ` 37 | Object { 38 | "data": Object { 39 | "data": Array [ 40 | 1, 41 | 2, 42 | 3, 43 | 4, 44 | 5, 45 | ], 46 | "type": "Buffer", 47 | }, 48 | "extendedStatus": Array [ 49 | 769, 50 | 261, 51 | 1283, 52 | ], 53 | "extendedStatusLength": 3, 54 | "generalStatusCode": 10, 55 | "service": 65, 56 | } 57 | `; 58 | -------------------------------------------------------------------------------- /src/enip/cip/message-router/index.ts: -------------------------------------------------------------------------------- 1 | const services = { 2 | GET_INSTANCE_ATTRIBUTE_LIST: 0x55, 3 | GET_ATTRIBUTES: 0x03, 4 | GET_ATTRIBUTE_ALL: 0x01, 5 | GET_ATTRIBUTE_SINGLE: 0x0e, 6 | GET_ENUM_STRING: 0x4b, 7 | RESET: 0x05, 8 | START: 0x06, 9 | STOP: 0x07, 10 | CREATE: 0x08, 11 | DELETE: 0x09, 12 | MULTIPLE_SERVICE_PACKET: 0x0a, 13 | APPLY_ATTRIBUTES: 0x0d, 14 | SET_ATTRIBUTE_SINGLE: 0x10, 15 | FIND_NEXT: 0x11, 16 | READ_TAG: 0x4c, 17 | WRITE_TAG: 0x4d, 18 | READ_TAG_FRAGMENTED: 0x52, 19 | WRITE_TAG_FRAGMENTED: 0x53, 20 | READ_MODIFY_WRITE_TAG: 0x4e, 21 | FORWARD_OPEN: 0x54, 22 | FORWARD_CLOSE: 0x4E, 23 | GET_FILE_DATA: 0x4f 24 | }; 25 | 26 | /** 27 | * Builds a Message Router Request Buffer 28 | * 29 | * @param service - CIP Service Code 30 | * @param path - CIP Padded EPATH (Vol 1 - Appendix C) 31 | * @param data - Service Specific Data to be Sent 32 | * @returns Message Router Request Buffer 33 | */ 34 | const build = (service: number, path: Buffer, data: Buffer): Buffer => { 35 | const pathBuf = Buffer.from(path); 36 | const dataBuf = Buffer.from(data); 37 | 38 | const pathLen = Math.ceil(pathBuf.length / 2); 39 | const buf = Buffer.alloc(2 + pathLen * 2 + dataBuf.length); 40 | 41 | buf.writeUInt8(service, 0); // Write Service Code to Buffer 42 | buf.writeUInt8(pathLen, 1); // Write Length of EPATH (16 bit word length) 43 | 44 | pathBuf.copy(buf, 2); // Write EPATH to Buffer 45 | dataBuf.copy(buf, 2 + pathLen * 2); // Write Service Data to Buffer 46 | 47 | return buf; 48 | }; 49 | 50 | type MessageRouter = { 51 | service: number, 52 | generalStatusCode: number, 53 | extendedStatusLength: number, 54 | extendedStatus: [number], 55 | data: Buffer 56 | } 57 | 58 | /** 59 | * Parses a Message Router Request Buffer 60 | * 61 | * @param buf - Message Router Request Buffer 62 | * @returns Decoded Message Router Object 63 | */ 64 | const parse = (buf: Buffer): MessageRouter => { 65 | let MessageRouter = { 66 | service: buf.readUInt8(0), 67 | generalStatusCode: buf.readUInt8(2), 68 | extendedStatusLength: buf.readUInt8(3), 69 | extendedStatus: null, 70 | data: null 71 | }; 72 | 73 | // Build Extended Status Array 74 | let arr = []; 75 | for (let i = 0; i < MessageRouter.extendedStatusLength; i++) { 76 | arr.push(buf.readUInt16LE(i * 2 + 4)); 77 | } 78 | MessageRouter.extendedStatus = arr; 79 | 80 | // Get Starting Point of Message Router Data 81 | const dataStart = MessageRouter.extendedStatusLength * 2 + 4; 82 | 83 | // Initialize Message Router Data Buffer 84 | let data = Buffer.alloc(buf.length - dataStart); 85 | 86 | // Copy Data to Message Router Data Buffer 87 | buf.copy(data, 0, dataStart); 88 | MessageRouter.data = data; 89 | 90 | return MessageRouter; 91 | }; 92 | 93 | export { build, parse, services }; 94 | -------------------------------------------------------------------------------- /src/enip/cip/message-router/message-router.spec.js: -------------------------------------------------------------------------------- 1 | const router = require("./index"); 2 | 3 | describe("Message Router", () => { 4 | describe("Builder", () => { 5 | it("Produces the Correct Output Buffer", () => { 6 | const { build } = router; 7 | const test = build(0x41, "Hello World", "Hello World"); 8 | 9 | expect(test).toMatchSnapshot(); 10 | }); 11 | }); 12 | 13 | describe("Parser", () => { 14 | it("Parses MR Object Correctly", () => { 15 | const { parse } = router; 16 | let buf = Buffer.from([ 17 | 0x41, // service 18 | 0x00, // Reserved - Set to 0 19 | 0x0a, // General Status Code 20 | 0x03, // Extended Status Length 21 | 0x01, // Extended Status 22 | 0x03, 23 | 0x05, 24 | 0x01, 25 | 0x03, 26 | 0x05, 27 | 0x01, // Reply Service Data 28 | 0x02, 29 | 0x03, 30 | 0x04, 31 | 0x05 32 | ]); 33 | 34 | const test = parse(buf); 35 | expect(test).toMatchSnapshot(); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/enip/cip/unconnected-send/__snapshots__/unconnected-send.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Unconnected Send Service Message Build Utility Generates Appropriate Output 1`] = ` 4 | Object { 5 | "data": Array [ 6 | 82, 7 | 2, 8 | 32, 9 | 6, 10 | 36, 11 | 1, 12 | 4, 13 | 125, 14 | 12, 15 | 0, 16 | 76, 17 | 4, 18 | 115, 19 | 111, 20 | 109, 21 | 101, 22 | 116, 23 | 97, 24 | 103, 25 | 0, 26 | 1, 27 | 0, 28 | 1, 29 | 0, 30 | 1, 31 | 5, 32 | ], 33 | "type": "Buffer", 34 | } 35 | `; 36 | -------------------------------------------------------------------------------- /src/enip/cip/unconnected-send/index.ts: -------------------------------------------------------------------------------- 1 | import * as MessageRouter from '../message-router'; 2 | import {segments} from '../epath'; 3 | 4 | const UNCONNECTED_SEND_SERVICE = 0x52; 5 | const UNCONNECTED_SEND_PATH = Buffer.concat([ 6 | segments.LOGICAL.build(segments.LOGICAL.types.ClassID, 0x06), 7 | segments.LOGICAL.build(segments.LOGICAL.types.InstanceID, 1) 8 | ]); 9 | 10 | type UCMMSendTimeout = { 11 | time_tick: number, 12 | ticks: number 13 | } 14 | 15 | /** 16 | * Gets the Best Available Timeout Values 17 | * 18 | * @param timeout - Desired Timeout in ms 19 | * @returns Timeout Values 20 | */ 21 | const generateEncodedTimeout = (timeout: number): UCMMSendTimeout => { 22 | if (timeout <= 0 || typeof timeout !== "number") 23 | throw new Error("Timeouts Must be Positive Integers"); 24 | 25 | let diff = Infinity; // let difference be very large 26 | let time_tick = 0; 27 | let ticks = 0; 28 | 29 | // Search for Best Timeout Encoding Values 30 | for (let i = 0; i < 16; i++) { 31 | for (let j = 1; j < 256; j++) { 32 | const newDiff = Math.abs(timeout - Math.pow(2, i) * j); 33 | if (newDiff <= diff) { 34 | diff = newDiff; 35 | time_tick = i; 36 | ticks = j; 37 | } 38 | } 39 | } 40 | 41 | return { time_tick, ticks }; 42 | }; 43 | 44 | /** 45 | * Builds an Unconnected Send Packet Buffer 46 | * 47 | * @param message_request - Message Request Encoded Buffer 48 | * @param path - Padded EPATH Buffer 49 | * @param timeout - timeout 50 | * @returns packet 51 | */ 52 | const build = (message_request: Buffer, path: Buffer, timeout: number = 2000): Buffer => { 53 | if (!Buffer.isBuffer(message_request)) 54 | throw new Error("Message Request Must be of Type Buffer"); 55 | if (!Buffer.isBuffer(path)) throw new Error("Path Must be of Type Buffer"); 56 | if (typeof timeout !== "number" || timeout < 100) timeout = 1000; 57 | 58 | // Get Encoded Timeout 59 | const encTimeout = generateEncodedTimeout(timeout); 60 | 61 | // Instantiate Buffer 62 | let buf = Buffer.alloc(2); 63 | 64 | // Write Encoded Timeout to Output Buffer 65 | buf.writeUInt8(encTimeout.time_tick, 0); 66 | buf.writeUInt8(encTimeout.ticks, 1); 67 | 68 | // Build Message Request Buffer 69 | const msgReqLen = message_request.length; 70 | const msgReqLenBuf = Buffer.alloc(2); 71 | msgReqLenBuf.writeUInt16LE(msgReqLen, 0); 72 | 73 | // Build Path Buffer 74 | const pathLen = Math.ceil(path.length / 2); 75 | const pathLenBuf = Buffer.alloc(1); 76 | pathLenBuf.writeUInt8(pathLen, 0); 77 | 78 | // Build Null Buffer 79 | const nullBuf = Buffer.alloc(1); 80 | 81 | // Assemble Unconnected Send Buffer 82 | if (msgReqLen % 2 === 1) { 83 | // requires Pad Byte after Message Request 84 | buf = Buffer.concat([ 85 | buf, 86 | msgReqLenBuf, 87 | message_request, 88 | nullBuf, 89 | pathLenBuf, 90 | nullBuf, 91 | path 92 | ]); 93 | } else { 94 | buf = Buffer.concat([buf, msgReqLenBuf, message_request, pathLenBuf, nullBuf, path]); 95 | } 96 | 97 | return MessageRouter.build(UNCONNECTED_SEND_SERVICE, UNCONNECTED_SEND_PATH, buf); 98 | }; 99 | 100 | export { generateEncodedTimeout, build }; 101 | -------------------------------------------------------------------------------- /src/enip/cip/unconnected-send/unconnected-send.spec.js: -------------------------------------------------------------------------------- 1 | const { build, generateEncodedTimeout } = require("./index"); 2 | const MessageRouter = require("../message-router"); 3 | const { 4 | segments: { PORT } 5 | } = require("../epath"); 6 | 7 | describe("Unconnected Send Service", () => { 8 | describe("Timeout Encoding Utility", () => { 9 | it("Generates Appropriate Outputs", () => { 10 | const fn = arg => generateEncodedTimeout(arg); 11 | 12 | expect(fn(2304)).toMatchObject({ time_tick: 8, ticks: 9 }); 13 | expect(fn(2400)).toMatchObject({ time_tick: 5, ticks: 75 }); 14 | expect(fn(2000)).toMatchObject({ time_tick: 4, ticks: 125 }); 15 | }); 16 | }); 17 | 18 | describe("Message Build Utility", () => { 19 | it("Generates Appropriate Output", () => { 20 | const readTag_Path = "sometag"; 21 | const readTag_Data = Buffer.alloc(2); 22 | readTag_Data.writeUInt16LE(1, 0); 23 | const mr = MessageRouter.build(0x4c, readTag_Path, readTag_Data); 24 | 25 | let test = build(mr, PORT.build(1, 5)); 26 | expect(test).toMatchSnapshot(); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/enip/encapsulation/__snapshots__/encapsulation.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Encapsulation Header Building Utility Builds Correct Encapsulation Buffer 1`] = ` 4 | Object { 5 | "data": Array [ 6 | 101, 7 | 0, 8 | 4, 9 | 0, 10 | 0, 11 | 0, 12 | 0, 13 | 0, 14 | 0, 15 | 0, 16 | 0, 17 | 0, 18 | 0, 19 | 0, 20 | 0, 21 | 0, 22 | 0, 23 | 0, 24 | 0, 25 | 0, 26 | 0, 27 | 0, 28 | 0, 29 | 0, 30 | 1, 31 | 0, 32 | 0, 33 | 0, 34 | ], 35 | "type": "Buffer", 36 | } 37 | `; 38 | 39 | exports[`Encapsulation Header Parsing Utility Builds Correct Encapsulation Buffer 1`] = ` 40 | Object { 41 | "command": "SendRRData", 42 | "commandCode": 111, 43 | "data": Object { 44 | "data": Array [ 45 | 1, 46 | 0, 47 | 0, 48 | 0, 49 | ], 50 | "type": "Buffer", 51 | }, 52 | "length": 4, 53 | "options": 0, 54 | "session": 98705, 55 | "status": "SUCCESS", 56 | "statusCode": 0, 57 | } 58 | `; 59 | 60 | exports[`Encapsulation Test Common Packet Format Helper Functions Build Helper Function Generates Correct Output 1`] = ` 61 | Object { 62 | "data": Array [ 63 | 2, 64 | 0, 65 | 0, 66 | 0, 67 | 0, 68 | 0, 69 | 178, 70 | 0, 71 | 11, 72 | 0, 73 | 104, 74 | 101, 75 | 108, 76 | 108, 77 | 111, 78 | 32, 79 | 119, 80 | 111, 81 | 114, 82 | 108, 83 | 100, 84 | ], 85 | "type": "Buffer", 86 | } 87 | `; 88 | 89 | exports[`Encapsulation Test Common Packet Format Helper Functions Build Helper Function Generates Correct Output 2`] = ` 90 | Object { 91 | "data": Array [ 92 | 3, 93 | 0, 94 | 0, 95 | 0, 96 | 0, 97 | 0, 98 | 178, 99 | 0, 100 | 11, 101 | 0, 102 | 104, 103 | 101, 104 | 108, 105 | 108, 106 | 111, 107 | 32, 108 | 119, 109 | 111, 110 | 114, 111 | 108, 112 | 100, 113 | 161, 114 | 0, 115 | 14, 116 | 0, 117 | 84, 118 | 104, 119 | 105, 120 | 115, 121 | 32, 122 | 105, 123 | 115, 124 | 32, 125 | 97, 126 | 32, 127 | 116, 128 | 101, 129 | 115, 130 | 116, 131 | ], 132 | "type": "Buffer", 133 | } 134 | `; 135 | 136 | exports[`Encapsulation Test Common Packet Format Helper Functions Parse Helper Function Generates Correct Output 1`] = ` 137 | Array [ 138 | Object { 139 | "TypeID": 0, 140 | "data": Object { 141 | "data": Array [], 142 | "type": "Buffer", 143 | }, 144 | "length": 0, 145 | }, 146 | Object { 147 | "TypeID": 178, 148 | "data": Object { 149 | "data": Array [ 150 | 104, 151 | 101, 152 | 108, 153 | 108, 154 | 111, 155 | 32, 156 | 119, 157 | 111, 158 | 114, 159 | 108, 160 | 100, 161 | ], 162 | "type": "Buffer", 163 | }, 164 | "length": 11, 165 | }, 166 | ] 167 | `; 168 | 169 | exports[`Encapsulation Test Common Packet Format Helper Functions Parse Helper Function Generates Correct Output 2`] = ` 170 | Array [ 171 | Object { 172 | "TypeID": 0, 173 | "data": Object { 174 | "data": Array [], 175 | "type": "Buffer", 176 | }, 177 | "length": 0, 178 | }, 179 | Object { 180 | "TypeID": 178, 181 | "data": Object { 182 | "data": Array [ 183 | 104, 184 | 101, 185 | 108, 186 | 108, 187 | 111, 188 | 32, 189 | 119, 190 | 111, 191 | 114, 192 | 108, 193 | 100, 194 | ], 195 | "type": "Buffer", 196 | }, 197 | "length": 11, 198 | }, 199 | Object { 200 | "TypeID": 161, 201 | "data": Object { 202 | "data": Array [ 203 | 84, 204 | 104, 205 | 105, 206 | 115, 207 | 32, 208 | 105, 209 | 115, 210 | 32, 211 | 97, 212 | 32, 213 | 116, 214 | 101, 215 | 115, 216 | 116, 217 | ], 218 | "type": "Buffer", 219 | }, 220 | "length": 14, 221 | }, 222 | ] 223 | `; 224 | 225 | exports[`Encapsulation Test Encapsulation Generator Functions Register Session Returns Correct Encapsulation String 1`] = ` 226 | Object { 227 | "data": Array [ 228 | 101, 229 | 0, 230 | 4, 231 | 0, 232 | 0, 233 | 0, 234 | 0, 235 | 0, 236 | 0, 237 | 0, 238 | 0, 239 | 0, 240 | 0, 241 | 0, 242 | 0, 243 | 0, 244 | 0, 245 | 0, 246 | 0, 247 | 0, 248 | 0, 249 | 0, 250 | 0, 251 | 0, 252 | 1, 253 | 0, 254 | 0, 255 | 0, 256 | ], 257 | "type": "Buffer", 258 | } 259 | `; 260 | 261 | exports[`Encapsulation Test Encapsulation Generator Functions SendRRData Returns Correct Encapsulation String 1`] = ` 262 | Object { 263 | "data": Array [ 264 | 111, 265 | 0, 266 | 27, 267 | 0, 268 | 145, 269 | 129, 270 | 1, 271 | 0, 272 | 0, 273 | 0, 274 | 0, 275 | 0, 276 | 0, 277 | 0, 278 | 0, 279 | 0, 280 | 0, 281 | 0, 282 | 0, 283 | 0, 284 | 0, 285 | 0, 286 | 0, 287 | 0, 288 | 0, 289 | 0, 290 | 0, 291 | 0, 292 | 10, 293 | 0, 294 | 2, 295 | 0, 296 | 0, 297 | 0, 298 | 0, 299 | 0, 300 | 178, 301 | 0, 302 | 11, 303 | 0, 304 | 104, 305 | 101, 306 | 108, 307 | 108, 308 | 111, 309 | 32, 310 | 119, 311 | 111, 312 | 114, 313 | 108, 314 | 100, 315 | ], 316 | "type": "Buffer", 317 | } 318 | `; 319 | 320 | exports[`Encapsulation Test Encapsulation Generator Functions SendUnitData Returns Correct Encapsulation String 1`] = ` 321 | Object { 322 | "data": Array [ 323 | 112, 324 | 0, 325 | 33, 326 | 0, 327 | 145, 328 | 129, 329 | 1, 330 | 0, 331 | 0, 332 | 0, 333 | 0, 334 | 0, 335 | 0, 336 | 0, 337 | 0, 338 | 0, 339 | 0, 340 | 0, 341 | 0, 342 | 0, 343 | 0, 344 | 0, 345 | 0, 346 | 0, 347 | 0, 348 | 0, 349 | 0, 350 | 0, 351 | 0, 352 | 0, 353 | 2, 354 | 0, 355 | 161, 356 | 0, 357 | 4, 358 | 0, 359 | 145, 360 | 125, 361 | 0, 362 | 0, 363 | 177, 364 | 0, 365 | 13, 366 | 0, 367 | 200, 368 | 1, 369 | 104, 370 | 101, 371 | 108, 372 | 108, 373 | 111, 374 | 32, 375 | 119, 376 | 111, 377 | 114, 378 | 108, 379 | 100, 380 | ], 381 | "type": "Buffer", 382 | } 383 | `; 384 | 385 | exports[`Encapsulation Test Encapsulation Generator Functions Unregister Session Returns Correct Encapsulation String 1`] = ` 386 | Object { 387 | "data": Array [ 388 | 102, 389 | 0, 390 | 0, 391 | 0, 392 | 145, 393 | 129, 394 | 1, 395 | 0, 396 | 0, 397 | 0, 398 | 0, 399 | 0, 400 | 0, 401 | 0, 402 | 0, 403 | 0, 404 | 0, 405 | 0, 406 | 0, 407 | 0, 408 | 0, 409 | 0, 410 | 0, 411 | 0, 412 | ], 413 | "type": "Buffer", 414 | } 415 | `; 416 | -------------------------------------------------------------------------------- /src/enip/encapsulation/encapsulation.spec.js: -------------------------------------------------------------------------------- 1 | const encapsulation = require("./index"); 2 | 3 | describe("Encapsulation", () => { 4 | describe("Command Validator", () => { 5 | const { validateCommand } = encapsulation; 6 | const { 7 | RegisterSession, 8 | UnregisterSession, 9 | SendRRData, 10 | SendUnitData 11 | } = encapsulation.commands; 12 | 13 | it("Rejects Invalid Commands", () => { 14 | expect(validateCommand(0x99)).toBeFalsy(); 15 | expect(validateCommand("hello")).toBeFalsy(); 16 | expect(validateCommand(0x02)).toBeFalsy(); 17 | }); 18 | 19 | it("Accepts Proper Commands", () => { 20 | expect(validateCommand(0x66)).toBeTruthy(); 21 | expect(validateCommand(102)).toBeTruthy(); 22 | expect(validateCommand(RegisterSession)).toBeTruthy(); 23 | expect(validateCommand(UnregisterSession)).toBeTruthy(); 24 | expect(validateCommand(SendRRData)).toBeTruthy(); 25 | expect(validateCommand(SendUnitData)).toBeTruthy(); 26 | }); 27 | }); 28 | 29 | describe("Status Parser", () => { 30 | const { parseStatus } = encapsulation; 31 | 32 | it("Rejects Non-Number Inputs", () => { 33 | expect(() => parseStatus("test")).toThrow(); 34 | expect(() => parseStatus(null)).toThrow(); 35 | expect(() => parseStatus(undefined)).toThrow(); 36 | }); 37 | 38 | it("Returns Proper Human Readable String", () => { 39 | expect(parseStatus(0)).toEqual("SUCCESS"); 40 | expect(parseStatus(0x01)).toEqual(expect.stringContaining("FAIL")); 41 | expect(parseStatus(1)).toEqual(expect.stringContaining("FAIL")); 42 | expect(parseStatus(0x45)).toEqual(expect.stringContaining("FAIL")); 43 | }); 44 | }); 45 | 46 | describe("Header Building Utility", () => { 47 | const { 48 | header: { build }, 49 | commands: { RegisterSession } 50 | } = encapsulation; 51 | 52 | it("Builds Correct Encapsulation Buffer", () => { 53 | const snap = build(RegisterSession, 0x00, [0x01, 0x00, 0x00, 0x00]); 54 | expect(snap).toMatchSnapshot(); 55 | }); 56 | }); 57 | 58 | describe("Header Parsing Utility", () => { 59 | const { 60 | header: { parse, build }, 61 | commands: { SendRRData } 62 | } = encapsulation; 63 | 64 | it("Builds Correct Encapsulation Buffer", () => { 65 | const data = build(SendRRData, 98705, [0x01, 0x00, 0x00, 0x00]); 66 | const snap = parse(data); 67 | 68 | expect(snap).toMatchSnapshot(); 69 | }); 70 | }); 71 | 72 | describe("Test Encapsulation Generator Functions", () => { 73 | const { registerSession, unregisterSession, sendRRData, sendUnitData } = encapsulation; 74 | 75 | it("Register Session Returns Correct Encapsulation String", () => { 76 | const data = registerSession(); 77 | 78 | expect(data).toMatchSnapshot(); 79 | }); 80 | 81 | it("Unregister Session Returns Correct Encapsulation String", () => { 82 | const data = unregisterSession(98705); 83 | 84 | expect(data).toMatchSnapshot(); 85 | }); 86 | 87 | it("SendRRData Returns Correct Encapsulation String", () => { 88 | const data = sendRRData(98705, Buffer.from("hello world")); 89 | 90 | expect(data).toMatchSnapshot(); 91 | }); 92 | 93 | it("SendUnitData Returns Correct Encapsulation String", () => { 94 | const data = sendUnitData(98705, Buffer.from("hello world"), 32145, 456); 95 | 96 | expect(data).toMatchSnapshot(); 97 | }); 98 | }); 99 | 100 | describe("Test Common Packet Format Helper Functions", () => { 101 | const { 102 | CPF: { parse, build, isCmd, ItemIDs } 103 | } = encapsulation; 104 | 105 | it("Invalid CPF Commands causes an Error to be Thrown", () => { 106 | const { Null, ListIdentity, ConnectionBased, UCMM } = ItemIDs; 107 | 108 | expect(isCmd(Null)).toBeTruthy(); 109 | expect(isCmd(ListIdentity)).toBeTruthy(); 110 | expect(isCmd(ConnectionBased)).toBeTruthy(); 111 | expect(isCmd(UCMM)).toBeTruthy(); 112 | expect(isCmd(0x8001)).toBeTruthy(); 113 | expect(isCmd(0x01)).toBeFalsy(); 114 | expect(isCmd(0x8003)).toBeFalsy(); 115 | expect(isCmd(0xc1)).toBeFalsy(); 116 | }); 117 | 118 | it("Build Helper Function Generates Correct Output", () => { 119 | const test1 = [ 120 | { TypeID: ItemIDs.Null, data: [] }, 121 | { TypeID: ItemIDs.UCMM, data: "hello world" } 122 | ]; 123 | 124 | const test2 = [ 125 | { TypeID: ItemIDs.Null, data: [] }, 126 | { TypeID: ItemIDs.UCMM, data: "hello world" }, 127 | { TypeID: ItemIDs.ConnectionBased, data: "This is a test" } 128 | ]; 129 | 130 | expect(build(test1)).toMatchSnapshot(); 131 | expect(build(test2)).toMatchSnapshot(); 132 | }); 133 | 134 | it("Parse Helper Function Generates Correct Output", () => { 135 | const test1 = build([ 136 | { TypeID: ItemIDs.Null, data: [] }, 137 | { TypeID: ItemIDs.UCMM, data: "hello world" } 138 | ]); 139 | 140 | const test2 = build([ 141 | { TypeID: ItemIDs.Null, data: [] }, 142 | { TypeID: ItemIDs.UCMM, data: "hello world" }, 143 | { TypeID: ItemIDs.ConnectionBased, data: "This is a test" } 144 | ]); 145 | 146 | expect(parse(test1)).toMatchSnapshot(); 147 | expect(parse(test2)).toMatchSnapshot(); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /src/enip/encapsulation/index.ts: -------------------------------------------------------------------------------- 1 | const commands = { 2 | NOP: 0x00, 3 | ListServices: 0x04, 4 | ListIdentity: 0x63, 5 | ListInterfaces: 0x64, 6 | RegisterSession: 0x65, // Begin Session Command 7 | UnregisterSession: 0x66, // Close Session Command 8 | SendRRData: 0x6f, // Send Unconnected Data Command 9 | SendUnitData: 0x70, // Send Connnected Data Command 10 | IndicateStatus: 0x72, 11 | Cancel: 0x73 12 | }; 13 | 14 | // region Validation Helper Functions 15 | 16 | /** 17 | * Parses Encapulation Status Code to Human Readable Error Message. 18 | * 19 | * @param status - Status Code 20 | * @returns Human Readable Error Message 21 | */ 22 | const parseStatus = (status: number): string => { 23 | if (typeof status !== "number") throw new Error("parseStatus accepts type only!"); 24 | 25 | /* eslint-disable indent */ 26 | switch (status) { 27 | case 0x00: 28 | return "SUCCESS"; 29 | case 0x01: 30 | return "FAIL: Sender issued an invalid ecapsulation command."; 31 | case 0x02: 32 | return "FAIL: Insufficient memory resources to handle command."; 33 | case 0x03: 34 | return "FAIL: Poorly formed or incorrect data in encapsulation packet."; 35 | case 0x64: 36 | return "FAIL: Originator used an invalid session handle."; 37 | case 0x65: 38 | return "FAIL: Target received a message of invalid length."; 39 | case 0x69: 40 | return "FAIL: Unsupported encapsulation protocol revision."; 41 | default: 42 | return `FAIL: General failure <${status}> occured.`; 43 | } 44 | /* eslint-enable indent */ 45 | }; 46 | 47 | /** 48 | * Checks if Command is a Valid Encapsulation Command 49 | * 50 | * @param cmd - Encapsulation command 51 | * @returns test result 52 | */ 53 | const validateCommand = (cmd: number): boolean => { 54 | for (let key of Object.keys(commands)) { 55 | if (cmd === commands[key]) return true; 56 | } 57 | return false; 58 | }; 59 | // endregion 60 | 61 | // region Compact Packet Format 62 | 63 | type CommonPacketData = { 64 | TypeID: number, 65 | data: Buffer, 66 | length: number 67 | } 68 | 69 | let CPF = { 70 | ItemIDs:{ 71 | Null: 0x00, 72 | ListIdentity: 0x0c, 73 | ConnectionBased: 0xa1, 74 | ConnectedTransportPacket: 0xb1, 75 | UCMM: 0xb2, 76 | ListServices: 0x100, 77 | SockaddrO2T: 0x8000, 78 | SockaddrT2O: 0x8001, 79 | SequencedAddrItem: 0x8002 80 | }, 81 | 82 | /** 83 | * Checks if Command is a Valid Encapsulation Command 84 | * 85 | * @param cmd - Encapsulation command 86 | * @returns test result 87 | */ 88 | isCmd: (cmd: number): boolean => { 89 | for (let key of Object.keys(CPF.ItemIDs)) { 90 | if (cmd === CPF.ItemIDs[key]) return true; 91 | } 92 | return false; 93 | }, 94 | 95 | /** 96 | * Builds a Common Packet Formatted Buffer to be 97 | * Encapsulated. 98 | * 99 | * @param dataItems - Array of CPF Data Items 100 | * @returns CPF Buffer to be Encapsulated 101 | */ 102 | build: (dataItems: CommonPacketData[]): Buffer => { 103 | // Write Item Count and Initialize Buffer 104 | let buf = Buffer.alloc(2); 105 | buf.writeUInt16LE(dataItems.length, 0); 106 | 107 | for (let item of dataItems) { 108 | const { TypeID, data } = item; 109 | 110 | if (!CPF.isCmd(TypeID)) throw new Error("Invalid CPF Type ID!"); 111 | 112 | let buf1 = Buffer.alloc(4); 113 | let buf2 = Buffer.from(data); 114 | 115 | buf1.writeUInt16LE(TypeID, 0); 116 | buf1.writeUInt16LE(buf2.length, 2); 117 | 118 | buf = buf2.length > 0 ? Buffer.concat([buf, buf1, buf2]) : Buffer.concat([buf, buf1]); 119 | } 120 | 121 | return buf; 122 | }, 123 | 124 | /** 125 | * Parses Incoming Common Packet Formatted Buffer 126 | * and returns an Array of Objects. 127 | * 128 | * @param {Buffer} buf - Common Packet Formatted Data Buffer 129 | * @returns {Array} Array of Common Packet Data Objects 130 | */ 131 | parse: (buf: Buffer): CommonPacketData[] => { 132 | const itemCount = buf.readUInt16LE(0); 133 | 134 | let ptr = 2; 135 | let arr: CommonPacketData[] = []; 136 | 137 | for (let i = 0; i < itemCount; i++) { 138 | // Get Type ID 139 | const TypeID = buf.readUInt16LE(ptr); 140 | ptr += 2; 141 | 142 | // Get Data Length 143 | const length = buf.readUInt16LE(ptr); 144 | ptr += 2; 145 | 146 | // Get Data from Data Buffer 147 | const data = Buffer.alloc(length); 148 | buf.copy(data, 0, ptr, ptr + length); 149 | 150 | // Append Gathered Data Object to Return Array 151 | arr.push({ TypeID, length, data}); 152 | 153 | ptr += length; 154 | } 155 | 156 | return arr; 157 | } 158 | }; 159 | 160 | // endregion 161 | 162 | // region Header Assemble Method Definitions 163 | 164 | type EncapsulationData = { 165 | commandCode: number, // Ecapsulation Command Code 166 | command: string, // Encapsulation Command String Interpretation 167 | length: number, // Length of Encapsulated Data 168 | session: number, // Session ID 169 | statusCode: number, // Status Code 170 | status: string, // Status Code String Interpretation 171 | options: number, // Options (Typically 0x00) 172 | data: Buffer // Encapsulated Data Buffer 173 | } 174 | 175 | let header = { 176 | /** 177 | * Builds an ENIP Encapsolated Packet 178 | * 179 | * @param cmd - Command to Send 180 | * @param session - Session ID 181 | * @param data - Data to Send 182 | * @returns Generated Buffer to be Sent to Target 183 | */ 184 | build: (cmd: number, session: number = 0x00, data: Buffer | [] = []): Buffer => { 185 | // Validate requested command 186 | if (!validateCommand(cmd)) throw new Error("Invalid Encapsulation Command!"); 187 | 188 | const buf = Buffer.from(data); 189 | 190 | // Initialize header buffer to appropriate length 191 | let header = Buffer.alloc(24 + buf.length, 0); 192 | 193 | // Build header from encapsulation data 194 | header.writeUInt16LE(cmd, 0); 195 | header.writeUInt16LE(buf.length, 2); 196 | header.writeUInt32LE(session, 4); 197 | buf.copy(header, 24); 198 | 199 | return header; 200 | }, 201 | 202 | /** 203 | * Parses an Encapsulated Packet Received from ENIP Target 204 | * 205 | * @param buf - Incoming Encapsulated Buffer from Target 206 | * @returns Parsed Encapsulation Data Object 207 | */ 208 | parse: (buf: Buffer): EncapsulationData => { 209 | if (!Buffer.isBuffer(buf)) throw new Error("header.parse accepts type only!"); 210 | 211 | const received = { 212 | commandCode: buf.readUInt16LE(0), 213 | command: null, 214 | length: buf.readUInt16LE(2), 215 | session: buf.readUInt32LE(4), 216 | statusCode: buf.readUInt32LE(8), 217 | status: null, 218 | options: buf.readUInt32LE(20), 219 | data: null 220 | }; 221 | 222 | // Get Returned Encapsulated Data 223 | let dataBuffer = Buffer.alloc(received.length); 224 | buf.copy(dataBuffer, 0, 24); 225 | 226 | received.data = dataBuffer; 227 | received.status = parseStatus(received.statusCode); 228 | 229 | for (let key of Object.keys(commands)) { 230 | if (received.commandCode === commands[key]) { 231 | received.command = key; 232 | break; 233 | } 234 | } 235 | 236 | return received; 237 | }, 238 | 239 | 240 | }; 241 | 242 | // endregion 243 | 244 | // region Common Command Helper Build Funtions 245 | 246 | /** 247 | * Returns a Register Session Request String 248 | * 249 | * @returns register session buffer 250 | */ 251 | const registerSession = (): Buffer => { 252 | const { RegisterSession } = commands; 253 | const { build } = header; 254 | const cmdBuf = Buffer.alloc(4); 255 | cmdBuf.writeUInt16LE(0x01, 0); // Protocol Version (Required to be 1) 256 | cmdBuf.writeUInt16LE(0x00, 2); // Opton Flags (Reserved for Future List) 257 | 258 | // Build Register Session Buffer and return it 259 | return build(RegisterSession, 0x00, cmdBuf); 260 | }; 261 | 262 | /** 263 | * Returns an Unregister Session Request String 264 | * 265 | * @param session - Encapsulation Session ID 266 | * @returns unregister session buffer 267 | */ 268 | const unregisterSession = (session: number):Buffer => { 269 | const { UnregisterSession } = commands; 270 | const { build } = header; 271 | 272 | // Build Unregister Session Buffer 273 | return build(UnregisterSession, session); 274 | }; 275 | 276 | /** 277 | * Returns a UCMM Encapsulated Packet String 278 | * 279 | * @param session - Encapsulation Session ID 280 | * @param data - Data to be Sent via UCMM 281 | * @param timeout - Timeout (sec) 282 | * @returns UCMM Encapsulated Message Buffer 283 | */ 284 | const sendRRData = (session: number, data: Buffer, timeout: number = 10): Buffer => { 285 | const { SendRRData } = commands; 286 | 287 | let timeoutBuf = Buffer.alloc(6); 288 | timeoutBuf.writeUInt32LE(0x00, 0); // Interface Handle ID (Shall be 0 for CIP) 289 | timeoutBuf.writeUInt16LE(timeout, 4); // Timeout (sec) 290 | 291 | // Enclose in Common Packet Format 292 | let buf = CPF.build([ 293 | { TypeID: CPF.ItemIDs.Null, data: Buffer.from([]), length: undefined}, 294 | { TypeID: CPF.ItemIDs.UCMM, data: data, length: undefined} 295 | ]); 296 | 297 | // Join Timeout Data with 298 | buf = Buffer.concat([timeoutBuf, buf]); 299 | 300 | // Build SendRRData Buffer 301 | return header.build(SendRRData, session, buf); 302 | }; 303 | 304 | /** 305 | * Returns a Connected Message Datagram (Transport Class 3) String 306 | * 307 | * @param {number} session - Encapsulation Session ID 308 | * @param {Buffer} data - Data to be Sent via Connected Message 309 | * @param {number} ConnectionID - Connection ID from FWD_OPEN 310 | * @param {number} SequenceNumber - Sequence Number of Datagram 311 | * @returns Connected Message Datagram Buffer 312 | */ 313 | const sendUnitData = (session: number, data: Buffer, ConnectionID: number, SequnceNumber: number): Buffer => { 314 | const { SendUnitData } = commands; 315 | 316 | let timeoutBuf = Buffer.alloc(6); 317 | timeoutBuf.writeUInt32LE(0x00, 0); // Interface Handle ID (Shall be 0 for CIP) 318 | timeoutBuf.writeUInt16LE(0x00, 4); // Timeout (sec) (Shall be 0 for Connected Messages) 319 | 320 | // Enclose in Common Packet Format 321 | const seqAddrBuf = Buffer.alloc(4); 322 | seqAddrBuf.writeUInt32LE(ConnectionID, 0); 323 | const seqNumberBuf = Buffer.alloc(2); 324 | seqNumberBuf.writeUInt16LE(SequnceNumber, 0); 325 | const ndata = Buffer.concat([ 326 | seqNumberBuf, 327 | data 328 | ]); 329 | 330 | let buf = CPF.build([ 331 | { 332 | TypeID: CPF.ItemIDs.ConnectionBased, 333 | data: seqAddrBuf, 334 | length: undefined 335 | }, 336 | { 337 | TypeID: CPF.ItemIDs.ConnectedTransportPacket, 338 | data: ndata, 339 | length: undefined 340 | } 341 | ]); 342 | 343 | // Join Timeout Data with 344 | buf = Buffer.concat([timeoutBuf, buf]); 345 | 346 | // Build SendRRData Buffer 347 | return header.build(SendUnitData, session, buf); 348 | }; 349 | // endregion 350 | 351 | export { 352 | header, 353 | CPF, 354 | validateCommand, 355 | commands, 356 | parseStatus, 357 | registerSession, 358 | unregisterSession, 359 | sendRRData, 360 | sendUnitData, 361 | EncapsulationData, 362 | CommonPacketData 363 | }; 364 | -------------------------------------------------------------------------------- /src/enip/enip.spec.js: -------------------------------------------------------------------------------- 1 | const { ENIP } = require("./index"); 2 | 3 | describe("ENIP Class", () => { 4 | describe("Properties Accessors", () => { 5 | it("error", () => { 6 | const enip = new ENIP(); 7 | const error = { code: 0x41, msg: "this failed for some reason" }; 8 | enip.state.error = error; 9 | 10 | expect(enip.error).toMatchObject(error); 11 | }); 12 | 13 | it("establising", () => { 14 | const enip = new ENIP(); 15 | 16 | expect(enip.establishing).toBe(false); 17 | }); 18 | 19 | it("established", () => { 20 | const enip = new ENIP(); 21 | 22 | expect(enip.established).toBe(false); 23 | }); 24 | 25 | it("session_id", () => { 26 | const enip = new ENIP(); 27 | expect(enip.session_id).toBe(null); 28 | 29 | enip.state.session.id = 23455; 30 | expect(enip.session_id).toBe(23455); 31 | }); 32 | 33 | it("establishing_conn", () => { 34 | const enip = new ENIP(); 35 | expect(enip.establishing_conn).toBe(false); 36 | 37 | enip.state.connection.establishing = true; 38 | expect(enip.establishing_conn).toBe(true); 39 | 40 | enip.establishing_conn = false; 41 | expect(enip.state.connection.establishing).toBe(false); 42 | 43 | expect(() => { 44 | enip.establishing_conn = "establishing"; 45 | }).toThrow(); 46 | }); 47 | 48 | 49 | it("established_conn", () => { 50 | const enip = new ENIP(); 51 | expect(enip.established_conn).toBe(false); 52 | 53 | enip.state.connection.established = true; 54 | expect(enip.established_conn).toBe(true); 55 | 56 | enip.established_conn = false; 57 | expect(enip.state.connection.established).toBe(false); 58 | 59 | expect(() => { 60 | enip.established_conn = "established"; 61 | }).toThrow(); 62 | }); 63 | 64 | it("id_conn", () => { 65 | const enip = new ENIP(); 66 | expect(enip.id_conn).toBe(null); 67 | 68 | enip.state.connection.id = 0x1337; 69 | expect(enip.id_conn).toBe(0x1337); 70 | 71 | enip.id_conn = 0x00; 72 | expect(enip.state.connection.id).toBe(0x00); 73 | 74 | expect(() => { 75 | enip.id_conn = "myTestID"; 76 | }).toThrow(); 77 | }); 78 | 79 | it("seq_conn", () => { 80 | const enip = new ENIP(); 81 | expect(enip.seq_conn).toBe(0x00); 82 | 83 | enip.state.connection.seq_num = 0x01; 84 | expect(enip.seq_conn).toBe(0x01); 85 | 86 | enip.seq_conn = 0x02; 87 | expect(enip.state.connection.seq_num).toBe(0x02); 88 | 89 | expect(() => { 90 | enip.seq_conn = "mySeqNo"; 91 | }).toThrow(); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/enip/index.ts: -------------------------------------------------------------------------------- 1 | import { Socket, isIPv4 } from 'net'; 2 | import { EIP_PORT } from '../config'; 3 | import * as encapsulation from './encapsulation'; 4 | import * as CIP from './cip'; 5 | const { promiseTimeout } = require("../utilities"); 6 | import { lookup } from 'dns'; 7 | 8 | /** 9 | * Low Level Ethernet/IP 10 | * 11 | * @class ENIP 12 | * @extends {Socket} 13 | * @fires ENIP#Session Registration Failed 14 | * @fires ENIP#Session Registered 15 | * @fires ENIP#Session Unregistered 16 | * @fires ENIP#SendRRData Received 17 | * @fires ENIP#SendUnitData Received 18 | * @fires ENIP#Unhandled Encapsulated Command Received 19 | */ 20 | type enipTCP = { 21 | establishing: boolean; 22 | established: boolean; 23 | } 24 | 25 | type enipSession = { 26 | id: number, 27 | establishing: boolean, 28 | established: boolean, 29 | } 30 | 31 | type enipConnection = { 32 | id: number, 33 | establishing: boolean, 34 | established: boolean, 35 | seq_num: number 36 | } 37 | 38 | type enipError = {code: number, msg: string} 39 | 40 | class ENIP extends Socket { 41 | state: { 42 | TCP: enipTCP, 43 | session: enipSession, 44 | connection: enipConnection, 45 | error: enipError 46 | } 47 | 48 | constructor() { 49 | super(); 50 | 51 | this.state = { 52 | TCP: { establishing: false, established: false }, 53 | session: { id: null, establishing: false, established: false }, 54 | connection: { id: null, establishing: false, established: false, seq_num: 0 }, 55 | error: { code: null, msg: null } 56 | }; 57 | 58 | // Initialize Event Handlers for Underlying Socket Class 59 | this._initializeEventHandlers(); 60 | } 61 | 62 | // region Property Accessors 63 | /** 64 | * Returns an Object 65 | * - error code 66 | * - human readable error 67 | * 68 | * @readonly 69 | * @memberof ENIP 70 | */ 71 | get error() { 72 | return this.state.error; 73 | } 74 | 75 | /** 76 | * Session Establishment In Progress 77 | * 78 | * @readonly 79 | * @memberof ENIP 80 | */ 81 | get establishing() { 82 | return this.state.session.establishing; 83 | } 84 | /** 85 | * Session Established Successfully 86 | * 87 | * @readonly 88 | * @memberof ENIP 89 | */ 90 | get established() { 91 | return this.state.session.established; 92 | } 93 | 94 | /** 95 | * Get ENIP Session ID 96 | * 97 | * @readonly 98 | * @memberof ENIP 99 | */ 100 | get session_id() { 101 | return this.state.session.id; 102 | } 103 | 104 | /** 105 | * Various setters for Connection parameters 106 | * 107 | * @memberof ENIP 108 | */ 109 | set establishing_conn(newEstablish) { 110 | if (typeof(newEstablish) !== "boolean") { 111 | throw new Error("Wrong type passed when setting connection: establishing parameter"); 112 | } 113 | this.state.connection.establishing = newEstablish; 114 | } 115 | 116 | set established_conn(newEstablished) { 117 | if (typeof(newEstablished) !== "boolean") { 118 | throw new Error("Wrong type passed when setting connection: established parameter"); 119 | } 120 | this.state.connection.established = newEstablished; 121 | } 122 | 123 | set id_conn(newID) { 124 | if (typeof(newID) !== "number") { 125 | throw new Error("Wrong type passed when setting connection: id parameter"); 126 | } 127 | this.state.connection.id = newID; 128 | } 129 | 130 | set seq_conn(newSeq) { 131 | if (typeof(newSeq) !== "number") { 132 | throw new Error("Wrong type passed when setting connection: seq_numparameter"); 133 | } 134 | this.state.connection.seq_num = newSeq; 135 | } 136 | 137 | /** 138 | * Various getters for Connection parameters 139 | * 140 | * @memberof ENIP 141 | */ 142 | get establishing_conn() { 143 | return this.state.connection.establishing; 144 | } 145 | 146 | get established_conn() { 147 | return this.state.connection.established; 148 | } 149 | 150 | get id_conn() { 151 | return this.state.connection.id; 152 | } 153 | 154 | get seq_conn() { 155 | return this.state.connection.seq_num; 156 | } 157 | // endregion 158 | 159 | // region Public Method Definitions 160 | connect(port: unknown, host?: unknown, connectionListener?: unknown): any 161 | /** 162 | * Initializes Session with Desired IP Address or FQDN 163 | * and Returns a Promise with the Established Session ID 164 | * 165 | * @override 166 | * @param IP_ADDR - IPv4 Address (can also accept a FQDN, provided port forwarding is configured correctly.) 167 | * @returns Session Id 168 | */ 169 | async connect(IP_ADDR: string, timeoutSP: number = 10000, localAddress: string = '0.0.0.0'): Promise { 170 | if (!IP_ADDR) { 171 | throw new Error("Controller requires IP_ADDR !!!"); 172 | } 173 | await new Promise((resolve, reject) => { 174 | lookup(IP_ADDR, (err, addr) => { 175 | if (err) reject(new Error("DNS Lookup failed for IP_ADDR " + IP_ADDR)); 176 | 177 | if (!isIPv4(addr)) { 178 | reject(new Error("Invalid IP_ADDR passed to Controller ")); 179 | } 180 | resolve(); 181 | }); 182 | }); 183 | 184 | const { registerSession } = encapsulation; 185 | 186 | this.state.session.establishing = true; 187 | this.state.TCP.establishing = true; 188 | 189 | const connectErr = new Error( 190 | "TIMEOUT occurred while attempting to establish TCP connection with Controller." 191 | ); 192 | 193 | // Connect to Controller and Then Send Register Session Packet 194 | await promiseTimeout( 195 | new Promise((resolve, reject)=> { 196 | let options = { 197 | port: EIP_PORT, 198 | host: IP_ADDR, 199 | localAddress: localAddress 200 | }; 201 | let socket = super.connect( 202 | options, 203 | () => { 204 | this.state.TCP.establishing = false; 205 | this.state.TCP.established = true; 206 | 207 | this.write(registerSession()); 208 | resolve(); 209 | } 210 | ); 211 | 212 | socket.on("error", () => { 213 | reject(new Error("SOCKET error")); 214 | }); 215 | }), 216 | timeoutSP, 217 | connectErr 218 | ); 219 | 220 | const sessionErr = new Error( 221 | "TIMEOUT occurred while attempting to establish Ethernet/IP session with Controller." 222 | ); 223 | 224 | // Wait for Session to be Registered 225 | const sessid = await promiseTimeout( 226 | new Promise(resolve => { 227 | this.on("Session Registered", sessid => { 228 | resolve(sessid); 229 | }); 230 | 231 | this.on("Session Registration Failed", error => { 232 | this.state.error.code = error; 233 | this.state.error.msg = "Failed to Register Session"; 234 | resolve(null); 235 | }); 236 | }), 237 | timeoutSP, 238 | sessionErr 239 | ); 240 | 241 | // Clean Up Local Listeners 242 | this.removeAllListeners("Session Registered"); 243 | this.removeAllListeners("Session Registration Failed"); 244 | 245 | // Return Session ID 246 | return sessid; 247 | } 248 | 249 | /** 250 | * Writes Ethernet/IP Data to Socket as an Unconnected Message 251 | * or a Transport Class 1 Datagram 252 | * 253 | * NOTE: Cant Override Socket Write due to net.Socket.write 254 | * implementation. =[. Thus, I am spinning up a new Method to 255 | * handle it. Dont Use Enip.write, use this function instead. 256 | * 257 | * @param data - Data Buffer to be Encapsulated 258 | * @param connected - Connected communication 259 | * @param timeout - Timeout (sec) 260 | * @param cb - Callback to be Passed to Parent.Write() 261 | */ 262 | write_cip(data: Buffer, connected: boolean = false, timeout: number = 10, cb: () => void = null) { 263 | const { sendRRData, sendUnitData } = encapsulation; 264 | const { session, connection } = this.state; 265 | 266 | if (session.established) { 267 | if(connected === true) { 268 | if (connection.established === true) { 269 | connection.seq_num += 1; 270 | if (connection.seq_num > 0xffff) connection.seq_num = 0; 271 | } 272 | else { 273 | throw new Error ("Connected message request, but no connection established. Forgot forwardOpen?"); 274 | } 275 | } 276 | const packet = connected 277 | ? sendUnitData(session.id, data, connection.id, connection.seq_num) 278 | : sendRRData(session.id, data, timeout); 279 | 280 | if (cb) { 281 | this.write(packet, cb); 282 | } else { 283 | this.write(packet); 284 | } 285 | } 286 | } 287 | 288 | /** 289 | * Sends Unregister Session Command and Destroys Underlying TCP Socket 290 | * 291 | * @override 292 | * @param {Exception} exception - Gets passed to 'error' event handler 293 | * @memberof ENIP 294 | */ 295 | destroy(exception?: Error): this { 296 | const { unregisterSession } = encapsulation; 297 | const unregisteredSession = unregisterSession(this.state.session.id); 298 | 299 | const onClose = () => { 300 | this.state.session.established = false; 301 | super.destroy(exception); 302 | }; 303 | 304 | // Only write to the socket if is not closed. 305 | if (exception !== undefined && exception.name !== "EPIPE" ) { 306 | this.write(unregisteredSession, onClose); 307 | } else { 308 | onClose(); 309 | } 310 | 311 | return this; 312 | } 313 | // endregion 314 | 315 | // region Private Method Definitions 316 | _initializeEventHandlers() { 317 | this.on("data", this._handleDataEvent); 318 | this.on("close", this._handleCloseEvent); 319 | } 320 | //endregion 321 | 322 | // region Event Handlers 323 | 324 | /** 325 | * @typedef EncapsulationData 326 | * @type {Object} 327 | * @property {number} commandCode - Ecapsulation Command Code 328 | * @property {string} command - Encapsulation Command String Interpretation 329 | * @property {number} length - Length of Encapsulated Data 330 | * @property {number} session - Session ID 331 | * @property {number} statusCode - Status Code 332 | * @property {string} status - Status Code String Interpretation 333 | * @property {number} options - Options (Typically 0x00) 334 | * @property {Buffer} data - Encapsulated Data Buffer 335 | */ 336 | /*****************************************************************/ 337 | 338 | /** 339 | * Socket.on('data) Event Handler 340 | * 341 | * @param data - Data Received from Socket.on('data', ...) 342 | */ 343 | _handleDataEvent(data: Buffer): void { 344 | const { header, CPF, commands } = encapsulation; 345 | 346 | const encapsulatedData = header.parse(data); 347 | const { statusCode, status, commandCode } = encapsulatedData; 348 | 349 | if (statusCode !== 0) { 350 | console.log(`Error <${statusCode}>:`, status); 351 | 352 | this.state.error.code = statusCode; 353 | this.state.error.msg = status; 354 | 355 | this.emit("Session Registration Failed", this.state.error); 356 | } else { 357 | this.state.error.code = null; 358 | this.state.error.msg = null; 359 | /* eslint-disable indent */ 360 | switch (commandCode) { 361 | case commands.RegisterSession: 362 | this.state.session.establishing = false; 363 | this.state.session.established = true; 364 | this.state.session.id = encapsulatedData.session; 365 | this.emit("Session Registered", this.state.session.id); 366 | break; 367 | 368 | case commands.UnregisterSession: 369 | this.state.session.established = false; 370 | this.emit("Session Unregistered"); 371 | break; 372 | 373 | case commands.SendRRData: { 374 | let buf1 = Buffer.alloc(encapsulatedData.length - 6); // length of Data - Interface Handle and Timeout 375 | encapsulatedData.data.copy(buf1, 0, 6); 376 | 377 | const srrd = CPF.parse(buf1); 378 | this.emit("SendRRData Received", srrd); 379 | break; 380 | } 381 | case commands.SendUnitData: { 382 | let buf2 = Buffer.alloc(encapsulatedData.length - 6); // length of Data - Interface Handle and Timeout 383 | encapsulatedData.data.copy(buf2, 0, 6); 384 | 385 | const sud = CPF.parse(buf2); 386 | this.emit("SendUnitData Received", sud); 387 | break; 388 | } 389 | default: 390 | this.emit("Unhandled Encapsulated Command Received", encapsulatedData); 391 | } 392 | /* eslint-enable indent */ 393 | } 394 | } 395 | 396 | /** 397 | * Socket.on('close',...) Event Handler 398 | * 399 | * @memberof ENIP 400 | */ 401 | _handleCloseEvent() { 402 | this.state.session.established = false; 403 | this.state.TCP.established = false; 404 | } 405 | // endregion 406 | } 407 | 408 | export { ENIP, CIP, encapsulation, enipConnection, enipSession, enipError, enipTCP}; 409 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Controller from "./controller"; 2 | import Tag from "./tag"; 3 | import TagGroup from "./tag-group"; 4 | import * as EthernetIP from "./enip"; 5 | import * as util from "./utilities"; 6 | import TagList from "./tag-list"; 7 | import { Structure } from "./structure"; 8 | import Browser from "./browser"; 9 | import IO from "./io"; 10 | import ControllerManager from "./controller-manager"; 11 | import { extController } from "./controller-manager"; 12 | 13 | export { Controller, Tag, TagGroup, EthernetIP, util, TagList, Structure, Browser, IO, ControllerManager, extController}; 14 | 15 | -------------------------------------------------------------------------------- /src/io/fork/child.js: -------------------------------------------------------------------------------- 1 | const Scanner = require("../udp"); 2 | 3 | const scanner = new Scanner(parseInt(process.argv[2]), process.argv[3]) 4 | let connMap = {}; 5 | if (process.on) { 6 | process.on('SIGINT', () => { 7 | //Don't Exit 8 | }) 9 | process.on('message', mess => { 10 | switch(mess.type) { 11 | case 'newConnection': 12 | if (mess.data.config.configInstance.data) { 13 | mess.data.config.configInstance.data = Buffer.from(mess.data.config.configInstance.data) 14 | } 15 | let conn = scanner.addConnection(mess.data.config, mess.data.rpi, mess.data.address, mess.data.port) 16 | connMap[mess.address] = conn; 17 | conn.inputDataLast = Buffer.alloc(conn.TOsize) 18 | conn.lastConnected = false; 19 | conn.inputInterval = setInterval(() => { 20 | if (Buffer.compare(conn.inputDataLast,conn.inputData) !== 0) { 21 | conn.inputData.copy(conn.inputDataLast); 22 | process.send({ 23 | type: 'inputData', 24 | data: conn.inputData, 25 | address: conn.address 26 | }) 27 | } 28 | 29 | if (conn.lastConnected !== conn.connected) { 30 | conn.lastConnected = conn.connected; 31 | process.send({ 32 | type: 'status', 33 | data: conn.connected, 34 | address: conn.address 35 | }) 36 | } 37 | }, conn.rpi) 38 | break; 39 | case 'run': 40 | connMap[mess.address].run = mess.data; 41 | break; 42 | case 'outputData': 43 | connMap[mess.address].outputData = Buffer.from(mess.data) 44 | break; 45 | case 'closeConn': 46 | connMap[mess.address].run = false; 47 | clearInterval(connMap[mess.address].inputInterval) 48 | connMap[mess.address] = null; 49 | break; 50 | case 'closeScanner': 51 | scanner.connections = []; 52 | scanner.socket.close(); 53 | process.exit(); 54 | break; 55 | } 56 | 57 | }) 58 | } 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/io/fork/parent.js: -------------------------------------------------------------------------------- 1 | import { fork } from 'child_process'; 2 | import path from 'path'; 3 | 4 | const program = path.resolve(__dirname,'./child.js'); 5 | 6 | const options = { 7 | stdio: [0, 1, 2, 'ipc'], 8 | }; 9 | 10 | class forkConnection { 11 | constructor(port, address, config, rpi, localAddress, child) { 12 | this.inputData = Buffer.alloc(config.inputInstance.size); 13 | this.outputData = Buffer.alloc(config.outputInstance.size); 14 | this.lastOutputData = Buffer.alloc(config.outputInstance.size); 15 | this.address = address; 16 | this.rpi = rpi; 17 | this.connected = false; 18 | this.runCommand = true; 19 | this.child = child; 20 | 21 | if (this.child.connected) { 22 | this.child.send({ 23 | type: 'newConnection', 24 | data: { 25 | config: config, 26 | rpi: rpi, 27 | address: address, 28 | localAddress: localAddress, 29 | port: port 30 | }, 31 | address: this.address 32 | }) 33 | } 34 | 35 | let that = this; 36 | this.scan = setInterval(() => { 37 | this._checkChanges(that) 38 | }, rpi); 39 | 40 | } 41 | 42 | set run(val) { 43 | this.runCommand = val; 44 | if (this.child.connected) { 45 | this.child.send({ 46 | type: 'run', 47 | data: val, 48 | address: this.address 49 | }); 50 | } else { 51 | this.connected = false; 52 | } 53 | } 54 | 55 | get run() { 56 | return this.runCommand 57 | } 58 | 59 | _checkChanges(that) { 60 | if (Buffer.compare(that.outputData, that.lastOutputData) !== 0) { 61 | that.outputData.copy(that.lastOutputData); 62 | 63 | if (that.child.connected) { 64 | that.child.send({ 65 | type: 'outputData', 66 | address: that.address, 67 | data: that.outputData 68 | }) 69 | } else { 70 | that.connected = false; 71 | } 72 | } 73 | } 74 | 75 | close() { 76 | this.runCommand = false; 77 | clearInterval(this.scan) 78 | 79 | if (this.child.connected) { 80 | this.child.send({ 81 | type: 'closeConn', 82 | address: this.address 83 | }) 84 | } else { 85 | this.connected = false; 86 | } 87 | 88 | } 89 | 90 | } 91 | 92 | class forkScanner { 93 | constructor(port=2222, localAddress='0.0.0.0' ) { 94 | this.port = port; 95 | this.localAddress = localAddress; 96 | this.child = fork(program, [this.port, this.localAddress], options); 97 | this.connections = {}; 98 | this.child.on('message', mess => { 99 | this._onMessage(mess, this); 100 | }); 101 | } 102 | 103 | addConnection(config, rpi, address, port=2222) { 104 | let conn = new forkConnection(port, address, config, rpi, this.localAddress, this.child); 105 | this.connections[conn.address] = conn 106 | return this.connections[conn.address]; 107 | } 108 | 109 | _onMessage(message, that) { 110 | if (message.type === 'inputData') { 111 | this.connections[message.address].inputData = Buffer.from(message.data) 112 | } 113 | 114 | if (message.type === 'status') { 115 | this.connections[message.address].connected = !!message.data 116 | } 117 | } 118 | 119 | close(cb) { 120 | if (this.child.connected) { 121 | this.child.send({ 122 | type: 'closeScanner' 123 | }); 124 | } 125 | cb(); 126 | } 127 | } 128 | 129 | module.exports = forkScanner; -------------------------------------------------------------------------------- /src/io/index.js: -------------------------------------------------------------------------------- 1 | const Scanner = require("./udp"); 2 | const ForkScanner = require("./fork/parent") 3 | 4 | module.exports = {Scanner, ForkScanner}; -------------------------------------------------------------------------------- /src/io/udp/connection/index.js: -------------------------------------------------------------------------------- 1 | import TCPController from "../../tcp"; 2 | import SerialNumber from "./sna"; 3 | const EventEmitter = require("events"); 4 | import InputMap from "./inputmap"; 5 | import OutputMap from "./outputmap"; 6 | 7 | class Connection extends EventEmitter { 8 | constructor(port=2222, address, config, rpi=10, localAddress) { 9 | super(); 10 | //this.tcpController = new TCPController(true, config.configInstance, config.outputInstance, config.inputInstance); 11 | this.connected = false; 12 | this.config = config; 13 | this.lastDataTime = 0; 14 | this.rpi = rpi; 15 | 16 | this.address = address; 17 | this.port = port; 18 | this.OTid = 0; 19 | this.TOid = 0; 20 | this.OTsize = config.outputInstance.size; 21 | this.TOsize = config.inputInstance.size; 22 | 23 | this.OTsequenceNum = 0; 24 | this.TOsequenceNum = 0; 25 | this.cipCount = 0; 26 | 27 | this.outputData = Buffer.alloc(this.OTsize); 28 | this.inputData = Buffer.alloc(this.TOsize); 29 | let that = this; 30 | this.localAddress = localAddress; 31 | this.run = true; 32 | this._connectTCP(that); 33 | 34 | this.inputMap = new InputMap(); 35 | this.outputMap = new OutputMap(); 36 | 37 | setInterval(() => that._checkStatus(that), 1000); 38 | } 39 | 40 | generateDataMessage() { 41 | let ioEnipPacket = Buffer.alloc(24); 42 | let ptr = 0; 43 | 44 | ioEnipPacket.writeUInt16LE(2); // Item Count 45 | ptr += 2; 46 | ioEnipPacket.writeUInt16LE(0x8002, ptr); //Sequenced Address Item 47 | ptr += 2; 48 | ioEnipPacket.writeUInt16LE(8, ptr); // Item Length 49 | ptr += 2; 50 | ioEnipPacket.writeUInt32LE(this.OTid, ptr); //Connection ID 51 | ptr += 4; 52 | ioEnipPacket.writeUInt32LE(this.OTsequenceNum, ptr); // Sequence Numtber 53 | ptr += 4; 54 | ioEnipPacket.writeUInt16LE(0x00b1, ptr); //Connected Data Item 55 | ptr += 2; 56 | ioEnipPacket.writeUInt16LE(6 + this.outputData.length, ptr); // Item Length 57 | ptr += 2; 58 | ioEnipPacket.writeUInt16LE(this.cipCount, ptr); // cip count 59 | ptr += 2; 60 | ioEnipPacket.writeUInt32LE(1, ptr); // 32-bit header 61 | 62 | return Buffer.concat([ioEnipPacket, this.outputData]); 63 | } 64 | 65 | send(socket) { 66 | this.OTsequenceNum ++; 67 | if (this.OTsequenceNum > 0xFFFFFFFF) this.OTsequenceNum = 0; 68 | if (this.run) socket.send(this.generateDataMessage(), this.port, this.address); 69 | } 70 | 71 | parseData(data, socket) { 72 | if (data.readUInt32LE(6) === this.TOid) { 73 | this.lastDataTime = Date.now(); 74 | const seqAddr = data.readUInt32LE(10); 75 | if ( new SerialNumber(seqAddr,32).gt(new SerialNumber(this.TOsequenceNum, 32)) ) { 76 | this.TOsequenceNum = seqAddr; 77 | 78 | this.cipCount++; 79 | if (this.cipCount > 0xFFFF) this.cipCount = 0; 80 | this.send(socket); 81 | this.inputData = data.slice(20, 20 + this.TOsize); 82 | } 83 | } 84 | } 85 | 86 | _connectTCP(that) { 87 | that.OTsequenceNum = 0; 88 | that.TOsequenceNum = 0; 89 | that.cipCount = 0; 90 | that.tcpController = new TCPController(true, that.config.configInstance, that.config.outputInstance, that.config.inputInstance); 91 | that.tcpController.rpi = that.rpi; 92 | that.tcpController.timeout_sp = 2000; 93 | that.tcpController.connect(that.address, 0, that.localAddress) 94 | .then ( () => { 95 | that.OTid = that.tcpController.OTconnectionID; 96 | that.TOid = that.tcpController.TOconnectionID; 97 | }) 98 | .catch(() => { 99 | that.lastDataTime = 0; 100 | that.connected = false; 101 | setTimeout(() => that._connectTCP(that), that.rpi * 20); 102 | }); 103 | } 104 | 105 | _checkStatus(that) { 106 | if (Date.now() - that.lastDataTime > that.tcpController.rpi * 4) { 107 | if (that.connected) { 108 | that.emit("disconnected", null); 109 | that.TOid = 0; 110 | } 111 | if (!that.tcpController.state.TCP.establishing && that.connected && that.run) setTimeout(() => that._connectTCP(that), that.rpi * 20); 112 | that.connected = false; 113 | 114 | } else { 115 | if(!that.connected) { 116 | that.emit("connected", null); 117 | } 118 | that.connected = true; 119 | } 120 | } 121 | 122 | addInputBit(byteOffset, bitOffset, name) { 123 | this.inputMap.addBit(byteOffset, bitOffset, name); 124 | } 125 | 126 | addInputInt(byteOffset, name) { 127 | this.inputMap.addInt(byteOffset, name); 128 | } 129 | 130 | addOutputBit(byteOffset, bitOffset, name) { 131 | this.outputMap.addBit(byteOffset, bitOffset, name); 132 | } 133 | 134 | addOutputInt(byteOffset, name) { 135 | this.outputMap.addInt(byteOffset, name); 136 | } 137 | 138 | listDataNames() { 139 | return { 140 | inputs: this.inputMap.mapping.map(map => map.name), 141 | outputs: this.outputMap.mapping.map(map => map.name) 142 | }; 143 | } 144 | 145 | getValue(name) { 146 | return this.inputMap.getValue(name, this.inputData); 147 | } 148 | 149 | setValue(name, value) { 150 | this.outputData = this.outputMap.setValue(name, value, this.outputData); 151 | } 152 | 153 | } 154 | 155 | module.exports = Connection; -------------------------------------------------------------------------------- /src/io/udp/connection/inputmap/index.ts: -------------------------------------------------------------------------------- 1 | type inputMapItem = { 2 | size: number, 3 | byte: number, 4 | offset: number, 5 | name: string, 6 | value: any 7 | } 8 | 9 | class InputMap { 10 | mapping: inputMapItem[] 11 | /** 12 | * Helper to decode input buffer to process value 13 | */ 14 | constructor() { 15 | this.mapping = []; 16 | } 17 | 18 | /** 19 | * Add a bit value to decode 20 | * 21 | * @param byte - Which byte to start at 22 | * @param offset - Number of bits to offset 23 | * @param name - Unique name to reference value 24 | */ 25 | addBit(byte: number, offset: number, name: string) { 26 | this.mapping.push({ 27 | size: 1, 28 | byte: byte, 29 | offset: offset, 30 | name: name, 31 | value: false 32 | }); 33 | 34 | } 35 | 36 | /** 37 | * Add a 16 bit integer value to decode 38 | * 39 | * @param byte - Number off bytes to offset 40 | * @param name - Unique name to reference value 41 | */ 42 | addInt(byte: number, name: string): void { 43 | this.mapping.push({ 44 | size: 16, 45 | byte: byte, 46 | offset: null, 47 | name: name, 48 | value: 0 49 | }); 50 | 51 | } 52 | 53 | /** 54 | * Reads input buffer and decodes each map process value 55 | * 56 | * @param data - Device input buffer 57 | */ 58 | _readMap(data: Buffer): void { 59 | this.mapping.forEach(map => { 60 | switch (map.size) { 61 | case 1: 62 | map.value = Boolean(data[map.byte] & (1 << map.offset)); 63 | break; 64 | case 16: 65 | map.value = data.readUInt16LE(map.byte); 66 | break; 67 | } 68 | }); 69 | } 70 | 71 | /** 72 | * Get a process value from a input buffer 73 | * 74 | * @param name - Name of map item 75 | * @param buf - Device input buffer 76 | * @returns Process value 77 | */ 78 | getValue(name: string, buf: Buffer): any { 79 | this._readMap(buf); 80 | return this.mapping.find(map => map.name === name).value; 81 | 82 | } 83 | 84 | /** 85 | * Get array of names assigned to mappings 86 | * 87 | * @returns Array of names 88 | */ 89 | getNames(): string[] { 90 | return this.mapping.map(item => item.name); 91 | } 92 | 93 | } 94 | 95 | export default InputMap; -------------------------------------------------------------------------------- /src/io/udp/connection/outputmap/index.ts: -------------------------------------------------------------------------------- 1 | type outputMapItem = { 2 | size: number, 3 | byte: number, 4 | offset: number, 5 | name: string, 6 | value: any 7 | } 8 | 9 | class OutputMap { 10 | mapping: outputMapItem[]; 11 | /** 12 | * Helper to encode input buffer from process value 13 | */ 14 | constructor() { 15 | this.mapping = []; 16 | } 17 | 18 | /** 19 | * Add a bit value to encode 20 | * 21 | * @param byte - Which byte to start at 22 | * @param offset - Number of bits to offset 23 | * @param name - Unique name to reference value 24 | * @param value - Initial value 25 | */ 26 | addBit(byte: number, offset: number, name: string, value: boolean = false): void { 27 | this.mapping.push({ 28 | size: 1, 29 | byte: byte, 30 | offset: offset, 31 | name: name, 32 | value: value 33 | }); 34 | 35 | } 36 | 37 | /** 38 | * Add a 16 bit integer value to encode 39 | * 40 | * @param byte - Number off bytes to offset 41 | * @param name - Unique name to reference value 42 | * @param value - Initial value 43 | */ 44 | addInt(byte: number, name: string, value: number = 0): void { 45 | this.mapping.push({ 46 | size: 16, 47 | byte: byte, 48 | offset: null, 49 | name: name, 50 | value: value 51 | }); 52 | 53 | } 54 | 55 | /** 56 | * Reads encodes each map process value and writes to buffer 57 | * 58 | * @param data - Device output buffer 59 | * @returns Device output buffer encoded with values 60 | */ 61 | _writeMap(data: Buffer): Buffer { 62 | this.mapping.forEach(map => { 63 | switch (map.size) { 64 | case 1: 65 | (map.value) ? data[map.byte] |= (1 << map.offset) : data[map.byte] &= ~(1 << map.offset); 66 | break; 67 | case 16: 68 | data.writeUInt16LE(map.value, map.byte); 69 | break; 70 | } 71 | }); 72 | 73 | return data; 74 | } 75 | 76 | /** 77 | * Set a process value for a output buffer 78 | * 79 | * @param name - Name of map item 80 | * @param data - Device input buffer 81 | * @param value - New value to send to device 82 | * @returns Output buffer to send to device 83 | */ 84 | setValue(name: string, value: any, data: Buffer): Buffer { 85 | this.mapping[this.mapping.findIndex(map => map.name === name)].value = value; 86 | return this._writeMap(data); 87 | } 88 | 89 | /** 90 | * Get array of names assigned to mappings 91 | * 92 | * @returns Array of names 93 | */ 94 | getNames(): string[] { 95 | return this.mapping.map(item => item.name); 96 | } 97 | 98 | } 99 | 100 | export default OutputMap; -------------------------------------------------------------------------------- /src/io/udp/connection/sna/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export default class SerialNumber { 3 | serialBits: number; 4 | serialBytes: number; 5 | _value: number; 6 | _half: number; 7 | _modulo: number; 8 | _maxAdd: number; 9 | number: number; 10 | /** 11 | * SerialNumber constructor 12 | * 13 | * @param value - The little endian encoded number 14 | * @param size - The size of the serial number space in bits 15 | **/ 16 | constructor(value: number, size: number) { 17 | if (!(this instanceof SerialNumber)) { 18 | return new SerialNumber(value, size); 19 | } 20 | 21 | value = typeof value !== "undefined" ? value : 0; 22 | size = typeof size !== "undefined" ? size : 32; 23 | 24 | this.serialBits = size; 25 | this.serialBytes = size / 8; 26 | this._value = value; 27 | this._modulo = Math.pow(2, this.serialBits); 28 | this._half = Math.pow(2, this.serialBits - 1); 29 | this._maxAdd = this._half - 1; 30 | this.number = this._value % this._modulo; 31 | } 32 | 33 | /** 34 | * Equality comparison with another SerialNumber 35 | * 36 | * @param that - SerialNumber to make comparison with 37 | * @return comparison 38 | **/ 39 | eq(that: SerialNumber): boolean { 40 | return this.number === that.number; 41 | }; 42 | 43 | /** 44 | * Not equal comparison with another SerialNumber 45 | * 46 | * @param that - SerialNumber to make comparison with 47 | * @return {comparison 48 | **/ 49 | ne(that: SerialNumber): boolean { 50 | return this.number !== that.number; 51 | }; 52 | 53 | /** 54 | * Less than comparison with another SerialNumber 55 | * 56 | * @param that - SerialNumber to make comparison with 57 | * @return comparison 58 | **/ 59 | lt(that: SerialNumber): boolean { 60 | return (this.number < that.number && (that.number - this.number < this._half)) || 61 | (this.number > that.number && (this.number - that.number > this._half)); 62 | }; 63 | 64 | /** 65 | * Greater than comparison with another SerialNumber 66 | * 67 | * @param that - SerialNumber to make comparison with 68 | * @return comparison 69 | **/ 70 | gt(that: SerialNumber): boolean { 71 | return (this.number < that.number && (that.number - this.number > this._half)) || 72 | (this.number > that.number && (this.number - that.number < this._half)); 73 | }; 74 | 75 | /** 76 | * Less than or equal comparison with another SerialNumber 77 | * 78 | * @param that - SerialNumber to make comparison with 79 | * @return comparison 80 | **/ 81 | le(that: SerialNumber): boolean { 82 | return this.eq(that) || this.lt(that); 83 | }; 84 | 85 | /** 86 | * Greater than or equal comparison with another SerialNumber 87 | * 88 | * @param that - SerialNumber to make comparison with 89 | * @return comparison 90 | **/ 91 | ge(that: SerialNumber): boolean { 92 | return this.eq(that) || this.gt(that); 93 | }; 94 | 95 | /** 96 | * Addition operation on two SerialNumbers 97 | * 98 | * @param that - Add this SerialNumber to the receiver 99 | * @return value of addition 100 | **/ 101 | add(that: SerialNumber): number { 102 | if (!this.additionOpValid.call(that)) { 103 | throw Error("Addition of this value outside [0 .. maxAdd] range"); 104 | } else { 105 | this.number = (this.number + that.number) % this._modulo; 106 | return this.number; 107 | } 108 | }; 109 | 110 | /** 111 | * Return the number 112 | * 113 | * @param options - Optional {radix: 10, string: true, encoding:} 114 | * @returns number 115 | **/ 116 | getNumber(options: any): string | number { 117 | options = typeof options !== "undefined" ? options : {}; 118 | options.radix = options.radix ? options.radix : 10; 119 | options.string = options.string !== undefined ? options.string : true; 120 | 121 | var number = this.number.toString(options.radix); 122 | 123 | if (options.encoding === "BE") { 124 | var buf = Buffer.alloc(this.serialBytes); 125 | buf.writeUIntLE(this.number, 0, this.serialBytes); 126 | number = buf.readUIntBE(0, this.serialBytes).toString(options.radix); 127 | } 128 | 129 | if (options.string) { 130 | return number; 131 | } else { 132 | return parseInt(number, options.radix); 133 | } 134 | }; 135 | 136 | /** 137 | * Return the serial space 138 | * 139 | * @params bytes - Return serial space as bytes instead of bits 140 | * @return bits|bytes as integer 141 | **/ 142 | getSpace(bytes: boolean): number { 143 | if (bytes) { 144 | return this.serialBytes; 145 | } else { 146 | return this.serialBits; 147 | } 148 | }; 149 | 150 | /* 151 | * Override default toString method 152 | */ 153 | toString(): string { 154 | return ""; 155 | }; 156 | 157 | /** 158 | * Test if addition op valid for two SerialNumbers 159 | * 160 | * @param that - Test if addition possible with receiver 161 | * @return result of test 162 | **/ 163 | additionOpValid(that: SerialNumber): boolean { 164 | return that.number > 0 && that.number <= this._maxAdd; 165 | } 166 | } 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | -------------------------------------------------------------------------------- /src/io/udp/index.js: -------------------------------------------------------------------------------- 1 | const dgram = require("dgram"); 2 | const Connection = require("./connection"); 3 | 4 | class Controller { 5 | constructor(port=2222, localAddress) { 6 | this.socket = dgram.createSocket("udp4"); 7 | this.socket.bind({port: port, address: localAddress}); 8 | this.localAddress = localAddress 9 | 10 | this.connections = []; 11 | this._setupMessageEvent(); 12 | } 13 | 14 | addConnection(config, rpi, address, port=2222) { 15 | let conn = new Connection(port, address, config, rpi, this.localAddress); 16 | return this.connections[this.connections.push(conn) - 1]; 17 | } 18 | 19 | _setupMessageEvent() { 20 | this.socket.on("message", data => { 21 | this._messageRouter(data); 22 | }); 23 | } 24 | 25 | _messageRouter(data) { 26 | this.connections.forEach(conn => { 27 | conn.parseData(data, this.socket); 28 | }); 29 | } 30 | } 31 | 32 | module.exports = Controller; -------------------------------------------------------------------------------- /src/structure/template/index.ts: -------------------------------------------------------------------------------- 1 | import { CIP } from "../../enip"; 2 | 3 | type templateType = { 4 | code: number, 5 | string: string, 6 | structure: boolean, 7 | reserved: boolean, 8 | arrayDims: number 9 | } 10 | 11 | type templateMember = { 12 | name: string, 13 | info: number, 14 | type: templateType, 15 | offset: number 16 | } 17 | 18 | type templateAttributes = { 19 | id: number, 20 | ObjDefinitionSize: number, 21 | StructureSize: number, 22 | MemberCount: number, 23 | StructureHandle: number 24 | } 25 | 26 | class Template { 27 | _attributes: templateAttributes; 28 | _members: templateMember[]; 29 | _name: string; 30 | id: number; 31 | 32 | /** 33 | * Template Class reads and parses information template information that is used for parsing STRUCT datatypes 34 | */ 35 | constructor () { 36 | 37 | this._attributes = { 38 | id: null, 39 | ObjDefinitionSize: null, 40 | StructureSize: null, 41 | MemberCount: null, 42 | StructureHandle: null 43 | }; 44 | this._members = []; 45 | this._name = ""; 46 | this.id = null; 47 | 48 | } 49 | 50 | /** 51 | * Build CIP message to get template attributes 52 | * 53 | * @param templateID - Id number of template 54 | * @returns CIP message to get template attributes 55 | */ 56 | _buildGetTemplateAttributesCIP (templateID: number): Buffer { 57 | const attributeCount = Buffer.from([0x04, 0x00]); 58 | const attributeList = Buffer.from([0x04, 0x00, 0x05, 0x00, 0x02, 0x00, 0x01, 0x00]); // Attributes 4, 5, 2, 1 59 | 60 | const { LOGICAL } = CIP.EPATH.segments; 61 | 62 | const path = Buffer.concat([ 63 | LOGICAL.build(LOGICAL.types.ClassID, 0x6C), 64 | LOGICAL.build(LOGICAL.types.InstanceID, templateID) 65 | ]); 66 | 67 | return CIP.MessageRouter.build(CIP.MessageRouter.services.GET_ATTRIBUTES, path, Buffer.concat([attributeCount, attributeList])); 68 | } 69 | 70 | /** 71 | * Parse message response and store template attributes 72 | * 73 | * @param data - message response 74 | */ 75 | _parseReadTemplateAttributes(data: Buffer): void { 76 | let pointer = 6; 77 | 78 | this._attributes.ObjDefinitionSize = data.readUInt32LE(pointer); 79 | pointer += 8; 80 | this._attributes.StructureSize = data.readUInt32LE(pointer); 81 | pointer += 8; 82 | this._attributes.MemberCount = data.readUInt16LE(pointer); 83 | pointer += 6; 84 | this._attributes.StructureHandle = data.readUInt16LE(pointer); 85 | } 86 | 87 | /** 88 | * Build CIP message to get template members 89 | * 90 | * @param offset 91 | * @param reqSize 92 | * @returns CIP message to get template members 93 | */ 94 | _buildGetTemplateCIP (offset:number = 0, reqSize: number): Buffer { 95 | 96 | const { LOGICAL } = CIP.EPATH.segments; 97 | 98 | const path = Buffer.concat([ 99 | CIP.EPATH.segments.LOGICAL.build(LOGICAL.types.ClassID, 0x6C), 100 | CIP.EPATH.segments.LOGICAL.build(LOGICAL.types.InstanceID, this._attributes.id) 101 | ]); 102 | 103 | let offsetBuf = Buffer.alloc(4); 104 | offsetBuf.writeUInt32LE(offset); 105 | let size = Buffer.alloc(2); 106 | size.writeUInt16LE(reqSize); 107 | 108 | return CIP.MessageRouter.build(CIP.MessageRouter.services.READ_TAG, path, Buffer.concat([ offsetBuf, size ])); 109 | } 110 | 111 | /** 112 | * Parse Template message data to create and store template member info 113 | * 114 | * @param data 115 | */ 116 | _parseReadTemplate(data: Buffer) { 117 | let pointer = 0; 118 | 119 | for (let i = 0; i < this._attributes.MemberCount; i++) { 120 | this._members.push({ 121 | name: '', 122 | info: data.readUInt16LE(pointer), 123 | type: { 124 | code: data.readUInt16LE(pointer + 2) & 0x0fff, 125 | string: CIP.DataTypes.getTypeCodeString(data.readUInt16LE(pointer + 2) & 0x0fff), 126 | structure: !!(data.readUInt16LE(pointer + 2) & 0x8000), 127 | reserved: !!(data.readUInt16LE(pointer + 2) & 0x1000), 128 | arrayDims: (data.readUInt16LE(pointer + 2) & 0x6000) >> 13 129 | }, 130 | offset: data.readUInt32LE(pointer + 4) 131 | }); 132 | 133 | pointer += 8; 134 | } 135 | 136 | let nameArray = []; 137 | 138 | let addNameData = true; 139 | while(data[pointer] !== 0x00) { 140 | if (data[pointer] === 0x3B) { 141 | addNameData = false; 142 | } 143 | if (addNameData) { 144 | nameArray.push(data[pointer]); 145 | } 146 | pointer++; 147 | } 148 | pointer++; 149 | 150 | this._name = String.fromCharCode(...nameArray); 151 | 152 | // Get Each Member 153 | for(let j=0; j < this._attributes.MemberCount; j++) { 154 | let memberNameArray = []; 155 | while(data[pointer] !== 0x00) { 156 | memberNameArray.push(data[pointer]); 157 | pointer++; 158 | } 159 | pointer++; 160 | this._members[j].name = String.fromCharCode(...memberNameArray); 161 | } 162 | } 163 | 164 | /** 165 | * Retrives Template attributes from PLC 166 | * 167 | * @param PLC - Controller Class Object 168 | * @param templateID - template ID number 169 | * @returns Promise resolved after retrival of template attributes 170 | */ 171 | _getTemplateAttributes(PLC: any, templateID: number): Promise { 172 | this.id = templateID; 173 | return new Promise((resolve, reject) => { 174 | const cipData = this._buildGetTemplateAttributesCIP(templateID); 175 | PLC.write_cip(cipData); 176 | 177 | PLC.on("Get Attributes", (error, data) => { 178 | PLC.removeAllListeners("Get Attributes"); 179 | if (error) { 180 | const errData = { 181 | func: "_getTemplateAttributes", 182 | templateID: templateID, 183 | cipReq: cipData, 184 | attributes: this._attributes, 185 | members: this._members, 186 | name: this._name 187 | }; 188 | 189 | if (Array.isArray(error.ext)) { 190 | error.ext.push(errData); 191 | } else { 192 | error.ext = [errData]; 193 | } 194 | 195 | reject(error); return; 196 | } 197 | 198 | this._parseReadTemplateAttributes(data); 199 | resolve(); 200 | }); 201 | }); 202 | } 203 | 204 | /** 205 | * Retrives the Template from PLC based on attribute data 206 | * 207 | * @param PLC - Controller Class object 208 | * @returns 209 | */ 210 | _getTemplate(PLC: any): Promise { 211 | return new Promise(async (resolve, reject) => { 212 | 213 | const reqSize = this._attributes.ObjDefinitionSize * 4 - 16; // Full template request size calc 214 | let dataBuffer = Buffer.alloc(0); 215 | let currOffset = 0; 216 | 217 | // Recursive Function incase template is bigger than max packet size 218 | const getTempData = (offset: number , getTempReqSize: number) => { 219 | return new Promise((res, rej) => { 220 | const cipData = this._buildGetTemplateCIP(offset, getTempReqSize); 221 | PLC.write_cip(cipData); 222 | 223 | PLC.on("Read Tag", (error, data) => { 224 | PLC.removeAllListeners("Read Tag"); 225 | 226 | if (error && error.generalStatusCode !== 6) { 227 | const errData = { 228 | func: "_getTemplate", 229 | offset: offset, 230 | getTempReqSize: getTempReqSize, 231 | cipReq: cipData, 232 | attributes: this._attributes, 233 | members: this._members, 234 | name: this._name 235 | }; 236 | 237 | if (Array.isArray(error.ext)) { 238 | error.ext.push(errData); 239 | } else { 240 | error.ext = [errData]; 241 | } 242 | 243 | rej(error); 244 | return; 245 | } 246 | 247 | dataBuffer = Buffer.concat([dataBuffer, data]); 248 | if (error && error.generalStatusCode === 6) { 249 | currOffset += data.length; 250 | res(getTempData(currOffset, reqSize - currOffset)); 251 | } else { 252 | res(); 253 | } 254 | }); 255 | }); 256 | }; 257 | 258 | await getTempData(currOffset, reqSize - currOffset).catch(reject); 259 | 260 | this._parseReadTemplate(dataBuffer); 261 | resolve(); 262 | }); 263 | } 264 | 265 | /** 266 | * Retrives complete template from PLC 267 | * 268 | * @param PLC - Controller Class object 269 | * @param templateID - Template ID 270 | * @returns Promise resolved upon retrival of template 271 | */ 272 | async getTemplate(PLC: any, templateID: number): Promise { 273 | this._attributes.id = templateID; 274 | await this._getTemplateAttributes(PLC, templateID); 275 | return await this._getTemplate(PLC); 276 | 277 | } 278 | } 279 | 280 | export default Template; -------------------------------------------------------------------------------- /src/tag-group/__snapshots__/tag-group.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Tag Class Generate Read Requests Method Generates Appropriate Output 1`] = ` 4 | Array [ 5 | Object { 6 | "data": Object { 7 | "data": Array [ 8 | 10, 9 | 2, 10 | 32, 11 | 2, 12 | 36, 13 | 1, 14 | 5, 15 | 0, 16 | 12, 17 | 0, 18 | 42, 19 | 0, 20 | 72, 21 | 0, 22 | 102, 23 | 0, 24 | 132, 25 | 0, 26 | 76, 27 | 13, 28 | 145, 29 | 12, 30 | 80, 31 | 114, 32 | 111, 33 | 103, 34 | 114, 35 | 97, 36 | 109, 37 | 58, 38 | 112, 39 | 114, 40 | 111, 41 | 103, 42 | 145, 43 | 9, 44 | 104, 45 | 101, 46 | 108, 47 | 108, 48 | 111, 49 | 84, 50 | 97, 51 | 103, 52 | 49, 53 | 0, 54 | 1, 55 | 0, 56 | 76, 57 | 13, 58 | 145, 59 | 12, 60 | 80, 61 | 114, 62 | 111, 63 | 103, 64 | 114, 65 | 97, 66 | 109, 67 | 58, 68 | 112, 69 | 114, 70 | 111, 71 | 103, 72 | 145, 73 | 9, 74 | 104, 75 | 101, 76 | 108, 77 | 108, 78 | 111, 79 | 84, 80 | 97, 81 | 103, 82 | 50, 83 | 0, 84 | 1, 85 | 0, 86 | 76, 87 | 13, 88 | 145, 89 | 12, 90 | 80, 91 | 114, 92 | 111, 93 | 103, 94 | 114, 95 | 97, 96 | 109, 97 | 58, 98 | 112, 99 | 114, 100 | 111, 101 | 103, 102 | 145, 103 | 9, 104 | 104, 105 | 101, 106 | 108, 107 | 108, 108 | 111, 109 | 84, 110 | 97, 111 | 103, 112 | 51, 113 | 0, 114 | 1, 115 | 0, 116 | 76, 117 | 13, 118 | 145, 119 | 12, 120 | 80, 121 | 114, 122 | 111, 123 | 103, 124 | 114, 125 | 97, 126 | 109, 127 | 58, 128 | 112, 129 | 114, 130 | 111, 131 | 103, 132 | 145, 133 | 9, 134 | 104, 135 | 101, 136 | 108, 137 | 108, 138 | 111, 139 | 84, 140 | 97, 141 | 103, 142 | 52, 143 | 0, 144 | 1, 145 | 0, 146 | 76, 147 | 13, 148 | 145, 149 | 12, 150 | 80, 151 | 114, 152 | 111, 153 | 103, 154 | 114, 155 | 97, 156 | 109, 157 | 58, 158 | 112, 159 | 114, 160 | 111, 161 | 103, 162 | 145, 163 | 9, 164 | 104, 165 | 101, 166 | 108, 167 | 108, 168 | 111, 169 | 84, 170 | 97, 171 | 103, 172 | 53, 173 | 0, 174 | 1, 175 | 0, 176 | ], 177 | "type": "Buffer", 178 | }, 179 | "tag_ids": Array [ 180 | "f933545900d94fa18e26bf9495c807a5", 181 | "6a094b0c9172a58a127de4f4e3d07b4d", 182 | "b76fbe023a62902b18acbd368c5dec70", 183 | "a7c5544db81347948c6c062509b7664d", 184 | "a44f360e207bf89c9f2a379caeb459a4", 185 | ], 186 | }, 187 | ] 188 | `; 189 | 190 | exports[`Tag Class Generate Write Requests Method Generates Appropriate Output 1`] = `Array []`; 191 | -------------------------------------------------------------------------------- /src/tag-group/index.ts: -------------------------------------------------------------------------------- 1 | import { CIP } from "../enip"; 2 | const { LOGICAL } = CIP.EPATH.segments; 3 | const { MessageRouter } = CIP; 4 | const { MULTIPLE_SERVICE_PACKET } = MessageRouter.services; 5 | import equals from "deep-equal"; 6 | import type Tag from "../tag"; 7 | 8 | type tagGroupState = { 9 | tags: object, 10 | path: Buffer, 11 | timestamp: Date 12 | } 13 | 14 | type readMessageRequests = { 15 | data: Buffer, 16 | tag_ids: string[] 17 | } 18 | 19 | type writeTagMessageRequests = { 20 | data: Buffer, 21 | tag: Tag, 22 | tag_ids: string[] 23 | } 24 | 25 | class TagGroup { 26 | state: tagGroupState; 27 | /** 28 | * Tag Group Class used for reading and writing multiple tags at once 29 | */ 30 | constructor() { 31 | const pathBuf = Buffer.concat([ 32 | LOGICAL.build(LOGICAL.types.ClassID, 0x02), // Message Router Class ID (0x02) 33 | LOGICAL.build(LOGICAL.types.InstanceID, 0x01) // Instance ID (0x01) 34 | ]); 35 | 36 | this.state = { 37 | tags: {}, 38 | path: pathBuf, 39 | timestamp: new Date() 40 | }; 41 | } 42 | 43 | /** 44 | * Fetches the Number of Tags 45 | * 46 | * @readonly 47 | * @returns Number of tags 48 | */ 49 | get length(): number { 50 | return Object.keys(this.state.tags).length; 51 | } 52 | // endregion 53 | 54 | /** 55 | * Adds Tag to Group 56 | * 57 | * @param tag - Tag to Add to Group 58 | */ 59 | add(tag: Tag): void { 60 | if (!this.state.tags[tag.instance_id]) this.state.tags[tag.instance_id] = tag; 61 | } 62 | 63 | /** 64 | * Removes Tag from Group 65 | * 66 | * @param tag - Tag to be Removed from Group 67 | */ 68 | remove(tag: Tag) { 69 | if (this.state.tags[tag.instance_id]) delete this.state.tags[tag.instance_id]; 70 | } 71 | 72 | /** 73 | * Iterable, Allows user to Iterate of each Tag in Group 74 | * 75 | * @param callback - Accepts Tag Class 76 | */ 77 | forEach(callback: (tag: Tag) => {}) { 78 | for (let key of Object.keys(this.state.tags)) callback(this.state.tags[key]); 79 | } 80 | 81 | /** 82 | * Generates Array of Messages to Compile into a Multiple 83 | * Service Request 84 | * 85 | * @returns Array of Read Tag Message Services 86 | */ 87 | generateReadMessageRequests(): readMessageRequests[] { 88 | const { tags } = this.state; 89 | 90 | // Initialize Variables 91 | let messages = []; 92 | let msgArr = []; 93 | let tagIds = []; 94 | let messageLength = 0; 95 | 96 | // Loop Over Tags in List 97 | for (let key of Object.keys(tags)) { 98 | const tag = tags[key]; 99 | 100 | // Build Current Message 101 | let msg = tag.generateReadMessageRequest(); 102 | 103 | messageLength += msg.length + 2; 104 | 105 | tagIds.push(tag.instance_id); 106 | msgArr.push(msg); 107 | 108 | // If Current Message Length is > 350 Bytes then Assemble Message and Move to Next Message 109 | if (messageLength >= 300) { 110 | let buf = Buffer.alloc(2 + 2 * msgArr.length); 111 | buf.writeUInt16LE(msgArr.length, 0); 112 | 113 | let ptr = 2; 114 | let offset = buf.length; 115 | 116 | for (let i = 0; i < msgArr.length; i++) { 117 | buf.writeUInt16LE(offset, ptr); 118 | ptr += 2; 119 | offset += msgArr[i].length; 120 | } 121 | 122 | buf = Buffer.concat([buf, ...msgArr]); 123 | buf = MessageRouter.build(MULTIPLE_SERVICE_PACKET, this.state.path, buf); 124 | 125 | messages.push({ data: buf, tag_ids: tagIds }); 126 | messageLength = 0; 127 | msgArr = []; 128 | tagIds = []; 129 | } 130 | } 131 | 132 | // Assemble and Push Last Message 133 | if (msgArr.length > 0) { 134 | let buf = Buffer.alloc(2 + 2 * msgArr.length); 135 | buf.writeUInt16LE(msgArr.length, 0); 136 | 137 | let ptr = 2; 138 | let offset = buf.length; 139 | 140 | for (let i = 0; i < msgArr.length; i++) { 141 | buf.writeUInt16LE(offset, ptr); 142 | ptr += 2; 143 | offset += msgArr[i].length; 144 | } 145 | 146 | buf = Buffer.concat([buf, ...msgArr]); 147 | buf = MessageRouter.build(MULTIPLE_SERVICE_PACKET, this.state.path, buf); 148 | 149 | messages.push({ data: buf, tag_ids: tagIds }); 150 | } 151 | 152 | return messages; 153 | } 154 | 155 | /** 156 | * Parse Incoming Multi Service Request Messages 157 | * 158 | * @param responses - response from controller 159 | * @param ids - Tag ids 160 | 161 | */ 162 | parseReadMessageResponses(responses: any[], ids: string[]) { 163 | for (let i = 0; i < ids.length; i++) { 164 | if(responses[i].generalStatusCode === 0) 165 | this.state.tags[ids[i]].parseReadMessageResponse(responses[i].data); 166 | if(responses[i].generalStatusCode === 4) 167 | this.state.tags[ids[i]].unknownTag(); 168 | } 169 | } 170 | 171 | /** 172 | * Generates Array of Messages to Compile into a Multiple 173 | * Service Request 174 | * 175 | * @returns Array of Write Tag Message Services 176 | */ 177 | generateWriteMessageRequests(): writeTagMessageRequests[] { 178 | const { tags } = this.state; 179 | 180 | // Initialize Variables 181 | let messages = []; 182 | let msgArr = []; 183 | let tagIds = []; 184 | let messageLength = 0; 185 | 186 | // Loop Over Tags in List 187 | for (let key of Object.keys(tags)) { 188 | const tag = tags[key]; 189 | 190 | if (tag.value !== null && tag.type === "STRUCT") 191 | tag.writeObjToValue(); 192 | 193 | if (tag.value !== null && !equals(tag.state.tag.value, tag.controller_value)) { 194 | // Build Current Message 195 | let msg = tag.generateWriteMessageRequest(); 196 | 197 | if (tag.type !== "STRUCT") { 198 | messageLength += msg.length + 2; 199 | 200 | tagIds.push(tag.instance_id); 201 | msgArr.push(msg); 202 | 203 | // If Current Message Length is > 350 Bytes then Assemble Message and Move to Next Message 204 | if (messageLength >= 350) { 205 | let buf = Buffer.alloc(2 + 2 * msgArr.length); 206 | buf.writeUInt16LE(msgArr.length, 0); 207 | 208 | let ptr = 2; 209 | let offset = buf.length; 210 | 211 | for (let i = 0; i < msgArr.length; i++) { 212 | buf.writeUInt16LE(offset, ptr); 213 | ptr += 2; 214 | offset += msgArr[i].length; 215 | } 216 | 217 | buf = Buffer.concat([buf, ...msgArr]); 218 | buf = MessageRouter.build(MULTIPLE_SERVICE_PACKET, this.state.path, buf); 219 | 220 | messages.push({ data: buf, tag: null, tag_ids: tagIds }); 221 | messageLength = 0; 222 | msgArr = []; 223 | tagIds = []; 224 | } 225 | } else { 226 | messages.push({data: null, tag: tag, tagIds: null}); // Single tag pushed to indicate need to write single STRUCT tag 227 | } 228 | } 229 | } 230 | 231 | // Assemble and Push Last Message 232 | if (msgArr.length > 0) { 233 | let buf = Buffer.alloc(2 + 2 * msgArr.length); 234 | buf.writeUInt16LE(msgArr.length, 0); 235 | 236 | let ptr = 2; 237 | let offset = buf.length; 238 | 239 | for (let i = 0; i < msgArr.length; i++) { 240 | buf.writeUInt16LE(offset, ptr); 241 | ptr += 2; 242 | offset += msgArr[i].length; 243 | } 244 | 245 | buf = Buffer.concat([buf, ...msgArr]); 246 | buf = MessageRouter.build(MULTIPLE_SERVICE_PACKET, this.state.path, buf); 247 | 248 | messages.push({ data: buf, tag: null, tag_ids: tagIds }); 249 | } 250 | 251 | return messages; 252 | } 253 | 254 | /** 255 | * Parse Incoming Multi Service Request Messages 256 | * 257 | * @param ids 258 | */ 259 | parseWriteMessageRequests(ids: string[]) { 260 | for (let id of ids) { 261 | this.state.tags[id].unstageWriteRequest(); 262 | } 263 | } 264 | // endregion 265 | 266 | // region Private Methods 267 | 268 | // endregion 269 | 270 | // region Event Handlers 271 | 272 | // endregion 273 | } 274 | 275 | export default TagGroup; 276 | -------------------------------------------------------------------------------- /src/tag-group/tag-group.spec.js: -------------------------------------------------------------------------------- 1 | const TagGroup = require("./index"); 2 | const Tag = require("../tag"); 3 | const { Types } = require("../enip/cip/data-types"); 4 | 5 | describe("Tag Class", () => { 6 | describe("Generate Read Requests Method", () => { 7 | it("Generates Appropriate Output", () => { 8 | const tag1 = new Tag("helloTag1", "prog", Types.DINT); 9 | const tag2 = new Tag("helloTag2", "prog", Types.DINT); 10 | const tag3 = new Tag("helloTag3", "prog", Types.DINT); 11 | const tag4 = new Tag("helloTag4", "prog", Types.DINT); 12 | const tag5 = new Tag("helloTag5", "prog", Types.DINT); 13 | 14 | const group = new TagGroup(); 15 | 16 | group.add(tag1); 17 | group.add(tag2); 18 | group.add(tag3); 19 | group.add(tag4); 20 | group.add(tag5); 21 | 22 | expect(group.generateReadMessageRequests()).toMatchSnapshot(); 23 | }); 24 | }); 25 | 26 | describe("Generate Write Requests Method", () => { 27 | it("Generates Appropriate Output", () => { 28 | const tag1 = new Tag("helloTag1", "prog", Types.DINT); 29 | const tag2 = new Tag("helloTag2", "prog", Types.DINT); 30 | const tag3 = new Tag("helloTag3", "prog", Types.DINT); 31 | const tag4 = new Tag("helloTag4", "prog", Types.DINT); 32 | const tag5 = new Tag("helloTag5", "prog", Types.DINT); 33 | 34 | const group = new TagGroup(); 35 | 36 | group.add(tag1); 37 | group.add(tag2); 38 | group.add(tag3); 39 | group.add(tag4); 40 | group.add(tag5); 41 | 42 | expect(group.generateWriteMessageRequests()).toMatchSnapshot(); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/tag-list/__snapshots__/tag-list.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Tag List Generate List Message Requests Method Generates Appropriate Output Instance 0 1`] = ` 4 | Object { 5 | "data": Array [ 6 | 85, 7 | 3, 8 | 32, 9 | 107, 10 | 37, 11 | 0, 12 | 0, 13 | 0, 14 | 2, 15 | 0, 16 | 1, 17 | 0, 18 | 2, 19 | 0, 20 | ], 21 | "type": "Buffer", 22 | } 23 | `; 24 | 25 | exports[`Tag List Get Program Names Generates Appropriate Output 1`] = ` 26 | Array [ 27 | "MainProgram", 28 | "MainProgram2", 29 | ] 30 | `; 31 | 32 | exports[`Tag List Parse Tag List Response Message Generates Appropriate Output 1`] = ` 33 | Array [ 34 | Object { 35 | "id": 3815, 36 | "name": "HowLongCanYouMakeAnIntegerName", 37 | "program": undefined, 38 | "type": Object { 39 | "arrayDims": 0, 40 | "code": 196, 41 | "reserved": false, 42 | "sintPos": null, 43 | "structure": false, 44 | "typeName": null, 45 | }, 46 | }, 47 | Object { 48 | "id": 6353, 49 | "name": "Timer2", 50 | "program": undefined, 51 | "type": Object { 52 | "arrayDims": 0, 53 | "code": 3971, 54 | "reserved": false, 55 | "sintPos": null, 56 | "structure": true, 57 | "typeName": null, 58 | }, 59 | }, 60 | Object { 61 | "id": 7162, 62 | "name": "Map:LocalENB", 63 | "program": undefined, 64 | "type": Object { 65 | "arrayDims": 0, 66 | "code": 105, 67 | "reserved": true, 68 | "sintPos": null, 69 | "structure": false, 70 | "typeName": null, 71 | }, 72 | }, 73 | Object { 74 | "id": 8255, 75 | "name": "Integer6", 76 | "program": undefined, 77 | "type": Object { 78 | "arrayDims": 0, 79 | "code": 196, 80 | "reserved": false, 81 | "sintPos": null, 82 | "structure": false, 83 | "typeName": null, 84 | }, 85 | }, 86 | Object { 87 | "id": 9248, 88 | "name": "Map:Controller", 89 | "program": undefined, 90 | "type": Object { 91 | "arrayDims": 0, 92 | "code": 105, 93 | "reserved": true, 94 | "sintPos": null, 95 | "structure": false, 96 | "typeName": null, 97 | }, 98 | }, 99 | Object { 100 | "id": 9602, 101 | "name": "AnalogOne", 102 | "program": undefined, 103 | "type": Object { 104 | "arrayDims": 0, 105 | "code": 202, 106 | "reserved": false, 107 | "sintPos": null, 108 | "structure": false, 109 | "typeName": null, 110 | }, 111 | }, 112 | Object { 113 | "id": 11241, 114 | "name": "Integer3", 115 | "program": undefined, 116 | "type": Object { 117 | "arrayDims": 0, 118 | "code": 196, 119 | "reserved": false, 120 | "sintPos": null, 121 | "structure": false, 122 | "typeName": null, 123 | }, 124 | }, 125 | Object { 126 | "id": 12648, 127 | "name": "BitOne", 128 | "program": undefined, 129 | "type": Object { 130 | "arrayDims": 0, 131 | "code": 193, 132 | "reserved": false, 133 | "sintPos": 0, 134 | "structure": false, 135 | "typeName": null, 136 | }, 137 | }, 138 | Object { 139 | "id": 13748, 140 | "name": "LongIntegerName1", 141 | "program": undefined, 142 | "type": Object { 143 | "arrayDims": 0, 144 | "code": 195, 145 | "reserved": false, 146 | "sintPos": null, 147 | "structure": false, 148 | "typeName": null, 149 | }, 150 | }, 151 | Object { 152 | "id": 14397, 153 | "name": "Integer5", 154 | "program": undefined, 155 | "type": Object { 156 | "arrayDims": 0, 157 | "code": 196, 158 | "reserved": false, 159 | "sintPos": null, 160 | "structure": false, 161 | "typeName": null, 162 | }, 163 | }, 164 | Object { 165 | "id": 16174, 166 | "name": "ThisIntegerNameIsEvenLongerThanTheFirst1", 167 | "program": undefined, 168 | "type": Object { 169 | "arrayDims": 0, 170 | "code": 196, 171 | "reserved": false, 172 | "sintPos": null, 173 | "structure": false, 174 | "typeName": null, 175 | }, 176 | }, 177 | Object { 178 | "id": 16805, 179 | "name": "LongStringName1", 180 | "program": undefined, 181 | "type": Object { 182 | "arrayDims": 0, 183 | "code": 4046, 184 | "reserved": false, 185 | "sintPos": null, 186 | "structure": true, 187 | "typeName": null, 188 | }, 189 | }, 190 | Object { 191 | "id": 19059, 192 | "name": "String2", 193 | "program": undefined, 194 | "type": Object { 195 | "arrayDims": 0, 196 | "code": 4046, 197 | "reserved": false, 198 | "sintPos": null, 199 | "structure": true, 200 | "typeName": null, 201 | }, 202 | }, 203 | Object { 204 | "id": 20716, 205 | "name": "TheInteger", 206 | "program": undefined, 207 | "type": Object { 208 | "arrayDims": 0, 209 | "code": 195, 210 | "reserved": false, 211 | "sintPos": null, 212 | "structure": false, 213 | "typeName": null, 214 | }, 215 | }, 216 | Object { 217 | "id": 22820, 218 | "name": "Program:MainProgram", 219 | "program": undefined, 220 | "type": Object { 221 | "arrayDims": 0, 222 | "code": 104, 223 | "reserved": true, 224 | "sintPos": null, 225 | "structure": false, 226 | "typeName": null, 227 | }, 228 | }, 229 | Object { 230 | "id": 26297, 231 | "name": "ThisIsAnotherMaximumLengthTagName1111111", 232 | "program": undefined, 233 | "type": Object { 234 | "arrayDims": 0, 235 | "code": 196, 236 | "reserved": false, 237 | "sintPos": null, 238 | "structure": false, 239 | "typeName": null, 240 | }, 241 | }, 242 | Object { 243 | "id": 31819, 244 | "name": "Integer4", 245 | "program": undefined, 246 | "type": Object { 247 | "arrayDims": 0, 248 | "code": 196, 249 | "reserved": false, 250 | "sintPos": null, 251 | "structure": false, 252 | "typeName": null, 253 | }, 254 | }, 255 | Object { 256 | "id": 33528, 257 | "name": "Program:MainProgram2", 258 | "program": undefined, 259 | "type": Object { 260 | "arrayDims": 0, 261 | "code": 104, 262 | "reserved": true, 263 | "sintPos": null, 264 | "structure": false, 265 | "typeName": null, 266 | }, 267 | }, 268 | Object { 269 | "id": 35479, 270 | "name": "BitTwo", 271 | "program": undefined, 272 | "type": Object { 273 | "arrayDims": 0, 274 | "code": 193, 275 | "reserved": false, 276 | "sintPos": 0, 277 | "structure": false, 278 | "typeName": null, 279 | }, 280 | }, 281 | Object { 282 | "id": 39856, 283 | "name": "String1", 284 | "program": undefined, 285 | "type": Object { 286 | "arrayDims": 0, 287 | "code": 4046, 288 | "reserved": false, 289 | "sintPos": null, 290 | "structure": true, 291 | "typeName": null, 292 | }, 293 | }, 294 | Object { 295 | "id": 46090, 296 | "name": "String3", 297 | "program": undefined, 298 | "type": Object { 299 | "arrayDims": 0, 300 | "code": 4046, 301 | "reserved": false, 302 | "sintPos": null, 303 | "structure": true, 304 | "typeName": null, 305 | }, 306 | }, 307 | Object { 308 | "id": 46883, 309 | "name": "Map:Local", 310 | "program": undefined, 311 | "type": Object { 312 | "arrayDims": 0, 313 | "code": 105, 314 | "reserved": true, 315 | "sintPos": null, 316 | "structure": false, 317 | "typeName": null, 318 | }, 319 | }, 320 | ] 321 | `; 322 | -------------------------------------------------------------------------------- /src/tag-list/index.ts: -------------------------------------------------------------------------------- 1 | import { CIP } from "../enip"; 2 | import { Template } from "../structure"; 3 | import type Controller from '../controller' 4 | 5 | type tagListTagType = { 6 | code: number, 7 | sintPos: number, 8 | typeName: string, 9 | structure: boolean, 10 | arrayDims: number, 11 | reserved: boolean 12 | } 13 | 14 | type tagListTag = { 15 | id: number, 16 | name: string, 17 | type: tagListTagType, 18 | program: string 19 | } 20 | 21 | type tagListTemplates = { 22 | [index: string]: Template; 23 | } 24 | 25 | class TagList { 26 | tags: tagListTag[]; 27 | templates: tagListTemplates; 28 | 29 | /** 30 | * TagList Class for retrieving a list of all tags on a PLC 31 | */ 32 | constructor () { 33 | this.tags = []; 34 | this.templates = {}; 35 | } 36 | 37 | /** 38 | * Generates the CIP message to request a list of tags 39 | * 40 | * @param instanceID- instance id to start getting a list of object tags 41 | * @param program - (optional) name of the program to search 42 | * @returns message to be sent to PLC 43 | */ 44 | _generateListMessageRequest(instanceID: number = 0, program?: string) { 45 | 46 | const { LOGICAL, DATA } = CIP.EPATH.segments; 47 | 48 | let pathArray = []; 49 | 50 | if (program) { pathArray.push(DATA.build("Program:" + program)); } 51 | 52 | pathArray.push(LOGICAL.build(LOGICAL.types.ClassID, 0x6b)); //Symbol Class ID 53 | 54 | if (instanceID === 0) { 55 | pathArray.push(Buffer.from([0x25, 0x00, 0x00, 0x00])); //Start at Instance 0; 56 | } else { 57 | pathArray.push( LOGICAL.build(LOGICAL.types.InstanceID, instanceID)); 58 | } 59 | 60 | const requestData = Buffer.from([0x02, 0x00, 0x01, 0x00, 0x02, 0x00]); // 2 Attributes - Attribute 1 and Attribute 2 61 | const request = CIP.MessageRouter.build( CIP.MessageRouter.services.GET_INSTANCE_ATTRIBUTE_LIST, Buffer.concat(pathArray), requestData); 62 | 63 | return request; 64 | } 65 | 66 | /** 67 | * Parse CIP response into tag data 68 | * 69 | * @param data - Buffer data to parse 70 | * @param program - (optional) name of the program tag is from (optional) 71 | * @returns Last instance id parsed 72 | */ 73 | _parseAttributeListResponse(data: Buffer, program?: string): number { 74 | let instanceID: number; 75 | let pointer = 0; 76 | 77 | while (pointer < data.length) { 78 | instanceID = data.readUInt32LE(pointer); //Parse instance ID 79 | pointer += 4; 80 | 81 | const nameLength = data.readUInt16LE(pointer); // Parse tag Name Length 82 | pointer += 2; 83 | 84 | const tagName = data.slice(pointer, pointer + nameLength).toString(); // Parse tag Name 85 | pointer += nameLength; 86 | 87 | const tagType = data.readUInt16LE(pointer); // Parse tag type 88 | pointer += 2; 89 | 90 | const lastTag = this.tags.findIndex(tag => { 91 | return (tag.id === instanceID && tag.program === program); 92 | }); 93 | 94 | const tagObj = { 95 | id: instanceID, 96 | name: tagName, 97 | type: this._parseTagType(tagType), 98 | program: program 99 | }; 100 | 101 | if (lastTag !== -1) { 102 | this.tags[lastTag] = tagObj; 103 | } else { 104 | this.tags.push(tagObj); 105 | } 106 | } 107 | return instanceID; // Return last instance id 108 | } 109 | 110 | /** 111 | * Get and store tag type name from code for all tags 112 | */ 113 | _getTagTypeNames () { 114 | for (const tag of this.tags) { 115 | tag.type.typeName = CIP.DataTypes.getTypeCodeString(tag.type.code); 116 | if(!tag.type.typeName && this.templates[tag.type.code]) { 117 | tag.type.typeName = this.templates[tag.type.code]._name; 118 | } 119 | } 120 | } 121 | 122 | /** 123 | * 124 | * @param tagType - tag type numerical value 125 | * @returns tag list type object 126 | */ 127 | _parseTagType(tagType: number): tagListTagType { 128 | 129 | let typeCode = null; 130 | let sintPos = null; 131 | if ((tagType & 0x00ff) === 0xc1) { 132 | typeCode = 0x00c1; 133 | sintPos = (tagType & 0x0f00) >> 8; 134 | } else { 135 | typeCode = tagType & 0x0fff; 136 | } 137 | 138 | const structure = !!(tagType & 0x8000); 139 | const reserved = !!(tagType & 0x1000); 140 | const arrayDims = (tagType & 0x6000) >> 13; 141 | 142 | return { 143 | code: typeCode, 144 | sintPos: sintPos, 145 | typeName: null, 146 | structure: structure, 147 | arrayDims: arrayDims, 148 | reserved: reserved 149 | }; 150 | } 151 | 152 | /** 153 | * Parse CIP response into tag data 154 | * 155 | * @param PLC - Controller to get tags from 156 | * @param program - (optional) name of the program tag is from (optional) 157 | * @returns Promise resolves taglist array 158 | */ 159 | getControllerTags(PLC: Controller, program: string = null): Promise { 160 | return new Promise( (resolve, reject) => { 161 | 162 | const getListAt = (instanceID = 0) => { // Create function that we can call back in recursion 163 | 164 | const cipData = this._generateListMessageRequest(instanceID, program); // Create CIP Request 165 | 166 | PLC.write_cip(cipData); // Write CIP data to PLC 167 | 168 | // Response Handler 169 | PLC.on("Get Instance Attribute List", async (err, data) => { 170 | 171 | PLC.removeAllListeners("Get Instance Attribute List"); // Make sure we don't handle future calls in this instance 172 | 173 | // Check For actual error (Skip too much data) 174 | if (err && err.generalStatusCode !== 6) { 175 | 176 | const errData = { 177 | func: "getControllerTags", 178 | instanceID: instanceID, 179 | program: program, 180 | cipReq: cipData, 181 | }; 182 | 183 | if (Array.isArray(err.ext)) { 184 | err.ext.push(errData); 185 | } else { 186 | err.ext = [errData]; 187 | } 188 | 189 | reject(err); 190 | return; 191 | } 192 | 193 | // If too much data, call function again starting at last instance + 1 194 | if (err && err.generalStatusCode === 6) { 195 | 196 | const lastInstance = this._parseAttributeListResponse(data, program); // Parse response data 197 | getListAt(lastInstance + 1); 198 | 199 | } else { 200 | 201 | this._parseAttributeListResponse(data, program); // pArse response data 202 | 203 | // If program is not defined fetch tags for existing programs 204 | if (!program) { 205 | for (let prg of this.programs) { 206 | await this.getControllerTags(PLC, prg).catch(reject); 207 | } 208 | 209 | await this._getAllTemplates(PLC).catch(reject); // Get All templates for structures 210 | 211 | } 212 | this._getTagTypeNames(); 213 | resolve(this.tags); 214 | } 215 | }); 216 | }; 217 | 218 | getListAt(0); // Call first time 219 | 220 | }); 221 | } 222 | 223 | /** 224 | * Gets Controller Program Names 225 | * 226 | * @returns array of program names 227 | */ 228 | get programs(): string[] { 229 | return this.tags.filter(tag => tag.name.slice(0, 8) === "Program:").map(tag => { 230 | return tag.name.slice(8, tag.name.length); 231 | }); 232 | } 233 | 234 | /** 235 | * Gets tag info from tag name and program name 236 | * 237 | * @param tagName 238 | * @param program 239 | * @returns 240 | */ 241 | getTag(tagName: string, program: string = null): tagListTag { 242 | return this.tags.find(tag => tag.name === tagName && tag.program === program); 243 | } 244 | 245 | /** 246 | * 247 | * @param tagName 248 | * @param program 249 | * @returns tag template or null if none 250 | */ 251 | getTemplateByTag(tagName: string, program: string = null): Template { 252 | const tagArray = tagName.split("."); 253 | const tag = this.tags.find(tag => tag.name.toLowerCase().replace(/\[.*/, "") === tagArray[0].toLowerCase().replace(/\[.*/, "") && String(tag.program).toLowerCase() === String(program).toLowerCase()); 254 | 255 | if (tag) { 256 | let finalTemplate = this.templates[tag.type.code]; 257 | let tagArrayPointer = 1; 258 | while (finalTemplate && tagArrayPointer < tagArray.length) { 259 | const memberName = String(tagArray[tagArrayPointer]).replace(/\[.*/, ""); //removes array indication 260 | const nextTag = finalTemplate._members.find(member => member.name === memberName); 261 | if(nextTag) { 262 | finalTemplate = this.templates[nextTag.type.code]; 263 | } else { 264 | finalTemplate = null; 265 | } 266 | tagArrayPointer++; 267 | } 268 | return finalTemplate; 269 | } else { 270 | return null; 271 | } 272 | 273 | } 274 | 275 | /** 276 | * Get all templates from a PLC 277 | * 278 | * @param PLC 279 | * @returns Promise that resolves after all templates are retrieved from PLC 280 | */ 281 | _getAllTemplates (PLC: Controller): Promise { 282 | return new Promise (async (resolve, reject) => { 283 | for (const tag of this.tags) { 284 | if (tag.type.structure && !this.templates[tag.type.code]) { 285 | 286 | try { 287 | const template = new Template(); 288 | await template.getTemplate(PLC, tag.type.code); 289 | this.templates[tag.type.code] = template; 290 | } catch (e) { /* ignore template fetching errors */ } 291 | 292 | } 293 | } 294 | 295 | let foundTemplate = true; 296 | 297 | while(foundTemplate) { 298 | foundTemplate = false; 299 | 300 | for (const temp in this.templates) { 301 | for (const member of this.templates[temp]._members) { 302 | if (member.type.structure && !this.templates[member.type.code]) { 303 | foundTemplate = true; 304 | const template = new Template(); 305 | await template.getTemplate(PLC, member.type.code).catch(reject); 306 | this.templates[member.type.code] = template; 307 | } 308 | } 309 | } 310 | } 311 | 312 | resolve(); 313 | }); 314 | } 315 | 316 | } 317 | 318 | export default TagList; 319 | export {tagListTag, tagListTemplates, tagListTagType} -------------------------------------------------------------------------------- /src/tag-list/tag-list.spec.js: -------------------------------------------------------------------------------- 1 | const TagList = require("./index"); 2 | 3 | const responseData = Buffer.from("e70e00001e00486f774c6f6e6743616e596f754d616b65416e496e74656765724e616d65c400d1180000060054696d657232838ffa1b00000c004d61703a4c6f63616c454e4269103f2000000800496e746567657236c400202400000e004d61703a436f6e74726f6c6c65726910822500000900416e616c6f674f6e65ca00e92b00000800496e746567657233c4006831000006004269744f6e65c100b435000010004c6f6e67496e74656765724e616d6531c3003d3800000800496e746567657235c4002e3f0000280054686973496e74656765724e616d6549734576656e4c6f6e6765725468616e546865466972737431c400a54100000f004c6f6e67537472696e674e616d6531ce8f734a00000700537472696e6732ce8fec5000000a00546865496e7465676572c30024590000130050726f6772616d3a4d61696e50726f6772616d6810b96600002800546869734973416e6f746865724d6178696d756d4c656e6774685461674e616d6531313131313131c4004b7c00000800496e746567657234c400f8820000140050726f6772616d3a4d61696e50726f6772616d326810978a0000060042697454776fc100b09b00000700537472696e6731ce8f0ab400000700537472696e6733ce8f23b7000009004d61703a4c6f63616c6910", "hex"); 4 | 5 | describe("Tag List", () => { 6 | describe("Generate List Message Requests Method", () => { 7 | it("Generates Appropriate Output Instance 0", () => { 8 | const tagList = new TagList(); 9 | 10 | expect(tagList._generateListMessageRequest(0)).toMatchSnapshot(); 11 | }); 12 | }); 13 | 14 | describe("Parse Tag List Response Message", () => { 15 | it("Generates Appropriate Output", () => { 16 | const tagList = new TagList(); 17 | 18 | tagList._parseAttributeListResponse(responseData); 19 | 20 | expect(tagList.tags).toMatchSnapshot(); 21 | }); 22 | }); 23 | 24 | describe("Get Program Names", () => { 25 | it("Generates Appropriate Output", () => { 26 | const tagList = new TagList(); 27 | 28 | tagList._parseAttributeListResponse(responseData); 29 | 30 | expect(tagList.programs).toMatchSnapshot(); 31 | }); 32 | }); 33 | }); -------------------------------------------------------------------------------- /src/tag/__snapshots__/tag.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Tag Class Read Message Generator Method Generates Appropriate Buffer 1`] = ` 4 | Object { 5 | "data": Array [ 6 | 76, 7 | 3, 8 | 145, 9 | 3, 10 | 116, 11 | 97, 12 | 103, 13 | 0, 14 | 1, 15 | 0, 16 | ], 17 | "type": "Buffer", 18 | } 19 | `; 20 | 21 | exports[`Tag Class Read Message Generator Method Generates Appropriate Buffer 2`] = ` 22 | Object { 23 | "data": Array [ 24 | 76, 25 | 3, 26 | 145, 27 | 3, 28 | 116, 29 | 97, 30 | 103, 31 | 0, 32 | 1, 33 | 0, 34 | ], 35 | "type": "Buffer", 36 | } 37 | `; 38 | 39 | exports[`Tag Class Read Message Generator Method Generates Appropriate Buffer 3`] = ` 40 | Object { 41 | "data": Array [ 42 | 76, 43 | 3, 44 | 145, 45 | 3, 46 | 116, 47 | 97, 48 | 103, 49 | 0, 50 | 1, 51 | 0, 52 | ], 53 | "type": "Buffer", 54 | } 55 | `; 56 | 57 | exports[`Tag Class Read Message Generator Method Generates Appropriate Buffer 4`] = ` 58 | Object { 59 | "data": Array [ 60 | 76, 61 | 3, 62 | 145, 63 | 3, 64 | 116, 65 | 97, 66 | 103, 67 | 0, 68 | 1, 69 | 0, 70 | ], 71 | "type": "Buffer", 72 | } 73 | `; 74 | 75 | exports[`Tag Class Read Message Generator Method Generates Appropriate Buffer 5`] = ` 76 | Object { 77 | "data": Array [ 78 | 76, 79 | 3, 80 | 145, 81 | 3, 82 | 116, 83 | 97, 84 | 103, 85 | 0, 86 | 1, 87 | 0, 88 | ], 89 | "type": "Buffer", 90 | } 91 | `; 92 | 93 | exports[`Tag Class Read Message Generator Method Generates Appropriate Buffer 6`] = ` 94 | Object { 95 | "data": Array [ 96 | 76, 97 | 3, 98 | 145, 99 | 3, 100 | 116, 101 | 97, 102 | 103, 103 | 0, 104 | 1, 105 | 0, 106 | ], 107 | "type": "Buffer", 108 | } 109 | `; 110 | 111 | exports[`Tag Class Read Message Generator Method Generates Appropriate Buffer 7`] = ` 112 | Object { 113 | "data": Array [ 114 | 76, 115 | 4, 116 | 145, 117 | 3, 118 | 116, 119 | 97, 120 | 103, 121 | 0, 122 | 40, 123 | 0, 124 | 1, 125 | 0, 126 | ], 127 | "type": "Buffer", 128 | } 129 | `; 130 | 131 | exports[`Tag Class Read Message Generator Method Generates Appropriate Buffer 8`] = ` 132 | Object { 133 | "data": Array [ 134 | 76, 135 | 5, 136 | 145, 137 | 3, 138 | 116, 139 | 97, 140 | 103, 141 | 0, 142 | 40, 143 | 0, 144 | 40, 145 | 0, 146 | 1, 147 | 0, 148 | ], 149 | "type": "Buffer", 150 | } 151 | `; 152 | 153 | exports[`Tag Class Read Message Generator Method Generates Appropriate Buffer 9`] = ` 154 | Object { 155 | "data": Array [ 156 | 76, 157 | 6, 158 | 145, 159 | 3, 160 | 116, 161 | 97, 162 | 103, 163 | 0, 164 | 40, 165 | 0, 166 | 40, 167 | 0, 168 | 40, 169 | 0, 170 | 1, 171 | 0, 172 | ], 173 | "type": "Buffer", 174 | } 175 | `; 176 | 177 | exports[`Tag Class Write Message Generator Method Generates Appropriate Buffer 1`] = ` 178 | Object { 179 | "data": Array [ 180 | 77, 181 | 3, 182 | 145, 183 | 3, 184 | 116, 185 | 97, 186 | 103, 187 | 0, 188 | 196, 189 | 0, 190 | 1, 191 | 0, 192 | 100, 193 | 0, 194 | 0, 195 | 0, 196 | ], 197 | "type": "Buffer", 198 | } 199 | `; 200 | 201 | exports[`Tag Class Write Message Generator Method Generates Appropriate Buffer 2`] = ` 202 | Object { 203 | "data": Array [ 204 | 77, 205 | 3, 206 | 145, 207 | 3, 208 | 116, 209 | 97, 210 | 103, 211 | 0, 212 | 193, 213 | 0, 214 | 1, 215 | 0, 216 | 1, 217 | ], 218 | "type": "Buffer", 219 | } 220 | `; 221 | 222 | exports[`Tag Class Write Message Generator Method Generates Appropriate Buffer 3`] = ` 223 | Object { 224 | "data": Array [ 225 | 77, 226 | 3, 227 | 145, 228 | 3, 229 | 116, 230 | 97, 231 | 103, 232 | 0, 233 | 202, 234 | 0, 235 | 1, 236 | 0, 237 | 93, 238 | 126, 239 | 0, 240 | 66, 241 | ], 242 | "type": "Buffer", 243 | } 244 | `; 245 | 246 | exports[`Tag Class Write Message Generator Method Generates Appropriate Buffer 4`] = ` 247 | Object { 248 | "data": Array [ 249 | 77, 250 | 3, 251 | 145, 252 | 3, 253 | 116, 254 | 97, 255 | 103, 256 | 0, 257 | 194, 258 | 0, 259 | 1, 260 | 0, 261 | 4, 262 | ], 263 | "type": "Buffer", 264 | } 265 | `; 266 | 267 | exports[`Tag Class Write Message Generator Method Generates Appropriate Buffer 5`] = ` 268 | Object { 269 | "data": Array [ 270 | 77, 271 | 3, 272 | 145, 273 | 3, 274 | 116, 275 | 97, 276 | 103, 277 | 0, 278 | 195, 279 | 0, 280 | 1, 281 | 0, 282 | 246, 283 | 255, 284 | ], 285 | "type": "Buffer", 286 | } 287 | `; 288 | 289 | exports[`Tag Class Write Message Generator Method Generates Appropriate Buffer 6`] = ` 290 | Object { 291 | "data": Array [ 292 | 78, 293 | 3, 294 | 145, 295 | 3, 296 | 116, 297 | 97, 298 | 103, 299 | 0, 300 | 4, 301 | 0, 302 | 1, 303 | 0, 304 | 0, 305 | 0, 306 | 255, 307 | 255, 308 | 255, 309 | 255, 310 | ], 311 | "type": "Buffer", 312 | } 313 | `; 314 | 315 | exports[`Tag Class Write Message Generator Method Generates Appropriate Buffer 7`] = ` 316 | Object { 317 | "data": Array [ 318 | 77, 319 | 4, 320 | 145, 321 | 3, 322 | 116, 323 | 97, 324 | 103, 325 | 0, 326 | 40, 327 | 0, 328 | 196, 329 | 0, 330 | 1, 331 | 0, 332 | 99, 333 | 0, 334 | 0, 335 | 0, 336 | ], 337 | "type": "Buffer", 338 | } 339 | `; 340 | 341 | exports[`Tag Class Write Message Generator Method Generates Appropriate Buffer 8`] = ` 342 | Object { 343 | "data": Array [ 344 | 77, 345 | 5, 346 | 145, 347 | 3, 348 | 116, 349 | 97, 350 | 103, 351 | 0, 352 | 40, 353 | 0, 354 | 40, 355 | 0, 356 | 196, 357 | 0, 358 | 1, 359 | 0, 360 | 99, 361 | 0, 362 | 0, 363 | 0, 364 | ], 365 | "type": "Buffer", 366 | } 367 | `; 368 | 369 | exports[`Tag Class Write Message Generator Method Generates Appropriate Buffer 9`] = ` 370 | Object { 371 | "data": Array [ 372 | 77, 373 | 6, 374 | 145, 375 | 3, 376 | 116, 377 | 97, 378 | 103, 379 | 0, 380 | 40, 381 | 0, 382 | 40, 383 | 0, 384 | 40, 385 | 0, 386 | 196, 387 | 0, 388 | 1, 389 | 0, 390 | 99, 391 | 0, 392 | 0, 393 | 0, 394 | ], 395 | "type": "Buffer", 396 | } 397 | `; 398 | -------------------------------------------------------------------------------- /src/tag/tag.spec.js: -------------------------------------------------------------------------------- 1 | const Tag = require("./index"); 2 | const { Types } = require("../enip/cip/data-types"); 3 | 4 | describe("Tag Class", () => { 5 | describe("New Instance", () => { 6 | it("Throws Error on Invalid Inputs", () => { 7 | const fn = (tagname, prog, type = Types.UDINT) => { 8 | return () => new Tag(tagname, prog, type); 9 | }; 10 | 11 | expect(fn(1234)).toThrow(); 12 | expect(fn("hello")).not.toThrow(); 13 | expect(fn("someTag", "prog", 0x31)).toThrow(); 14 | expect(fn("someTag", "prog", Types.EPATH)).not.toThrow(); 15 | expect(fn("someTag", "prog", 0xc1)).not.toThrow(); 16 | expect(fn("tag[0].0", null, Types.BIT_STRING)).toThrow(); 17 | }); 18 | }); 19 | 20 | describe("Tagname Validator", () => { 21 | it("Accepts and Rejects Appropriate Inputs", () => { 22 | const fn = test => Tag.isValidTagname(test); 23 | 24 | expect(fn("_sometagname")).toBeTruthy(); 25 | expect(fn(12345)).toBeFalsy(); 26 | expect(fn(null)).toBeFalsy(); 27 | expect(fn(undefined)).toBeFalsy(); 28 | expect(fn(`hello${311}`)).toBeTruthy(); 29 | expect(fn("hello.how3")).toBeTruthy(); 30 | expect(fn("randy.julian.bubbles")).toBeTruthy(); 31 | expect(fn("a.b.c")).toBeTruthy(); 32 | expect(fn("1.1.1")).toBeFalsy(); 33 | expect(fn({ prop: "value" })).toBeFalsy(); 34 | expect(fn("fffffffffffffffffffffffffffffffffffffffff")).toBeFalsy(); 35 | expect(fn("ffffffffffffffffffffffffffffffffffffffff")).toBeTruthy(); 36 | expect(fn("4hello")).toBeFalsy(); 37 | expect(fn("someTagArray[12]")).toBeTruthy(); 38 | expect(fn("someTagArray[1a]")).toBeFalsy(); 39 | expect(fn("hello[f]")).toBeFalsy(); 40 | expect(fn("someOtherTag[0]a")).toBeFalsy(); 41 | expect(fn("tagname")).toBeTruthy(); 42 | expect(fn("tag_with_underscores45")).toBeTruthy(); 43 | expect(fn("someTagArray[0]")).toBeTruthy(); 44 | expect(fn("a")).toBeTruthy(); 45 | expect(fn("tagBitIndex.0")).toBeTruthy(); 46 | expect(fn("tagBitIndex.31")).toBeTruthy(); 47 | expect(fn("tagBitIndex.0a")).toBeFalsy(); 48 | expect(fn("tagBitIndex.-1")).toBeFalsy(); 49 | expect(fn("tagArray[0,0]")).toBeTruthy(); 50 | expect(fn("tagArray[0,0,0]")).toBeTruthy(); 51 | expect(fn("tagArray[-1]")).toBeFalsy(); 52 | expect(fn("tagArray[0,0,-1]")).toBeFalsy(); 53 | expect(fn("Program:program.tag")).toBeTruthy(); 54 | expect(fn("Program:noProgramArray[0].tag")).toBeFalsy(); 55 | expect(fn("notProgram:program.tag")).toBeFalsy(); 56 | expect(fn("Program::noDoubleColon.tag")).toBeFalsy(); 57 | expect(fn("Program:noExtraColon:tag")).toBeFalsy(); 58 | expect(fn("Program:program.tag.singleDimMemArrayOk[0]")).toBeTruthy(); 59 | expect(fn("Program:program.tag.noMultiDimMemArray[0,0]")).toBeFalsy(); 60 | expect( 61 | fn("Program:program.tag.memberArray[0]._0member[4]._another_1member.f1nal_member.5") 62 | ).toBeTruthy(); 63 | expect(fn("Program:9noNumberProgram.tag")).toBeFalsy(); 64 | expect(fn("tag.9noNumberMember")).toBeFalsy(); 65 | expect(fn("tag.noDouble__underscore1")).toBeFalsy(); 66 | expect(fn("tag.__noDoubleUnderscore2")).toBeFalsy(); 67 | expect(fn("tag.noEndInUnderscore_")).toBeFalsy(); 68 | expect(fn("tag._member_Length_Ok_And_ShouldPassAt40Char")).toBeTruthy(); 69 | expect(fn("tag._memberLengthTooLongAndShouldFailAt41Char")).toBeFalsy(); 70 | expect(fn("tag..noDoubleDelimitters")).toBeFalsy(); 71 | expect(fn("Local:1:I.Data")).toBeTruthy(); 72 | expect(fn("Local:1:I.Data.3")).toBeTruthy(); 73 | expect(fn("Remote_Rack:I.Data[1].5")).toBeTruthy(); 74 | expect(fn("Remote_Rack:O.Data[1].5")).toBeTruthy(); 75 | expect(fn("Remote_Rack:C.Data[1].5")).toBeTruthy(); 76 | expect(fn("Remote_Rack:1:I.0")).toBeTruthy(); 77 | }); 78 | }); 79 | 80 | describe("Read Message Generator Method", () => { 81 | it("Generates Appropriate Buffer", () => { 82 | const tag1 = new Tag("tag", null, Types.DINT); 83 | const tag2 = new Tag("tag", null, Types.BOOL); 84 | const tag3 = new Tag("tag", null, Types.REAL); 85 | const tag4 = new Tag("tag", null, Types.SINT); 86 | const tag5 = new Tag("tag", null, Types.INT); 87 | const tag6 = new Tag("tag.0", null, Types.DINT); // test bit index 88 | const tag7 = new Tag("tag[0]", null, Types.DINT); // test single dim array 89 | const tag8 = new Tag("tag[0,0]", null, Types.DINT); // test 2 dim array 90 | const tag9 = new Tag("tag[0,0,0]", null, Types.DINT); // test 3 dim array 91 | 92 | expect(tag1.generateReadMessageRequest()).toMatchSnapshot(); 93 | expect(tag2.generateReadMessageRequest()).toMatchSnapshot(); 94 | expect(tag3.generateReadMessageRequest()).toMatchSnapshot(); 95 | expect(tag4.generateReadMessageRequest()).toMatchSnapshot(); 96 | expect(tag5.generateReadMessageRequest()).toMatchSnapshot(); 97 | expect(tag6.generateReadMessageRequest()).toMatchSnapshot(); 98 | expect(tag7.generateReadMessageRequest()).toMatchSnapshot(); 99 | expect(tag8.generateReadMessageRequest()).toMatchSnapshot(); 100 | expect(tag9.generateReadMessageRequest()).toMatchSnapshot(); 101 | }); 102 | }); 103 | 104 | describe("Write Message Generator Method", () => { 105 | it("Generates Appropriate Buffer", () => { 106 | const tag1 = new Tag("tag", null, Types.DINT); 107 | const tag2 = new Tag("tag", null, Types.BOOL); 108 | const tag3 = new Tag("tag", null, Types.REAL); 109 | const tag4 = new Tag("tag", null, Types.SINT); 110 | const tag5 = new Tag("tag", null, Types.INT); 111 | const tag6 = new Tag("tag.0", null, Types.DINT); // test bit index 112 | const tag7 = new Tag("tag[0]", null, Types.DINT); // test single dim array 113 | const tag8 = new Tag("tag[0,0]", null, Types.DINT); // test 2 dim array 114 | const tag9 = new Tag("tag[0,0,0]", null, Types.DINT); // test 3 dim array 115 | 116 | expect(tag1.generateWriteMessageRequest(100)).toMatchSnapshot(); 117 | expect(tag2.generateWriteMessageRequest(true)).toMatchSnapshot(); 118 | expect(tag3.generateWriteMessageRequest(32.1234)).toMatchSnapshot(); 119 | expect(tag4.generateWriteMessageRequest(4)).toMatchSnapshot(); 120 | expect(tag5.generateWriteMessageRequest(-10)).toMatchSnapshot(); 121 | expect(tag6.generateWriteMessageRequest(true)).toMatchSnapshot(); 122 | expect(tag7.generateWriteMessageRequest(99)).toMatchSnapshot(); 123 | expect(tag8.generateWriteMessageRequest(99)).toMatchSnapshot(); 124 | expect(tag9.generateWriteMessageRequest(99)).toMatchSnapshot(); 125 | }); 126 | }); 127 | 128 | describe("keepAlive parameter", () => { 129 | it("should allow a number input", () => { 130 | const testTag = new Tag("testkeepalive", undefined, undefined, 10); 131 | expect(testTag).toBeInstanceOf(Tag); 132 | }); 133 | 134 | it("should throw an error on non-number types", () => { 135 | expect(() => { 136 | new Tag("testkeepalive", undefined, undefined, "apple"); 137 | }).toThrowError("Tag expected keepAlive of type instead got type "); 138 | }); 139 | 140 | it("should throw an error if keepAlive is less than 0", () => { 141 | expect(() => { 142 | new Tag("testkeepalive", undefined, undefined, -20); 143 | }).toThrowError("Tag expected keepAlive to be greater than 0, got -20"); 144 | }); 145 | }); 146 | 147 | describe("bitIndex parameter", () => { 148 | it("should be null if no bit index is in tag name", () => { 149 | const testTag = new Tag("tag"); 150 | expect(testTag.bitIndex).toEqual(null); 151 | }); 152 | 153 | it("should equal bit index", () => { 154 | const testTag = new Tag("tag.5"); 155 | expect(testTag.bitIndex).toEqual(5); 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /src/utilities/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wraps a Promise with a Timeout 3 | * 4 | * @param promise - Promise to add timeout to 5 | * @param ms - Timeout Length (ms) 6 | * @param error - Error to Emit if Timeout Occurs 7 | * @returns promise that rejects if not completed by timeout length 8 | */ 9 | const promiseTimeout = (promise: Promise, ms: number, error: Error | string = new Error("ASYNC Function Call Timed Out!!!")): Promise => { 10 | return new Promise((resolve, reject) => { 11 | setTimeout(() => reject(error), ms); 12 | promise.then(resolve).catch(reject); 13 | }); 14 | }; 15 | 16 | /** 17 | * Delays X ms 18 | * 19 | * @param ms - Delay Length (ms) 20 | * @returns Promise resolved after delay length 21 | */ 22 | const delay = (ms: number):Promise => new Promise(resolve => setTimeout(resolve, ms)); 23 | 24 | /** 25 | * Helper Funcs to process strings 26 | * 27 | * @param buff - Buffer with encoded string length 28 | * @returns String 29 | */ 30 | const bufferToString = (buff: Buffer): string => { 31 | let newBuff = Buffer.from(buff); 32 | const len = newBuff.readUInt32LE(); 33 | return newBuff.subarray(4, len + 4).toString(); 34 | }; 35 | 36 | /** 37 | * Helper Funcs to process strings 38 | * 39 | * @param str - Text string 40 | * @param len - Buffer Length to be encode string on to 41 | * @returns Buffer 42 | */ 43 | const stringToBuffer = (str: string, len: number = 88) => { 44 | const buf = Buffer.alloc(len); 45 | buf.writeUInt32LE(str.length); 46 | Buffer.from(str).copy(buf, 4); 47 | return buf; 48 | }; 49 | 50 | type structureString = { 51 | DATA: Buffer, 52 | LEN: number 53 | } 54 | /** 55 | * Convert string stucture object to string 56 | * 57 | * @param obj - string structure object 58 | * @returns 59 | */ 60 | const objToString = (obj: structureString): string => { 61 | return String.fromCharCode(...obj.DATA.subarray(0,obj.LEN)); 62 | }; 63 | 64 | /** 65 | * Convert string to string structure object 66 | * 67 | * @param str - String to encode 68 | * @param len - Buffer length 69 | * @returns 70 | */ 71 | const stringToObj = (str, len = 82) => { 72 | const array = Array(len).fill(0); 73 | [...str].forEach( (c, k) => { 74 | array[k] = c.charCodeAt(); 75 | }); 76 | 77 | return { 78 | LEN: str.length, 79 | DATA: array 80 | }; 81 | }; 82 | 83 | export { promiseTimeout, delay, stringToBuffer, bufferToString, objToString, stringToObj }; 84 | -------------------------------------------------------------------------------- /src/utilities/utilities.spec.js: -------------------------------------------------------------------------------- 1 | const { promiseTimeout, delay } = require("./index"); 2 | 3 | describe("Utilites", () => { 4 | describe("Promise Timeout Utility", () => { 5 | it("Resolves and Rejects as Expected", async () => { 6 | const fn = (ms, arg) => { 7 | return promiseTimeout( 8 | new Promise(resolve => { 9 | setTimeout(() => { 10 | if (arg) resolve(arg); 11 | resolve(); 12 | }, ms); 13 | }), 14 | 100, 15 | "error" 16 | ); 17 | }; 18 | 19 | await expect(fn(200)).rejects.toMatch("error"); 20 | await expect(fn(110)).rejects.toMatch("error"); 21 | await expect(fn(90)).resolves.toBeUndefined(); 22 | await expect(fn(50)).resolves.toBeUndefined(); 23 | await expect(fn(50, "hello")).resolves.toBe("hello"); 24 | await expect(fn(50, { a: 5, b: 6 })).resolves.toMatchObject({ a: 5, b: 6 }); 25 | }); 26 | }); 27 | 28 | describe("Delay Utility", () => { 29 | it("Resolves and Rejects as Expected", async () => { 30 | const fn = ms => { 31 | return promiseTimeout( 32 | new Promise(async resolve => { 33 | await delay(ms); 34 | resolve(); 35 | }), 36 | 100, 37 | "error" 38 | ); 39 | }; 40 | 41 | await expect(fn(200)).rejects.toMatch("error"); 42 | await expect(fn(110)).rejects.toMatch("error"); 43 | await expect(fn(90)).resolves.toBeUndefined(); 44 | await expect(fn(50)).resolves.toBeUndefined(); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /tests/plc_comm.js: -------------------------------------------------------------------------------- 1 | const eip = require('../dist/index.js'); 2 | const commTags = require('./plc_comm_tags.json'); 3 | const deepEqual = require('deep-equal'); 4 | 5 | 6 | if (process.argv.length > 2 ) { 7 | testSuite(process.argv[2]); 8 | } else { 9 | console.log('Missing IP address argument.'); 10 | } 11 | 12 | function testSuite(ipAddress){ 13 | const PLC = new eip.Controller(true); 14 | let testsFailed = 0; 15 | let testsPassed = 0; 16 | PLC.connect(ipAddress, 0, true) 17 | .then(async () => { 18 | 19 | //Read Write Value Tag Test 20 | for (let test of commTags.tests) { 21 | let tag = PLC.newTag(test.tagDef.tagName, test.tagDef.program, false, test.tagDef.arrayDims, test.tagDef.arraySize); 22 | 23 | try { 24 | await PLC.readTag(tag); 25 | if (deepEqual(tag.value, test.startValue)) { 26 | console.log('Read Tag ->', tag.name, ':', tag.value, 'Pass'); 27 | testsPassed++; 28 | 29 | } else { 30 | console.log('Read Tag ->', tag.name, ':', tag.value, 'Should Be: ', test.startValue, 'Fail'); 31 | testsFailed++; 32 | } 33 | 34 | if (test.testValue != null) { 35 | 36 | tag.value = test.testValue 37 | 38 | await PLC.writeTag(tag) 39 | console.log('Write Tag ->', tag.name, ':', tag.value); 40 | 41 | await PLC.readTag(tag); 42 | if (deepEqual(tag.value, test.testValue)) { 43 | console.log('Read Tag ->', tag.name, ':', tag.value, 'Pass'); 44 | testsPassed++; 45 | 46 | } else { 47 | console.log('Read Tag ->', tag.name, ':', tag.value, 'Should Be: ', test.testValue, 'Fail'); 48 | testsFailed++; 49 | } 50 | } 51 | 52 | } catch (e) { 53 | console.log(e, 'Fail'); 54 | testsFailed++; 55 | } 56 | 57 | } 58 | 59 | console.log(testsPassed, 'Tests Passed.'); 60 | console.log(testsFailed, 'Tests Failed.'); 61 | 62 | process.exit(0); 63 | 64 | }) 65 | .catch(e => { 66 | console.log(e); 67 | process.exit(0); 68 | }) 69 | } 70 | --------------------------------------------------------------------------------