├── .editorconfig ├── .eslintrc.js ├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── ecosystem.config.js ├── jest.config.js ├── jsconfig.json ├── lxd profiles ├── README.md └── tentacle-docker.yaml ├── package-lock.json ├── package.json ├── src ├── __tests__ │ ├── auth.js │ ├── server.js │ └── tag.js ├── auth.js ├── database │ ├── __tests__ │ │ └── model.js │ ├── index.js │ └── model.js ├── device │ ├── __tests__ │ │ └── device.js │ ├── device.js │ ├── ethernetip.js │ ├── index.js │ ├── modbus.js │ └── opcua.js ├── index.js ├── logger.js ├── relations.js ├── resolvers │ ├── DeviceConfig.js │ ├── MqttPrimaryHost.js │ ├── Mutation │ │ ├── auth.js │ │ ├── device │ │ │ ├── ethernetip.js │ │ │ ├── index.js │ │ │ ├── modbus.js │ │ │ └── opcua.js │ │ ├── index.js │ │ ├── service │ │ │ ├── index.js │ │ │ └── mqtt.js │ │ └── tag.js │ ├── Opcua.js │ ├── OpcuaNode.js │ ├── Query │ │ ├── device.js │ │ ├── index.js │ │ ├── service.js │ │ ├── tag.js │ │ └── user.js │ ├── Source.js │ ├── Subscription │ │ ├── device.js │ │ ├── index.js │ │ ├── service.js │ │ └── tag.js │ ├── __tests__ │ │ └── resolvers.js │ └── index.js ├── schema.graphql ├── server.js ├── service │ ├── __tests__ │ │ └── service.js │ ├── index.js │ ├── mqtt.js │ └── service.js └── tag.js └── test ├── db.js └── graphql ├── fragment.js ├── index.js ├── mutation.js └── query.js /.editorconfig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joyja/tentacle/6623fb837c1856c937e53ed80c6c84a45dd954aa/.editorconfig -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: false, 5 | node: true, 6 | es6: true, 7 | }, 8 | parserOptions: { 9 | ecmaVersion: 2018, 10 | }, 11 | extends: ['prettier', 'plugin:prettier/recommended'], 12 | plugins: ['prettier'], 13 | // add your custom rules here 14 | rules: {}, 15 | } 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [joyja] 2 | patreon: jarautomation 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE / Editor 81 | .idea 82 | 83 | # Service worker 84 | sw.* 85 | 86 | # Mac OSX 87 | .DS_Store 88 | 89 | # Vim swap files 90 | *.swp 91 | 92 | # production database 93 | /spread-edge*.db 94 | /tentacle-edge*.db 95 | /database* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "arrowParens": "always", 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | dist: bionic 4 | node_js: 5 | - '12' 6 | branches: 7 | only: 8 | - master 9 | cache: 10 | directories: 11 | - node_modules 12 | before_install: 13 | - npm update 14 | install: 15 | - npm install 16 | script: 17 | - npm run test:ci 18 | - npm run coveralls 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM keymetrics/pm2:latest-stretch 2 | 3 | # Bundle APP files 4 | COPY src src/ 5 | COPY package.json . 6 | COPY ecosystem.config.js . 7 | 8 | # Install app dependencies 9 | ENV NPM_CONFIG_LOGLEVEL warn 10 | RUN npm install --production 11 | 12 | # Expose the listening port of your app 13 | EXPOSE 4000 14 | 15 | # Show current folder structure in logs 16 | CMD [ "pm2-runtime", "start", "ecosystem.config.js" ] 17 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'tentacle', 5 | script: './src/index.js', 6 | env: { 7 | NODE_ENV: 'development', 8 | }, 9 | env_production: { 10 | NODE_ENV: 'production', 11 | }, 12 | }, 13 | ], 14 | } 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { defaults } = require('jest-config') 3 | 4 | module.exports = { 5 | roots: [path.join(__dirname, './src')], 6 | rootDir: path.join(__dirname, '.'), 7 | testEnvironment: 'node', 8 | testMatch: ['**/__tests__/**'], 9 | moduleDirectories: ['node_modules', __dirname, path.join(__dirname, './src')], 10 | coverageDirectory: path.join(__dirname, './coverage/'), 11 | collectCoverageFrom: ['**/src/**/*.js'], 12 | coveragePathIgnorePatterns: ['.*/__tests__/.*'], 13 | moduleFileExtensions: [...defaults.moduleFileExtensions, 'graphql'], 14 | watchPlugins: [ 15 | require.resolve('jest-watch-select-projects'), 16 | require.resolve('jest-watch-typeahead/filename'), 17 | require.resolve('jest-watch-typeahead/testname'), 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "~/*": ["./*"], 6 | "@/*": ["./*"], 7 | "~~/*": ["./*"], 8 | "@@/*": ["./*"], 9 | "*": ["test/*", "src/*"] 10 | } 11 | }, 12 | "exclude": ["node_modules", ".nuxt", "dist"] 13 | } 14 | -------------------------------------------------------------------------------- /lxd profiles/README.md: -------------------------------------------------------------------------------- 1 | # LXD Profiles 2 | 3 | The tentacle-docker profile sets up docker and docker compose, then installs the latest stable version of tentacle, tentacle-ui, and an nginx container. Using docker-compose, it creates an upstream network for the containers to communicate with one another and two volumes: one for the nginx configuration and one for the tentacle database. Port 80 is exposed and the tentacle graphql api is at the /api endpoint. -------------------------------------------------------------------------------- /lxd profiles/tentacle-docker.yaml: -------------------------------------------------------------------------------- 1 | config: 2 | security.nesting: "true" 3 | user.user-data: | 4 | #cloud-config 5 | package_update: true 6 | package_upgrade: true 7 | apt: 8 | sources: 9 | docker: 10 | source: "deb [arch=amd64] https://download.docker.com/linux/ubuntu $RELEASE stable" 11 | keyid: 0EBFCD88 12 | packages: 13 | - apt-transport-https 14 | - ca-certificates 15 | - curl 16 | - gnupg-agent 17 | - software-properties-common 18 | - docker-ce 19 | write_files: 20 | - path: /etc/environment 21 | content: | 22 | COMPOSE_HTTP_TIMEOUT=300 23 | DOCKER_CLIENT_TIMEOUT=300 24 | - path: /root/docker-compose.yml 25 | permissions: 0644 26 | content: | 27 | version: '3' 28 | services: 29 | tentacle: 30 | restart: always 31 | container_name: 'tentacle' 32 | image: 'joyja/tentacle:version-0.0.35' 33 | volumes: 34 | - tentacleDB:/database 35 | networks: 36 | - upstream 37 | tentacle-ui: 38 | restart: always 39 | container_name: 'tentacle-ui' 40 | image: 'joyja/tentacle-ui:version-0.0.24' 41 | networks: 42 | - upstream 43 | nginx: 44 | restart: always 45 | container_name: 'nginx' 46 | image: 'nginx' 47 | volumes: 48 | - /root/nginx.conf:/etc/nginx/conf.d/default.conf 49 | ports: 50 | - 80:80 51 | networks: 52 | - upstream 53 | depends_on: 54 | - tentacle 55 | - tentacle-ui 56 | networks: 57 | upstream: {} 58 | volumes: 59 | tentacleDB: {} 60 | - path: /root/nginx.conf 61 | permissions: 0644 62 | content: | 63 | server { 64 | listen 80; 65 | listen [::]:80; 66 | server_name _; 67 | 68 | location /api/ { 69 | proxy_http_version 1.1; 70 | proxy_cache_bypass $http_upgrade; 71 | 72 | proxy_set_header Upgrade $http_upgrade; 73 | proxy_set_header Connection "Upgrade"; 74 | proxy_set_header Host $host; 75 | proxy_set_header X-Real-IP $remote_addr; 76 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 77 | proxy_set_header X-Forwarded-Proto $scheme; 78 | proxy_set_header X-Forwarded-Host $host; 79 | proxy_set_header X-Forwarded-Port $server_port; 80 | 81 | proxy_pass http://tentacle:4000/; 82 | } 83 | 84 | location / { 85 | proxy_http_version 1.1; 86 | proxy_cache_bypass $http_upgrade; 87 | 88 | proxy_set_header Upgrade $http_upgrade; 89 | proxy_set_header Connection "Upgrade"; 90 | proxy_set_header Host $host; 91 | proxy_set_header X-Real-IP $remote_addr; 92 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 93 | proxy_set_header X-Forwarded-Proto $scheme; 94 | proxy_set_header X-Forwarded-Host $host; 95 | proxy_set_header X-Forwarded-Port $server_port; 96 | 97 | proxy_pass http://tentacle-ui:3000/; 98 | } 99 | } 100 | runcmd: 101 | - 'sudo curl -L "https://github.com/docker/compose/releases/download/1.27.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose' 102 | - 'chmod +x /usr/local/bin/docker-compose' 103 | - 'usermod -aG docker ubuntu' 104 | - 'export DOCKER_CLIENT_TIMEOUT=300' 105 | - 'export COMPOSE_HTTP_TIMEOUT=300' 106 | - 'systemctl restart docker' 107 | - '/usr/local/bin/docker-compose -f /root/docker-compose.yml up -d' 108 | 109 | description: Tentacle LXD Profile 110 | name: tentacle -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tentacle-edge", 3 | "version": "0.0.38", 4 | "description": "A nodejs industrial automation edge gateway with a GraphQL API", 5 | "scripts": { 6 | "lint": "eslint --ext .js,.vue --ignore-path .gitignore .", 7 | "dev": "cross-env NODE_ENV=development nodemon ./src/index.js -e js,graphql --watch src", 8 | "start": "cross-env node ./src/index.js", 9 | "test": "jest --watchAll", 10 | "test:ci": "jest", 11 | "test:coverage": "jest --coverage", 12 | "test:detect-open-handles": "jest --detectOpenHandles", 13 | "fix": "eslint --ext .js,.vue --fix --ignore-path .gitignore .", 14 | "coveralls": "jest --coverage --coverageReporters=text-lcov | coveralls" 15 | }, 16 | "bin": { 17 | "tentacle": "./src/index.js" 18 | }, 19 | "main": "src/index.js", 20 | "author": "", 21 | "license": "GPL-3.0", 22 | "devDependencies": { 23 | "coveralls": "^3.1.0", 24 | "eslint": "^7.24.0", 25 | "eslint-config-prettier": "^8.2.0", 26 | "eslint-config-standard": "^16.0.2", 27 | "eslint-plugin-jest": "^24.3.5", 28 | "eslint-plugin-node": "^11.1.0", 29 | "eslint-plugin-prettier": "^3.4.0", 30 | "eslint-plugin-promise": "^4.3.1", 31 | "eslint-plugin-standard": "^4.1.0", 32 | "graphql-request": "^3.4.0", 33 | "jest": "^26.6.3", 34 | "jest-watch-select-projects": "^2.0.0", 35 | "jest-watch-typeahead": "^0.6.3", 36 | "nodemon": "^2.0.7", 37 | "prettier": "^2.2.1" 38 | }, 39 | "dependencies": { 40 | "@types/jest": "^26.0.22", 41 | "apollo-server-express": "^2.23.0", 42 | "bcryptjs": "^2.4.3", 43 | "cross-env": "^7.0.3", 44 | "date-fns": "^2.21.1", 45 | "date-fns-timezone": "^0.1.4", 46 | "esm": "^3.2.25", 47 | "ethernet-ip": "^1.2.5", 48 | "express": "^4.17.1", 49 | "graphql": "^15.5.0", 50 | "jsonwebtoken": "^8.5.1", 51 | "lodash": "^4.17.21", 52 | "make-promises-safe": "^5.1.0", 53 | "modbus-serial": "^8.0.1", 54 | "mqtt": "^4.2.6", 55 | "node-opcua": "^2.40.0", 56 | "sparkplug-client": "^3.2.2", 57 | "sqlite3": "^5.0.2", 58 | "subscriptions-transport-ws": "^0.9.18", 59 | "task-easy": "^1.0.1", 60 | "tentacle-sparkplug-client": "0.0.6", 61 | "treeify": "^1.1.0", 62 | "uuid": "^8.3.2", 63 | "winston": "^3.3.3" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/__tests__/auth.js: -------------------------------------------------------------------------------- 1 | const { createTestDb, deleteTestDb } = require('../../test/db') 2 | const { User } = require('../relations') 3 | const bcrypt = require('bcryptjs') 4 | const jwt = require('jsonwebtoken') 5 | 6 | const dbFilename = `test-auth-spread-edge.db` 7 | 8 | let db = undefined 9 | beforeAll(async () => { 10 | db = await createTestDb() 11 | }) 12 | 13 | afterAll(async () => { 14 | await deleteTestDb(db) 15 | }) 16 | 17 | describe(`User:`, () => { 18 | test(`initialize creates a single user with username: admin, password: password`, async () => { 19 | pubsub = {} 20 | await User.initialize(db) 21 | const user = User.instances[0] 22 | expect(user.id).toEqual(expect.any(Number)) 23 | expect(user.username).toBe(`admin`) 24 | expect(await bcrypt.compare(`password`, user.password)).toBe(true) 25 | }) 26 | test(`if a root user already exists, initialize will not create another root user`, async () => { 27 | pubsub = {} 28 | const prevLength = User.instances.length 29 | await User.initialize(db) 30 | expect(User.instances.length).toBe(prevLength) 31 | }) 32 | test(`create: creates a new user.`, async () => { 33 | const prevLength = User.instances.length 34 | const user = await User.create(`newUser`, `newPassword`) 35 | expect(User.instances.length).toBe(prevLength + 1) 36 | expect(user.username).toBe(`newUser`) 37 | expect(await bcrypt.compare(`newPassword`, user.password)).toBe(true) 38 | }) 39 | test(`login with a valid username and password should return a token and the user`, async () => { 40 | const { token, user } = await User.login(`admin`, `password`) 41 | expect(token).toEqual(expect.any(String)) 42 | expect(user.username).toBe(`admin`) 43 | expect(await bcrypt.compare(`password`, user.password)).toBe(true) 44 | }) 45 | test(`login with an invalid username should throw an error`, async () => { 46 | expect( 47 | await User.login(`bogusUsername`, `bogusPassword`).catch((e) => e) 48 | ).toMatchInlineSnapshot(`[Error: The username or password is incorrect.]`) 49 | }) 50 | test(`login with an invalid password should throw an error`, async () => { 51 | expect( 52 | await User.login(`admin`, `bogusPassword`).catch((e) => e) 53 | ).toMatchInlineSnapshot(`[Error: The username or password is incorrect.]`) 54 | }) 55 | test(`getUserFromContext with approriate authorization token returns a valid user.`, async () => { 56 | const { token } = await User.login(`admin`, `password`) 57 | const context = { 58 | req: { 59 | headers: { 60 | authorization: `Bearer ${token}`, 61 | }, 62 | }, 63 | } 64 | const user = await User.getUserFromContext(context) 65 | expect(user.username).toBe(`admin`) 66 | expect(await bcrypt.compare(`password`, user.password)).toBe(true) 67 | }) 68 | test(`authorization works with subscription authorization headers.`, async () => { 69 | const { token } = await User.login(`admin`, `password`) 70 | const context = { 71 | connection: { 72 | context: { 73 | Authorization: `Bearer ${token}`, 74 | }, 75 | }, 76 | } 77 | const user = await User.getUserFromContext(context) 78 | expect(user.username).toBe(`admin`) 79 | expect(await bcrypt.compare(`password`, user.password)).toBe(true) 80 | }) 81 | test(`init gives us the _username and _password fields.`, async () => { 82 | const { id, username, password } = User.instances[0] 83 | User.instances = [] // clear out instances to avoid `instance exists error` 84 | const user = new User(id) 85 | await user.init() 86 | expect(user._username).toBe(username) 87 | expect(user._password).toBe(password) 88 | await User.getAll() // refresh instances. 89 | }) 90 | test(`username and password getters return the appropriate underscore fields`, () => { 91 | const user = User.instances[0] 92 | expect(user.username).toBe(user._username) 93 | expect(user.password).toBe(user._password) 94 | }) 95 | test(`username and password setters return the appropriate underscore fields`, async () => { 96 | const user = User.instances[0] 97 | await user.setUsername(`newUsername`) 98 | await user.setPassword(`newPassword`) 99 | expect(user.username).toBe(`newUsername`) 100 | expect(await bcrypt.compare(`newPassword`, user.password)).toBe(true) 101 | await user.setUsername(`admin`) 102 | await user.setPassword(`password`) 103 | }) 104 | test(`change password with invalid old password throws error.`, async () => { 105 | const { token } = await User.login(`admin`, `password`) 106 | const context = { 107 | req: { 108 | headers: { 109 | authorization: `Bearer ${token}`, 110 | }, 111 | }, 112 | } 113 | expect( 114 | await User.changePassword( 115 | context, 116 | 'incorrectPassword', 117 | 'newPassword' 118 | ).catch((e) => e) 119 | ).toMatchInlineSnapshot(`[Error: Invalid old password.]`) 120 | }) 121 | test(`change password with valid old password returns valid user.`, async () => { 122 | const { token } = await User.login(`admin`, `password`) 123 | const context = { 124 | req: { 125 | headers: { 126 | authorization: `Bearer ${token}`, 127 | }, 128 | }, 129 | } 130 | const user = await User.changePassword(context, 'password', 'newPassword') 131 | expect(await bcrypt.compare(`newPassword`, user.password)).toBe(true) 132 | }) 133 | test(`getUserFromContext without valid token returns an error.`, async () => { 134 | const token = jwt.sign( 135 | { 136 | userId: 123, 137 | }, 138 | `aSecret` 139 | ) 140 | const context = { 141 | req: { 142 | headers: { 143 | authorization: `Bearer ${token}`, 144 | }, 145 | }, 146 | } 147 | expect( 148 | await User.getUserFromContext(context).catch((e) => e) 149 | ).toMatchInlineSnapshot(`[Error: You are not authorized.]`) 150 | }) 151 | }) 152 | -------------------------------------------------------------------------------- /src/__tests__/tag.js: -------------------------------------------------------------------------------- 1 | jest.mock(`apollo-server-express`) 2 | const { PubSub } = require(`apollo-server-express`) 3 | const { createTestDb, deleteTestDb } = require('../../test/db') 4 | const { 5 | User, 6 | Tag, 7 | ScanClass, 8 | Device, 9 | Service, 10 | MqttSource, 11 | } = require('../relations') 12 | const fromUnixTime = require('date-fns/fromUnixTime') 13 | 14 | const dbFilename = `test-tag-spread-edge.db` 15 | const pubsub = new PubSub() 16 | let db = undefined 17 | beforeAll(async () => { 18 | db = await createTestDb().catch((error) => { 19 | throw error 20 | }) 21 | }) 22 | 23 | afterAll(async () => { 24 | await deleteTestDb(db).catch((error) => { 25 | throw error 26 | }) 27 | }) 28 | 29 | afterEach(() => { 30 | jest.clearAllMocks() 31 | }) 32 | 33 | test(`Tag: initialize initializes ScanClass to.`, async () => { 34 | await Tag.initialize(db, pubsub) 35 | expect(ScanClass.initialized).toBe(true) 36 | }) 37 | 38 | jest.useFakeTimers() 39 | describe(`ScanClass:`, () => { 40 | test(`create creates a new ScanClass with the appropriate fields.`, async () => { 41 | await User.initialize(db, pubsub) 42 | const user = User.instances[0] 43 | const name = 'default' 44 | const description = 'Default Scan Class' 45 | const rate = 1000 46 | const scanClass = await ScanClass.create(name, description, rate, user.id) 47 | expect(ScanClass.instances.length).toBe(1) 48 | expect(scanClass.id).toEqual(expect.any(Number)) 49 | expect(scanClass.name).toBe(name) 50 | expect(scanClass.description).toBe(description) 51 | expect(scanClass.rate).toBe(rate) 52 | expect(scanClass.createdBy.id).toBe(user.id) 53 | }) 54 | test(`stopScan doesn't clear an interval if there is isn't one.`, () => { 55 | const scanClass = ScanClass.instances[0] 56 | scanClass.stopScan() 57 | expect(clearInterval).toHaveBeenCalledTimes(0) 58 | }) 59 | test(`startScan creates an interval`, () => { 60 | const scanClass = ScanClass.instances[0] 61 | scanClass.startScan() 62 | expect(setInterval).toHaveBeenCalledTimes(1) 63 | expect(setInterval).toHaveBeenCalledWith( 64 | expect.any(Function), 65 | scanClass.rate 66 | ) 67 | }) 68 | test(`stopScan clears an interval if there is one.`, () => { 69 | const scanClass = ScanClass.instances[0] 70 | scanClass.stopScan() 71 | expect(clearInterval).toHaveBeenCalledTimes(1) 72 | }) 73 | test 74 | 75 | test(`Getters all return their underscore values`, () => { 76 | const scanClass = ScanClass.instances[0] 77 | expect(scanClass.rate).toBe(scanClass._rate) 78 | expect(scanClass.createdBy.id).toBe(scanClass._createdBy) 79 | expect(scanClass.createdOn).toStrictEqual( 80 | fromUnixTime(scanClass._createdOn) 81 | ) 82 | }) 83 | test(`Setters all set the values appropriately`, async () => { 84 | const scanClass = ScanClass.instances[0] 85 | const rate = 1234 86 | await scanClass.setRate(rate) 87 | expect(scanClass.rate).toBe(rate) 88 | }) 89 | }) 90 | let tag = null 91 | describe('Tag:', () => { 92 | test(`create creates a new Tag with the appropriate fields.`, async () => { 93 | const name = `testTag` 94 | const description = `Test Tag` 95 | const value = 123 96 | const scanClass = ScanClass.instances[0].id 97 | const createdBy = User.instances[0].id 98 | const datatype = `INT32` 99 | tag = await Tag.create( 100 | name, 101 | description, 102 | value, 103 | scanClass, 104 | createdBy, 105 | datatype 106 | ) 107 | expect(tag.createdBy.id).toBe(createdBy) 108 | expect(tag.datatype).toBe(datatype) 109 | }) 110 | test(`check that init sets the appropriate underscore fields.`, async () => { 111 | Tag.instances = [] 112 | const uninitTag = new Tag(tag.id) 113 | await uninitTag.init() 114 | expect(uninitTag._name).toBe(tag._name) 115 | expect(uninitTag._description).toBe(tag._description) 116 | expect(uninitTag._value).toBe(tag._value) 117 | expect(uninitTag._scanClass).toBe(tag._scanClass) 118 | expect(uninitTag._createdBy).toBe(tag._createdBy) 119 | expect(uninitTag._CreatedOn).toBe(tag._CreatedOn) 120 | expect(uninitTag._datatype).toBe(tag._datatype) 121 | await Tag.getAll() 122 | }) 123 | test(`Getters all return their underscore values`, () => { 124 | expect(tag.name).toBe(tag._name) 125 | expect(tag.description).toBe(tag._description) 126 | expect(tag.value).toBe(parseInt(tag._value)) 127 | expect(tag.scanClass.id).toBe(tag._scanClass) 128 | expect(tag.createdBy.id).toBe(tag._createdBy) 129 | expect(tag.createdOn).toStrictEqual(fromUnixTime(tag._createdOn)) 130 | expect(tag.datatype).toBe(tag._datatype) 131 | }) 132 | test(`Setters all set the values appropriately`, async () => { 133 | const name = `newName` 134 | const description = `New description` 135 | const value = 321 136 | const datatype = `FLOAT` 137 | await tag.setName(name) 138 | await tag.setDescription(description) 139 | await tag.setValue(value) 140 | await tag.setDatatype(datatype) 141 | expect(tag.name).toBe(name) 142 | expect(tag.description).toBe(description) 143 | expect(tag.value).toBe(value) 144 | expect(tag.datatype).toBe(datatype) 145 | }) 146 | test(`setScanClass with invalid scan class id throws error`, async () => { 147 | expect(await tag.setScanClass(12345).catch((e) => e)).toMatchInlineSnapshot( 148 | `[Error: Scan Class with 12345 does not exist.]` 149 | ) 150 | }) 151 | }) 152 | test(`ScanClass: tags returns the tags we've assigned this scan class to.`, () => { 153 | const scanClass = ScanClass.instances[0] 154 | expect(scanClass.tags.length).toBe(1) 155 | expect(scanClass.tags[0].id).toBe(Tag.instances[0].id) 156 | }) 157 | -------------------------------------------------------------------------------- /src/auth.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcryptjs') 2 | const jwt = require('jsonwebtoken') 3 | const { executeUpdate, Model } = require('./database') 4 | const { v1: uuidv1 } = require('uuid') 5 | 6 | const APP_SECRET = 7 | process.env.NODE_ENV === 'development' ? 'development_secret' : uuidv1() 8 | 9 | class User extends Model { 10 | static async initialize(db, pubsub) { 11 | const result = await super.initialize(db, pubsub) 12 | const rootUser = User.instances.find((user) => { 13 | return user.username === `admin` 14 | }) 15 | if (!rootUser) { 16 | await User.create(`admin`, `password`) 17 | } 18 | return result 19 | } 20 | static async create(username, password) { 21 | const hash = await bcrypt.hash(password, 10) 22 | const fields = { 23 | username, 24 | password: hash, 25 | } 26 | return super.create(fields) 27 | } 28 | static async login(username, password) { 29 | const user = User.instances.find((user) => { 30 | return user.username === username 31 | }) 32 | const errorMessage = 'The username or password is incorrect.' 33 | if (user) { 34 | const valid = await bcrypt.compare(password, user.password) 35 | if (!valid) { 36 | throw new Error(errorMessage) 37 | } else { 38 | const token = jwt.sign( 39 | { 40 | userId: user.id, 41 | }, 42 | APP_SECRET 43 | ) 44 | return { 45 | token, 46 | user, 47 | } 48 | } 49 | } else { 50 | throw new Error(errorMessage) 51 | } 52 | } 53 | static async getUserFromContext(context) { 54 | const secret = APP_SECRET 55 | const errorMessage = `You are not authorized.` 56 | const authorization = context.req 57 | ? context.req.headers.authorization 58 | : context.connection.context.Authorization 59 | if (authorization) { 60 | const token = authorization.replace('Bearer ', '') 61 | try { 62 | const { userId } = jwt.verify(token, secret) 63 | return User.get(userId) 64 | } catch (error) { 65 | throw new Error(errorMessage) 66 | } 67 | } else { 68 | throw new Error(errorMessage) 69 | } 70 | } 71 | static async changePassword(context, oldPassword, newPassword) { 72 | const user = await User.getUserFromContext(context) 73 | const valid = await bcrypt.compare(oldPassword, user.password) 74 | if (!valid) { 75 | throw new Error('Invalid old password.') 76 | } else { 77 | await user.setPassword(newPassword) 78 | return user 79 | } 80 | } 81 | async init() { 82 | const result = await super.init() 83 | this._username = result.username 84 | this._password = result.password 85 | } 86 | get username() { 87 | this.checkInit() 88 | return this._username 89 | } 90 | setUsername(newValue) { 91 | return this.update(this.id, `username`, newValue).then((result) => { 92 | this._username = newValue 93 | }) 94 | } 95 | get password() { 96 | this.checkInit() 97 | return this._password 98 | } 99 | async setPassword(newValue) { 100 | const password = await bcrypt.hash(newValue, 10) 101 | return this.update(this.id, `password`, password, User).then((result) => { 102 | this._password = password 103 | }) 104 | } 105 | } 106 | User.table = `user` 107 | User.fields = [ 108 | { colName: 'username', colType: 'TEXT' }, 109 | { colName: 'password', colType: 'TEXT' }, 110 | ] 111 | User.instances = [] 112 | User.initialized = false 113 | 114 | module.exports = { 115 | User, 116 | } 117 | -------------------------------------------------------------------------------- /src/database/__tests__/model.js: -------------------------------------------------------------------------------- 1 | jest.mock('../../logger') 2 | const logger = require('../../logger') 3 | const { createTestDb, deleteTestDb } = require('../../../test/db') 4 | const { executeQuery, executeUpdate, Model } = require('../../database') 5 | 6 | let db = undefined 7 | beforeAll(async () => { 8 | db = await createTestDb().catch((error) => { 9 | throw error 10 | }) 11 | }) 12 | 13 | afterAll(async () => { 14 | await deleteTestDb(db).catch((error) => { 15 | throw error 16 | }) 17 | }) 18 | 19 | afterEach(() => { 20 | jest.clearAllMocks() 21 | }) 22 | 23 | class TestModel extends Model { 24 | static initialize(db, pubsub) { 25 | return super.initialize(db, pubsub, TestModel) 26 | } 27 | static checkInitialized() { 28 | return super.checkInitialized(this) 29 | } 30 | } 31 | TestModel.initialized = false 32 | TestModel.fields = [{ colName: 'testField', colType: 'TEXT' }] 33 | TestModel.instances = [] 34 | TestModel.table = 'test' 35 | 36 | describe(`executeQuery:`, () => { 37 | test('Called with undefined params calls db.all with empty object.', () => { 38 | const mockdb = { 39 | all: jest.fn((sql, params, callback) => callback()), 40 | } 41 | const sql = `` 42 | const params = undefined 43 | executeQuery(mockdb, sql, params) 44 | expect(mockdb.all).toBeCalledWith(sql, [], expect.any(Function)) 45 | expect(mockdb.all).toBeCalledTimes(1) 46 | }) 47 | test('If db call returns error, error is thrown', async () => { 48 | const sql = `` 49 | expect(await executeQuery(db, sql).catch((e) => e)).toMatchInlineSnapshot( 50 | `[Error: SQLITE_MISUSE: not an error]` 51 | ) 52 | }) 53 | }) 54 | 55 | test('executeUpdate: Called with undefined params calls db.run with empty object.', () => { 56 | const mockdb = { 57 | run: jest.fn((sql, params, callback) => callback()), 58 | } 59 | const sql = `` 60 | const params = undefined 61 | executeUpdate(mockdb, sql, params) 62 | expect(mockdb.run).toBeCalledWith(sql, [], expect.any(Function)) 63 | expect(mockdb.run).toBeCalledTimes(1) 64 | }) 65 | 66 | describe(`Model:`, () => { 67 | test('running get before initialize results in an error.', async () => { 68 | const error = await TestModel.get(1).catch((e) => e) 69 | expect(error).toMatchInlineSnapshot( 70 | `[Error: you need to run .initialize() before running any methods or accessing properties on a subclass of model.]` 71 | ) 72 | }) 73 | test('running getall before initialize results in an error.', async () => { 74 | const error = await TestModel.getAll().catch((e) => e) 75 | expect(error).toMatchInlineSnapshot( 76 | `[Error: you need to run .initialize() before running any methods or accessing properties on a subclass of model.]` 77 | ) 78 | }) 79 | test('running create before initialize results in an error.', async () => { 80 | const error = await TestModel.create().catch((e) => e) 81 | expect(error).toMatchInlineSnapshot( 82 | `[Error: you need to run .initialize() before running any methods or accessing properties on a subclass of model.]` 83 | ) 84 | }) 85 | test('running delete before initialize results in an error.', async () => { 86 | const error = await TestModel.delete().catch((e) => e) 87 | expect(error).toMatchInlineSnapshot( 88 | `[Error: you need to run .initialize() before running any methods or accessing properties on a subclass of model.]` 89 | ) 90 | }) 91 | test('running findById before initialize results in an error.', async () => { 92 | let error = null 93 | try { 94 | TestModel.findById(1) 95 | } catch (error) { 96 | expect(error).toMatchInlineSnapshot( 97 | `[Error: you need to run .initialize() before running any methods or accessing properties on a subclass of model.]` 98 | ) 99 | } 100 | }) 101 | 102 | test('initialized sets class parameters.', async () => { 103 | const pubsub = {} 104 | await TestModel.initialize(db, pubsub) 105 | expect(TestModel.db).toEqual(db) 106 | expect(TestModel.pubsub).toEqual(pubsub) 107 | expect(TestModel.initialized).toBe(true) 108 | }) 109 | let testInstanceId = null 110 | let testInstance = null 111 | test('create returns an instance and adds it to the instances class.', async () => { 112 | testInstance = await TestModel.create({ testField: 'testValue' }) 113 | expect(TestModel.instances.length).toBe(1) 114 | expect(TestModel.instances[0]).toEqual(testInstance) 115 | expect(testInstance.id).toEqual(expect.any(Number)) 116 | expect(testInstance.initialized).toBe(true) 117 | testInstanceId = testInstance.id 118 | }) 119 | test(`Running the constructor with an id that's already been logs an error.`, () => { 120 | let error = null 121 | new TestModel(testInstanceId) 122 | expect(logger.error).toBeCalledTimes(1) 123 | }) 124 | test(`Running the constructor with an id that's not a number throws an error.`, () => { 125 | let error = null 126 | new TestModel(`This isn't a number.`) 127 | expect(logger.error).toBeCalledTimes(1) 128 | }) 129 | test(`findById returns the appropriate instance.`, () => { 130 | const localInstance = TestModel.findById(testInstanceId) 131 | expect(localInstance.id).toBe(testInstanceId) 132 | }) 133 | test('get returns the instance with the appropriate id.', async () => { 134 | const localInstance = await TestModel.get(testInstanceId) 135 | expect(localInstance.id).toBe(testInstanceId) 136 | expect(localInstance.initialized).toBe(true) 137 | expect(TestModel.instances.length).toBe(1) 138 | }) 139 | test('get throws error if the id is not a number', async () => { 140 | await TestModel.get(`a bad id`) 141 | expect(logger.error).toBeCalledTimes(1) 142 | }) 143 | test(`get throws error if the id doesn't existing in the database`, async () => { 144 | const result = await TestModel.get(123).catch((e) => e) 145 | expect(result.errors[0]).toMatchInlineSnapshot( 146 | `[Error: There is no test with id# 123.]` 147 | ) 148 | expect(logger.error).toBeCalledTimes(1) 149 | }) 150 | test('update sets field to new value', async () => { 151 | const newValue = `newTestValue` 152 | const result = await testInstance.update(1, 'testField', newValue) 153 | expect(result).toBe(newValue) 154 | }) 155 | test('update throws exception on sqlite error', async () => { 156 | await testInstance.update() 157 | expect(logger.error).toBeCalledTimes(1) 158 | }) 159 | test('delete removes the instance from constructor instances, deletes it from the database, and returns the deleted instance.', async () => { 160 | const localInstance = await testInstance.delete() 161 | expect(TestModel.instances.length).toBe(0) 162 | expect(localInstance.id).toBe(1) 163 | }) 164 | test('Accessing id without running instance init throws an error.', async () => { 165 | const result = await new Promise((resolve, reject) => { 166 | return db.run( 167 | `INSERT INTO test (testField) VALUES ("testValue")`, 168 | function (error) { 169 | if (error) { 170 | reject(error) 171 | } else { 172 | resolve(this) 173 | } 174 | } 175 | ) 176 | }) 177 | const localTestInstance = new TestModel(result.lastID) 178 | let error = null 179 | try { 180 | localTestInstance.id 181 | } catch (e) { 182 | error = e 183 | } 184 | expect() 185 | expect(error).toMatchInlineSnapshot( 186 | `[Error: you need to run .init() before running any methods or accessing properties on this tag instance.]` 187 | ) 188 | }) 189 | }) 190 | -------------------------------------------------------------------------------- /src/database/index.js: -------------------------------------------------------------------------------- 1 | const model = require('./model') 2 | 3 | module.exports = { 4 | ...model, 5 | } 6 | -------------------------------------------------------------------------------- /src/database/model.js: -------------------------------------------------------------------------------- 1 | const logger = require('../logger') 2 | 3 | const executeQuery = function (db, sql, params = [], firstRowOnly = false) { 4 | if (process.env.TENTACLE_DEBUG) { 5 | console.log(new Date().toISOString()) 6 | console.log(sql) 7 | console.log(params) 8 | } 9 | return new Promise((resolve, reject) => { 10 | const callback = (error, rows) => { 11 | if (error) { 12 | reject(error) 13 | } else { 14 | resolve(rows) 15 | } 16 | } 17 | if (firstRowOnly) { 18 | db.get(sql, params, callback) 19 | } else { 20 | db.all(sql, params, callback) 21 | } 22 | }) 23 | } 24 | 25 | const executeUpdate = function (db, sql, params = []) { 26 | if (process.env.TENTACLE_DEBUG) { 27 | console.log(new Date().toISOString()) 28 | console.log(sql) 29 | console.log(params) 30 | } 31 | return new Promise((resolve, reject) => { 32 | db.run(sql, params, function (error) { 33 | if (error) { 34 | reject(error) 35 | } else { 36 | resolve(this) 37 | } 38 | }) 39 | }) 40 | } 41 | 42 | class Model { 43 | static executeUpdate(sql, params) { 44 | return executeUpdate(this.db, sql, params).catch((error) => { 45 | logger.error(error.message, { message: `sql: ${sql}` }) 46 | }) 47 | } 48 | static executeQuery(sql, params, firstRowOnly) { 49 | return executeQuery(this.db, sql, params, firstRowOnly).catch((error) => { 50 | logger.error(error, { message: `sql: ${sql}` }) 51 | }) 52 | } 53 | static async createTable() { 54 | // fields should be formatted { colName, colType } for typical columns 55 | // fields should be formatted { colName, colRef, onDelete } for foreign key 56 | this.checkInitialized() 57 | let sql = `CREATE TABLE IF NOT EXISTS "${this.table}" (` 58 | sql = `${sql} "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE` 59 | this.fields.forEach((field) => { 60 | if (field.colRef) { 61 | sql = `${sql}, "${field.colName}" INTEGER` 62 | } else { 63 | sql = `${sql}, "${field.colName}" ${field.colType}` 64 | } 65 | }) 66 | this.fields.forEach((field) => { 67 | if (field.colRef) { 68 | sql = `${sql}, FOREIGN KEY("${field.colName}") REFERENCES "${field.colRef}"("id") ON DELETE ${field.onDelete}` 69 | } 70 | }) 71 | sql = `${sql});` 72 | const result = await this.executeUpdate(sql) 73 | for (const field of this.fields) { 74 | if (field.colRef) { 75 | sql = `CREATE INDEX IF NOT EXISTS idx_${this.table}_${field.colName} ON ${this.table} (${field.colName});` 76 | await this.executeUpdate(sql) 77 | } 78 | } 79 | return result 80 | } 81 | static async initialize(db, pubsub) { 82 | this.initialized = true 83 | this.db = db 84 | this.pubsub = pubsub 85 | const { user_version } = await this.executeQuery( 86 | 'PRAGMA user_version', 87 | [], 88 | true 89 | ) 90 | this.version = user_version 91 | let sql = `SELECT name FROM sqlite_master WHERE type='table' AND name=?` 92 | let params = [this.table] 93 | const result = await this.executeQuery(sql, params, true) 94 | this.tableExisted = result ? result.name === this.table : false 95 | await this.createTable() 96 | return this.getAll() 97 | } 98 | static checkInitialized() { 99 | if (!this.initialized) { 100 | throw Error( 101 | `you need to run .initialize() before running any methods or accessing properties on a subclass of model.` 102 | ) 103 | } 104 | } 105 | static async get(selector, ignoreExisting = false) { 106 | this.checkInitialized() 107 | let model = undefined 108 | if (typeof selector === 'number') { 109 | if (!ignoreExisting) { 110 | model = this.instances.find((instance) => { 111 | return instance._id === selector 112 | }) 113 | } 114 | if (!model) { 115 | model = new this(selector) 116 | await model.init() 117 | } 118 | return model 119 | } else { 120 | logger.error( 121 | new Error('Must provide an id (Type of Number) as selector.') 122 | ) 123 | } 124 | } 125 | static async getAll() { 126 | this.checkInitialized() 127 | let sql = `SELECT id FROM ${this.table}` 128 | this.instances = [] 129 | const result = await this.executeQuery(sql) 130 | const instances = await Promise.all( 131 | result.map((row) => { 132 | return this.get(row.id, true) 133 | }) 134 | ) 135 | this.instances = instances 136 | return instances 137 | } 138 | static async create(fields) { 139 | this.checkInitialized() 140 | const sql = `INSERT INTO ${this.table} ("${Object.keys(fields).join( 141 | `","` 142 | )}") VALUES (${Array(Object.keys(fields).length).fill(`?`).join(',')})` 143 | const params = Object.keys(fields).map((key) => fields[key]) 144 | const result = await this.executeUpdate(sql, params) 145 | return this.get(result.lastID, false) 146 | } 147 | static async delete(selector) { 148 | this.checkInitialized() 149 | const sql = `DELETE FROM ${this.table} WHERE id=?` 150 | await this.executeUpdate(sql, [selector]) 151 | this.instances = this.instances.filter((instance) => { 152 | return instance._id !== selector 153 | }) 154 | return selector 155 | } 156 | static findById(id) { 157 | this.checkInitialized() 158 | return this.instances.find((instance) => { 159 | return instance.id === parseInt(id) 160 | }) 161 | } 162 | constructor(selector) { 163 | const Subclass = this.constructor 164 | Subclass.checkInitialized() 165 | this.db = Subclass.db 166 | this.pubsub = Subclass.pubsub 167 | this.initialized = false 168 | this.errors = [] 169 | if (typeof selector === 'number') { 170 | this._id = selector 171 | const exists = Subclass.instances.some((instance) => { 172 | return instance._id === selector 173 | }) 174 | if (!exists) { 175 | Subclass.instances.push(this) 176 | } else { 177 | logger.error( 178 | new Error( 179 | `A ${Subclass.table} with this id already exists. Use get() method to get the existing instance.` 180 | ) 181 | ) 182 | } 183 | } else { 184 | logger.error( 185 | new Error('Must provide an id (Type of Number) as selector.') 186 | ) 187 | } 188 | } 189 | async init() { 190 | const sql = `SELECT * FROM ${this.constructor.table} WHERE id=?` 191 | let result 192 | try { 193 | result = await this.constructor.executeQuery(sql, [this._id]) 194 | if (result.length < 1) { 195 | throw new Error( 196 | `There is no ${this.constructor.table} with id# ${this._id}.` 197 | ) 198 | } else { 199 | this.initialized = true 200 | this._id = result[0].id 201 | } 202 | } catch (error) { 203 | this.constructor.instances = this.constructor.instances.filter( 204 | (instance) => { 205 | return instance._id !== this._id 206 | } 207 | ) 208 | this.errors.push(error) 209 | logger.error(error) 210 | } 211 | return result[0] 212 | } 213 | checkInit() { 214 | if (!this.initialized) { 215 | throw Error( 216 | `you need to run .init() before running any methods or accessing properties on this tag instance.` 217 | ) 218 | } 219 | } 220 | update(selector, field, value) { 221 | const sql = `UPDATE ${this.constructor.table} SET "${field}"=? WHERE id=?` 222 | const params = [value, selector] 223 | return this.constructor.executeUpdate(sql, params).then((result) => value) 224 | } 225 | async delete() { 226 | await this.constructor.delete(this.id) 227 | return this 228 | } 229 | get id() { 230 | this.checkInit() 231 | return this._id 232 | } 233 | } 234 | 235 | module.exports = { 236 | executeQuery, 237 | executeUpdate, 238 | Model, 239 | } 240 | -------------------------------------------------------------------------------- /src/device/__tests__/device.js: -------------------------------------------------------------------------------- 1 | jest.mock(`modbus-serial`) 2 | jest.mock(`ethernet-ip`) 3 | jest.mock(`apollo-server-express`) 4 | jest.mock(`node-opcua`, () => { 5 | return { 6 | OPCUAClient: { 7 | create: () => { 8 | return { 9 | connect: jest.fn(async () => {}), 10 | disconnect: jest.fn(), 11 | on: jest.fn(), 12 | createSession: jest.fn(() => { 13 | return { 14 | readVariableValue: jest.fn(), 15 | writeSingleNode: jest.fn(), 16 | close: jest.fn(), 17 | } 18 | }), 19 | } 20 | }, 21 | }, 22 | MessageSecurityMode: { None: null }, 23 | SecurityPolicy: { None: null }, 24 | DataType: { 25 | Boolean: 1, 26 | Float: 2, 27 | Int32: 3, 28 | String: 4, 29 | }, 30 | NodeCrawler: function (session) { 31 | return { 32 | read: jest.fn((nodeId, callback) => { 33 | callback() 34 | }), 35 | on: jest.fn(), 36 | } 37 | }, 38 | } 39 | }) 40 | const { PubSub } = require(`apollo-server-express`) 41 | const ModbusRTU = require(`modbus-serial`) 42 | const { Controller } = require(`ethernet-ip`) 43 | const { 44 | OPCUAClient, 45 | MessageSecurityMode, 46 | SecurityPolicy, 47 | } = require(`node-opcua`) 48 | 49 | const { createTestDb, deleteTestDb } = require('../../../test/db') 50 | const { 51 | ScanClass, 52 | Tag, 53 | User, 54 | Device, 55 | Modbus, 56 | ModbusSource, 57 | EthernetIP, 58 | EthernetIPSource, 59 | Opcua, 60 | OpcuaSource, 61 | } = require('../../relations') 62 | const fromUnixTime = require('date-fns/fromUnixTime') 63 | const { read } = require('tentacle-sparkplug-client/src/logger') 64 | 65 | const pubsub = new PubSub() 66 | let db = undefined 67 | beforeAll(async () => { 68 | db = await createTestDb() 69 | await User.initialize(db, pubsub) 70 | await Tag.initialize(db, pubsub) 71 | ModbusRTU.prototype.getTimeout.mockImplementation(() => { 72 | return 1000 73 | }) 74 | }) 75 | 76 | afterAll(async () => { 77 | await deleteTestDb(db) 78 | }) 79 | 80 | afterEach(async () => { 81 | jest.clearAllMocks() 82 | }) 83 | 84 | test(`Initializing Device, also initializes Modbus, ModbusSource and EthernetIP.`, async () => { 85 | await Device.initialize(db, pubsub) 86 | expect(Device.initialized).toBe(true) 87 | expect(Modbus.initialized).toBe(true) 88 | expect(ModbusSource.initialized).toBe(true) 89 | expect(EthernetIP.initialized).toBe(true) 90 | expect(EthernetIPSource.initialized).toBe(true) 91 | expect(Opcua.initialized).toBe(true) 92 | expect(OpcuaSource.initialized).toBe(true) 93 | }) 94 | let device = null 95 | test(`Modbus: create creates a device with modbus config`, async () => { 96 | await User.initialize(db, pubsub) 97 | user = User.instances[0] 98 | const name = `testDevice` 99 | const description = `Test Device` 100 | const host = `localhost` 101 | const port = 502 102 | const reverseBits = true 103 | const reverseWords = true 104 | const zeroBased = true 105 | const timeout = 1000 106 | const retryRate = 3000 107 | const createdBy = user.id 108 | const modbus = await Modbus.create( 109 | name, 110 | description, 111 | host, 112 | port, 113 | reverseBits, 114 | reverseWords, 115 | zeroBased, 116 | timeout, 117 | retryRate, 118 | createdBy 119 | ) 120 | device = modbus.device 121 | expect(modbus.device).toBe(Device.instances[0]) 122 | expect(modbus.device.name).toBe(name) 123 | expect(modbus.device.description).toBe(description) 124 | expect(modbus.host).toBe(host) 125 | expect(modbus.port).toBe(port) 126 | expect(modbus.reverseBits).toBe(reverseBits) 127 | expect(modbus.reverseWords).toBe(reverseWords) 128 | expect(modbus.zeroBased).toBe(zeroBased) 129 | expect(modbus.timeout).toBe(timeout) 130 | expect(modbus.retryRate).toBe(retryRate) 131 | expect(modbus.device.createdBy.id).toBe(user.id) 132 | }) 133 | describe(`Device: `, () => { 134 | test(`check that init sets the appropriate underscore fields.`, async () => { 135 | Device.instances = [] 136 | const uninitDevice = new Device(device.id) 137 | await uninitDevice.init() 138 | expect(uninitDevice._name).toBe(device._name) 139 | expect(uninitDevice._description).toBe(device._description) 140 | expect(uninitDevice._type).toBe(device._type) 141 | expect(uninitDevice._createdBy).toBe(device._createdBy) 142 | expect(uninitDevice._CreatedOn).toBe(device._CreatedOn) 143 | expect(uninitDevice._config).toBe(device._config) 144 | await Device.getAll() 145 | }) 146 | test(`Getters all return their underscore values`, () => { 147 | expect(device.name).toBe(device._name) 148 | expect(device.description).toBe(device._description) 149 | expect(device.type).toBe(device._type) 150 | expect(device.createdBy.id).toBe(device._createdBy) 151 | expect(device.createdOn).toStrictEqual(fromUnixTime(device._createdOn)) 152 | }) 153 | test(`Setters all set the values appropriately`, async () => { 154 | const name = `newName` 155 | const description = `New description` 156 | await device.setName(name) 157 | await device.setDescription(description) 158 | expect(device.name).toBe(name) 159 | expect(device.description).toBe(description) 160 | }) 161 | }) 162 | 163 | // ============================== 164 | // Modbus 165 | // ============================== 166 | 167 | describe(`Modbus: `, () => { 168 | let modbus = null 169 | test(`Instantiating a modbus client creats a new ModbusRTU client`, () => { 170 | const modbusId = device.config.id 171 | Modbus.instances = [] 172 | modbus = new Modbus(modbusId) 173 | expect(ModbusRTU).toHaveBeenCalledTimes(1) 174 | expect(modbus.client.constructor.name).toBe(`ModbusRTU`) 175 | }) 176 | test(`check that init sets the appropriate underscore fields.`, async () => { 177 | await modbus.init() 178 | expect(modbus._device).toBe(device.config._device) 179 | expect(modbus._host).toBe(device.config._host) 180 | expect(modbus._port).toBe(device.config._port) 181 | expect(modbus._reverseBits).toBe(device.config._reverseBits) 182 | expect(modbus._reverseWords).toBe(device.config._reverseWords) 183 | expect(modbus._zeroBased).toBe(device.config._zeroBased) 184 | expect(modbus.client.getTimeout()).toBe(device.config.client.getTimeout()) 185 | expect(modbus.connected).toBe(false) 186 | expect(modbus.error).toBe(null) 187 | await Modbus.getAll() 188 | }) 189 | test(`Connect calls connectTCP and rejected results in a false connected status.`, async () => { 190 | modbus.client.connectTCP.mockRejectedValueOnce( 191 | new Error(`Connection Error.`) 192 | ) 193 | await modbus.connect() 194 | expect(modbus.error).toMatchInlineSnapshot(`"Connection Error."`) 195 | expect(modbus.client.connectTCP).toBeCalledTimes(1) 196 | expect(modbus.connected).toBe(false) 197 | modbus.client.connectTCP.mockReset() 198 | }) 199 | test(`Connect calls connectTCP and resolved results in a true connected status.`, async () => { 200 | modbus.client.connectTCP.mockResolvedValueOnce({}) 201 | await modbus.connect() 202 | expect(modbus.error).toBe(null) 203 | expect(modbus.client.connectTCP).toBeCalledTimes(1) 204 | expect(modbus.connected).toBe(true) 205 | modbus.client.connectTCP.mockReset() 206 | }) 207 | test(`Disconnect calls client close throws an error on reject.`, async () => { 208 | modbus.client.close.mockImplementation(() => { 209 | throw new Error(`Close connection failed.`) 210 | }) 211 | expect(await modbus.disconnect().catch((e) => e)).toMatchInlineSnapshot( 212 | `[Error: Close connection failed.]` 213 | ) 214 | expect(modbus.client.close).toBeCalledTimes(1) 215 | expect(modbus.connected).toBe(true) 216 | modbus.client.close.mockClear() 217 | }) 218 | test(`Disconnect calls client close and throws an`, async () => { 219 | modbus.client.close.mockImplementation((callback) => { 220 | callback() 221 | }) 222 | await modbus.disconnect() 223 | expect(modbus.client.close).toBeCalledTimes(1) 224 | expect(modbus.connected).toBe(false) 225 | modbus.client.close.mockClear() 226 | }) 227 | test(`Getters all return their underscore values`, () => { 228 | expect(modbus.host).toBe(modbus._host) 229 | expect(modbus.port).toBe(modbus._port) 230 | expect(modbus.reverseBits).toBe(Boolean(modbus._reverseBits)) 231 | expect(modbus.reverseWords).toBe(Boolean(modbus._reverseWords)) 232 | }) 233 | test(`Setters all set the values appropriately`, async () => { 234 | const host = `newHost` 235 | const port = 12345 236 | const reverseBits = true 237 | const reverseWords = true 238 | await modbus.setHost(host) 239 | await modbus.setPort(port) 240 | await modbus.setReverseBits(reverseBits) 241 | await modbus.setReverseWords(reverseWords) 242 | expect(modbus.host).toBe(host) 243 | expect(modbus.port).toBe(port) 244 | expect(modbus.reverseBits).toBe(reverseBits) 245 | expect(modbus.reverseWords).toBe(reverseWords) 246 | }) 247 | test(`Get timeout calls modbus-serial client getTimeout.`, () => { 248 | modbus.client.getTimeout.mockReset() 249 | modbus.client.getTimeout.mockReturnValueOnce(modbus._timeout) 250 | expect(modbus.timeout).toBe(modbus._timeout) 251 | expect(modbus.client.getTimeout).toBeCalledTimes(1) 252 | }) 253 | test(`If modbus.connected returns connected`, () => { 254 | modbus.connected = true 255 | expect(modbus.status).toBe(`connected`) 256 | }) 257 | test(`If modbus.connected returns connected`, () => { 258 | modbus.connected = false 259 | modbus.error = `There's an error` 260 | expect(modbus.status).toBe(modbus.error) 261 | }) 262 | test(`If not connected and no error, status is connecting`, () => { 263 | modbus.connected = false 264 | modbus.error = null 265 | expect(modbus.status).toBe(`connecting`) 266 | }) 267 | }) 268 | Modbus.instances = [] 269 | 270 | let scanClass = undefined 271 | describe(`Modbus Source: `, () => { 272 | test(`Create creates instance and adds to Modbus.sources.`, async () => { 273 | await ScanClass.initialize(db, pubsub).catch((error) => { 274 | throw error 275 | }) 276 | await Tag.initialize(db, pubsub).catch((error) => { 277 | throw error 278 | }) 279 | const modbus = Modbus.instances[0] 280 | const user = User.instances[0] 281 | scanClass = await ScanClass.create(1000, user) 282 | const tag = await Tag.create( 283 | 'testTag', 284 | 'Test Tag', 285 | 0, 286 | scanClass.id, 287 | user.id, 288 | `FLOAT` 289 | ) 290 | const modbusSource = await ModbusSource.create( 291 | modbus.id, 292 | tag.id, 293 | 1234, 294 | 'INPUT_REGISTER' 295 | ) 296 | expect(ModbusSource.instances[0].id).toBe(modbusSource.id) 297 | expect(modbus.sources[0].id).toBe(modbusSource.id) 298 | }) 299 | let modbusSource = null 300 | test(`check that init sets the appropriate underscore fields.`, async () => { 301 | const id = ModbusSource.instances[0].id 302 | const modbusId = ModbusSource.instances[0]._modbus 303 | const tagId = ModbusSource.instances[0]._tag 304 | const register = ModbusSource.instances[0]._register 305 | const registerType = ModbusSource.instances[0]._registerType 306 | ModbusSource.instances = [] 307 | modbusSource = new ModbusSource(id) 308 | await modbusSource.init() 309 | expect(modbusSource._modbus).toBe(modbusId) 310 | expect(modbusSource._tag).toBe(tagId) 311 | expect(modbusSource._register).toBe(register) 312 | expect(modbusSource._registerType).toBe(registerType) 313 | await ModbusSource.getAll() 314 | }) 315 | test('read with register type INPUT_REGISTER calls readHoldingRegister', async () => { 316 | ModbusRTU.prototype.readInputRegisters.mockImplementation( 317 | async (register, quantity, callback) => { 318 | const data = { data: [0, 0] } 319 | await callback(undefined, data) 320 | } 321 | ) 322 | ModbusSource.instances[0].modbus.connected = true 323 | await ModbusSource.instances[0].read() 324 | expect(ModbusRTU.prototype.readInputRegisters).toBeCalledTimes(1) 325 | expect(ModbusRTU.prototype.readHoldingRegisters).toBeCalledTimes(0) 326 | expect(ModbusRTU.prototype.readDiscreteInputs).toBeCalledTimes(0) 327 | }) 328 | test('read with register type HOLDING_REGISTER calls readHoldingRegister', async () => { 329 | ModbusRTU.prototype.readHoldingRegisters.mockImplementation( 330 | async (register, quantity, callback) => { 331 | const data = { data: [0, 0] } 332 | await callback(undefined, data) 333 | } 334 | ) 335 | ModbusSource.instances[0].tag.setDatatype(`INT32`) 336 | ModbusSource.instances[0].modbus.connected = true 337 | await ModbusSource.instances[0].setRegisterType('HOLDING_REGISTER') 338 | await ModbusSource.instances[0].read() 339 | expect(ModbusRTU.prototype.readInputRegisters).toBeCalledTimes(0) 340 | expect(ModbusRTU.prototype.readHoldingRegisters).toBeCalledTimes(1) 341 | expect(ModbusRTU.prototype.readDiscreteInputs).toBeCalledTimes(0) 342 | }) 343 | test('read with register type DISCRETE_INPUT calls readDiscreteInputs', async () => { 344 | ModbusRTU.prototype.readDiscreteInputs.mockImplementation( 345 | async (register, quantity, callback) => { 346 | const data = { data: [0] } 347 | await callback(undefined, data) 348 | } 349 | ) 350 | ModbusSource.instances[0].modbus.connected = true 351 | await ModbusSource.instances[0].setRegisterType('DISCRETE_INPUT') 352 | await ModbusSource.instances[0].read() 353 | expect(ModbusRTU.prototype.readInputRegisters).toBeCalledTimes(0) 354 | expect(ModbusRTU.prototype.readHoldingRegisters).toBeCalledTimes(0) 355 | expect(ModbusRTU.prototype.readDiscreteInputs).toBeCalledTimes(1) 356 | }) 357 | test.todo(`Test formatValue`) 358 | test(`Getters all return their underscore values`, () => { 359 | expect(modbusSource.register).toBe(modbusSource._register) 360 | expect(modbusSource.registerType).toBe(modbusSource._registerType) 361 | }) 362 | test(`Setters all set the values appropriately`, async () => { 363 | const register = 54321 364 | const registerType = `INT32` 365 | await modbusSource.setRegister(register) 366 | await modbusSource.setRegisterType(registerType) 367 | expect(modbusSource.register).toBe(register) 368 | expect(modbusSource.registerType).toBe(registerType) 369 | }) 370 | }) 371 | 372 | // ============================== 373 | // Ethernet/IP 374 | // ============================== 375 | 376 | let ethernetip = undefined 377 | describe(`EthernetIP :`, () => { 378 | test(`create creates a device with ethernetip config`, async () => { 379 | await User.initialize(db, pubsub) 380 | user = User.instances[0] 381 | const name = `testDevice` 382 | const description = `Test Device` 383 | const host = `localhost` 384 | const slot = 3 385 | const createdBy = user.id 386 | ethernetip = await EthernetIP.create( 387 | name, 388 | description, 389 | host, 390 | slot, 391 | createdBy 392 | ) 393 | device = ethernetip.device 394 | expect(ethernetip.device).toBe(Device.instances[1]) 395 | expect(ethernetip.device.name).toBe(name) 396 | expect(ethernetip.device.description).toBe(description) 397 | expect(ethernetip.host).toBe(host) 398 | expect(ethernetip.slot).toBe(slot) 399 | expect(ethernetip.device.createdBy.id).toBe(user.id) 400 | expect(ethernetip.client.constructor.name).toBe('Controller') 401 | }) 402 | test.todo(`rewrite connect tests to mock ethernet-ip module.`) 403 | test(`Connect calls Controller.connect and rejected results in a false connected status.`, async () => { 404 | ethernetip.client.connect.mockRejectedValueOnce( 405 | new Error(`Connection Error.`) 406 | ) 407 | await ethernetip.connect() 408 | expect(ethernetip.error).toMatchInlineSnapshot(`"Connection Error."`) 409 | expect(ethernetip.client.connect).toBeCalledTimes(1) 410 | expect(ethernetip.connected).toBe(false) 411 | ethernetip.client.connect.mockReset() 412 | }) 413 | test(`Connect calls Controller.connect and resolved results in a true connected status.`, async () => { 414 | ethernetip.client.connect.mockResolvedValueOnce({}) 415 | await ethernetip.connect() 416 | expect(ethernetip.error).toBe(null) 417 | expect(ethernetip.client.connect).toBeCalledTimes(1) 418 | expect(ethernetip.connected).toBe(true) 419 | ethernetip.client.connect.mockReset() 420 | }) 421 | test(`Disconnect calls client close throws an error on reject.`, async () => { 422 | ethernetip.client.destroy.mockImplementation(() => { 423 | throw new Error(`Close connection failed.`) 424 | }) 425 | expect(await ethernetip.disconnect().catch((e) => e)).toMatchInlineSnapshot( 426 | `[Error: Close connection failed.]` 427 | ) 428 | expect(ethernetip.client.destroy).toBeCalledTimes(1) 429 | expect(ethernetip.connected).toBe(true) 430 | ethernetip.client.destroy.mockClear() 431 | }) 432 | test(`Disconnect calls client close and connected status becomes false.`, async () => { 433 | ethernetip.client.destroy.mockImplementation(() => {}) 434 | await ethernetip.disconnect() 435 | expect(ethernetip.connected).toBe(false) 436 | }) 437 | }) 438 | 439 | describe(`EthernetIPSource: `, () => { 440 | let ethernetipSource = undefined 441 | test.todo(`rewrite read tests to mock ethernet-ip module.`) 442 | test(`read reads`, async () => { 443 | Controller.prototype.connect.mockResolvedValueOnce({}) 444 | await ethernetip.connect() 445 | ethernetip.client.readTag.mockImplementation(async (tagData) => { 446 | return new Promise((resolve, reject) => { 447 | tagData.value = 123.456 448 | resolve() 449 | }) 450 | }) 451 | const tag = await Tag.create( 452 | 'testEthernetIP', 453 | 'Test Ethernet IP Tag', 454 | 0, 455 | scanClass.id, 456 | user.id, 457 | `FLOAT` 458 | ) 459 | ethernetipSource = await EthernetIPSource.create( 460 | ethernetip.id, 461 | tag.id, 462 | 'RTU25A_7XFR5_FIT_001.VALUE' 463 | ) 464 | await ethernetipSource.read() 465 | expect(tag.value).toBeGreaterThan(0) 466 | }) 467 | test(`Getters all return their underscore values`, () => { 468 | expect(ethernetipSource.tagname).toBe(ethernetipSource._tagname) 469 | }) 470 | test(`Setters all set the values appropriately`, async () => { 471 | const tagname = `ADifferentTag` 472 | await ethernetipSource.setTagname(tagname) 473 | expect(ethernetipSource.tagname).toBe(tagname) 474 | }) 475 | }) 476 | 477 | // ============================== 478 | // OPCUA 479 | // ============================== 480 | 481 | let opcua = undefined 482 | describe('OPCUA: ', () => { 483 | test(`create creates a device with opcua config`, async () => { 484 | await User.initialize(db, pubsub) 485 | user = User.instances[0] 486 | const name = `testDevice` 487 | const description = `Test Device` 488 | const host = `localhost` 489 | const port = 1234 490 | const retryRate = 10000 491 | const createdBy = user.id 492 | opcua = await Opcua.create( 493 | name, 494 | description, 495 | host, 496 | port, 497 | retryRate, 498 | createdBy 499 | ) 500 | device = opcua.device 501 | expect(opcua.device).toBe(Device.instances[2]) 502 | expect(opcua.device.name).toBe(name) 503 | expect(opcua.device.description).toBe(description) 504 | expect(opcua.host).toBe(host) 505 | expect(opcua.port).toBe(port) 506 | expect(opcua.retryRate).toBe(retryRate) 507 | expect(opcua.device.createdBy.id).toBe(user.id) 508 | }) 509 | test(`Connect calls OPCUAClient.connect and rejected results in a false connected status.`, async () => { 510 | opcua.client.connect.mockRejectedValueOnce(new Error(`Connection Error.`)) 511 | await opcua.connect() 512 | expect(opcua.error).toMatchInlineSnapshot(`"Connection Error."`) 513 | expect(opcua.client.connect).toBeCalledTimes(1) 514 | expect(opcua.connected).toBe(false) 515 | opcua.client.connect.mockReset() 516 | }) 517 | test(`Connect calls OPCUAClient.connect and resolved results in a true connected status.`, async () => { 518 | opcua.client.connect.mockResolvedValueOnce({}) 519 | await opcua.connect() 520 | expect(opcua.error).toBe(null) 521 | expect(opcua.client.connect).toBeCalledTimes(1) 522 | expect(opcua.client.createSession).toBeCalledTimes(1) 523 | expect(opcua.connected).toBe(true) 524 | opcua.client.connect.mockReset() 525 | }) 526 | test(`Disconnect calls client close throws an error on reject.`, async () => { 527 | opcua.client.disconnect.mockImplementation(() => { 528 | throw new Error(`Close connection failed.`) 529 | }) 530 | expect(await opcua.disconnect().catch((e) => e)).toMatchInlineSnapshot( 531 | `[Error: Close connection failed.]` 532 | ) 533 | expect(opcua.client.disconnect).toBeCalledTimes(1) 534 | expect(opcua.session.close).toBeCalledTimes(1) 535 | expect(opcua.connected).toBe(true) 536 | opcua.client.disconnect.mockClear() 537 | }) 538 | test(`Disconnect calls client close and connected status becomes false.`, async () => { 539 | opcua.client.disconnect.mockImplementation(() => {}) 540 | await opcua.disconnect() 541 | expect(opcua.connected).toBe(false) 542 | }) 543 | test(`Getters all return their underscore values`, () => { 544 | expect(opcua.host).toBe(opcua._host) 545 | expect(opcua.port).toBe(opcua._port) 546 | expect(opcua.retryRate).toBe(opcua._retryRate) 547 | }) 548 | test(`Setters all set the values appropriately`, async () => { 549 | const host = `newHost` 550 | const port = 12345 551 | const retryRate = 30000 552 | await opcua.setHost(host) 553 | await opcua.setPort(port) 554 | await opcua.setRetryRate(retryRate) 555 | expect(opcua.host).toBe(host) 556 | expect(opcua.port).toBe(port) 557 | expect(opcua.retryRate).toBe(retryRate) 558 | }) 559 | 560 | describe(`OPCUASource: `, () => { 561 | let opcuaSource = undefined 562 | test.todo(`rewrite read tests to mock ethernet-ip module.`) 563 | test(`read reads`, async () => { 564 | opcua.client.connect.mockResolvedValueOnce({}) 565 | await opcua.connect() 566 | opcua.session.readVariableValue.mockImplementation(async (nodeId) => { 567 | return new Promise((resolve, reject) => { 568 | resolve({ 569 | value: { 570 | value: 123.45, 571 | }, 572 | }) 573 | }) 574 | }) 575 | const tag = await Tag.create( 576 | 'testOpcua', 577 | 'Test OPC-UA Tag', 578 | 0, 579 | scanClass.id, 580 | user.id, 581 | `FLOAT` 582 | ) 583 | opcuaSource = await OpcuaSource.create(opcua.id, tag.id, 'n1') 584 | await opcuaSource.read() 585 | expect(tag.value).toBeGreaterThan(0) 586 | opcua.session.readVariableValue.mockReset() 587 | }) 588 | test(`write writes`, async () => { 589 | opcua.client.connect.mockResolvedValueOnce({}) 590 | opcua.session.writeSingleNode.mockResolvedValueOnce({}) 591 | await opcua.connect() 592 | await opcuaSource.write() 593 | opcua.session.writeSingleNode.mockResolvedValueOnce({}) 594 | await opcuaSource.tag.setDatatype('INT32') 595 | await opcuaSource.write() 596 | opcua.session.writeSingleNode.mockResolvedValueOnce({}) 597 | await opcuaSource.tag.setDatatype('BOOLEAN') 598 | await opcuaSource.write() 599 | opcua.session.writeSingleNode.mockResolvedValueOnce({}) 600 | await opcuaSource.tag.setDatatype('STRING') 601 | await opcuaSource.write() 602 | opcua.session.writeSingleNode.mockRejectedValueOnce({}) 603 | await opcuaSource.write() 604 | await opcua.disconnect() 605 | await opcuaSource.write() 606 | expect(opcua.session.writeSingleNode).toBeCalledTimes(5) 607 | opcua.session.writeSingleNode.mockReset() 608 | }) 609 | test(`Getters all return their underscore values`, () => { 610 | expect(opcuaSource.nodeId).toBe(opcuaSource._nodeId) 611 | }) 612 | test(`Setters all set the values appropriately`, async () => { 613 | const nodeId = `ADifferentNodeId` 614 | await opcuaSource.setNodeId(nodeId) 615 | expect(opcuaSource.nodeId).toBe(nodeId) 616 | }) 617 | test(`browse browses.`, async () => { 618 | opcua.client.connect.mockResolvedValueOnce({}) 619 | await opcua.browse() 620 | await opcua.browse('', true) 621 | await opcua.connect() 622 | await opcua.browse() 623 | await opcua.browse('', true) 624 | }) 625 | }) 626 | }) 627 | -------------------------------------------------------------------------------- /src/device/device.js: -------------------------------------------------------------------------------- 1 | const { Model } = require('../database') 2 | const { Opcua, OpcuaSource } = require('./opcua') 3 | const { Modbus, ModbusSource } = require('./modbus') 4 | const { EthernetIP, EthernetIPSource } = require('./ethernetip') 5 | const getUnixTime = require('date-fns/getUnixTime') 6 | const fromUnixTime = require('date-fns/fromUnixTime') 7 | 8 | class Device extends Model { 9 | static async initialize(db, pubsub) { 10 | await Opcua.initialize(db, pubsub) 11 | await Modbus.initialize(db, pubsub) 12 | await EthernetIP.initialize(db, pubsub) 13 | return super.initialize(db, pubsub, Device) 14 | } 15 | static create(name, description, type, createdBy) { 16 | const createdOn = getUnixTime(new Date()) 17 | const fields = { 18 | name, 19 | description, 20 | type, 21 | createdBy, 22 | createdOn, 23 | } 24 | return super.create(fields) 25 | } 26 | static async delete(selector) { 27 | for (const instance of this.instances) { 28 | if (instance.config) { 29 | await instance.config.disconnect() 30 | } 31 | } 32 | const deleted = await super.delete(selector) 33 | await Opcua.getAll() 34 | await OpcuaSource.getAll() 35 | await ModbusSource.getAll() 36 | await Modbus.getAll() 37 | await EthernetIPSource.getAll() 38 | await EthernetIP.getAll() 39 | for (const instance of this.instances) { 40 | if (instance.config) { 41 | await instance.config.connect() 42 | } 43 | } 44 | return deleted 45 | } 46 | async init() { 47 | const result = await super.init() 48 | this._name = result.name 49 | this._description = result.description 50 | this._type = result.type 51 | this._createdBy = result.createdBy 52 | this._createdOn = result.createdOn 53 | } 54 | get name() { 55 | this.checkInit() 56 | return this._name 57 | } 58 | setName(value) { 59 | return this.update(this.id, 'name', value).then( 60 | (result) => (this._name = result) 61 | ) 62 | } 63 | get description() { 64 | this.checkInit() 65 | return this._description 66 | } 67 | setDescription(value) { 68 | return this.update(this.id, 'description', value).then( 69 | (result) => (this._description = result) 70 | ) 71 | } 72 | get type() { 73 | this.checkInit() 74 | return this._type 75 | } 76 | get createdOn() { 77 | this.checkInit() 78 | return fromUnixTime(this._createdOn) 79 | } 80 | } 81 | Device.table = `device` 82 | Device.fields = [ 83 | { colName: 'name', colType: 'TEXT' }, 84 | { colName: 'description', colType: 'TEXT' }, 85 | { colName: 'type', colType: 'TEXT' }, 86 | { colName: 'createdBy', colRef: 'user', onDelete: 'SET NULL' }, 87 | { colName: 'createdOn', colType: 'INTEGER' }, 88 | ] 89 | Device.instances = [] 90 | Device.initialized = false 91 | 92 | module.exports = { 93 | Device, 94 | } 95 | -------------------------------------------------------------------------------- /src/device/ethernetip.js: -------------------------------------------------------------------------------- 1 | const { Model } = require(`../database`) 2 | const { Controller, Tag } = require('ethernet-ip') 3 | const logger = require(`../logger`) 4 | 5 | class EthernetIP extends Model { 6 | static async initialize(db, pubsub) { 7 | await EthernetIPSource.initialize(db, pubsub) 8 | const result = await super.initialize(db, pubsub) 9 | if (this.tableExisted && this.version < 6) { 10 | const newColumns = [ 11 | { colName: 'retryRate', colType: 'INTEGER', default: 5000 }, 12 | ] 13 | for (const column of newColumns) { 14 | let sql = `ALTER TABLE "${this.table}" ADD "${column.colName}" ${column.colType}` 15 | if (column.default) { 16 | sql = `${sql} DEFAULT ${column.default}` 17 | } 18 | await this.executeUpdate(sql) 19 | } 20 | } 21 | return result 22 | } 23 | static _createModel(fields) { 24 | return super.create(fields) 25 | } 26 | static async delete(selector) { 27 | const deleted = super.delete(selector) 28 | EthernetIPSource.getAll() 29 | return deleted 30 | } 31 | constructor(selector, checkExists = true) { 32 | super(selector, checkExists) 33 | this.client = new Controller() 34 | } 35 | async init() { 36 | const result = await super.init() 37 | this._device = result.device 38 | this._host = result.host 39 | this._slot = result.slot 40 | this._retryRate = 50000 41 | this.connected = false 42 | this.error = null 43 | this.retryCount = 0 44 | } 45 | async connect() { 46 | if (!this.connected) { 47 | this.error = null 48 | logger.info( 49 | `Connecting to ethernetip device ${this.device.name}, host: ${this.host}, slot: ${this.slot}.` 50 | ) 51 | if (!this.client) { 52 | this.client = new Controller() 53 | } 54 | await this.client.connect(this.host, this.slot).catch((error) => { 55 | this.error = error.message 56 | this.connected = false 57 | if (!this.retryInterval) { 58 | this.retryInterval = setInterval(async () => { 59 | if (this.device) { 60 | logger.info( 61 | `Retrying connection to ethernetip device ${this.device.name}, retry attempts: ${this.retryCount}.` 62 | ) 63 | this.retryCount += 1 64 | await this.connect() 65 | } else { 66 | clearInterval(this.retryInterval) 67 | } 68 | }, this.retryRate) 69 | } 70 | }) 71 | if (!this.error) { 72 | this.retryCount = 0 73 | this.retryInterval = clearInterval(this.retryInterval) 74 | logger.info( 75 | `Connected to ethernetip device ${this.device.name}, host: ${this.host}, slot: ${this.slot}.` 76 | ) 77 | this.connected = true 78 | } else { 79 | this.connected = false 80 | logger.info( 81 | `Connection failed to ethernetip device ${this.device.name}, host: ${this.host}, slot: ${this.slot}, with error: ${this.error}.` 82 | ) 83 | } 84 | this.pubsub.publish('deviceUpdate', { 85 | deviceUpdate: this.device, 86 | }) 87 | } 88 | } 89 | async disconnect() { 90 | this.retryCount = 0 91 | this.retryInterval = clearInterval(this.retryInterval) 92 | logger.info(`Disconnecting from ethernetip device ${this.device.name}`) 93 | const logText = `Closed connection to ethernetip device ${this.device.name}` 94 | if (this.client) { 95 | this.client.destroy() 96 | } 97 | this.client = null 98 | logger.info(logText) 99 | this.connected = false 100 | this.pubsub.publish('deviceUpdate', { 101 | deviceUpdate: this.device, 102 | }) 103 | } 104 | get host() { 105 | this.checkInit() 106 | return this._host 107 | } 108 | setHost(value) { 109 | return this.update(this.id, 'host', value).then( 110 | (result) => (this._host = result) 111 | ) 112 | } 113 | get slot() { 114 | this.checkInit() 115 | return this._slot 116 | } 117 | setSlot(value) { 118 | return this.update(this.id, 'slot', value).then( 119 | (result) => (this._slot = result) 120 | ) 121 | } 122 | get retryRate() { 123 | this.checkInit() 124 | return this._retryRate 125 | } 126 | setRetryRate(value) { 127 | return this.update(this.id, 'retryRate', value).then( 128 | (result) => (this._retryRate = result) 129 | ) 130 | } 131 | get status() { 132 | if (this.connected) { 133 | return `connected` 134 | } else if (this.error) { 135 | return this.error 136 | } else { 137 | return `connecting` 138 | } 139 | } 140 | } 141 | EthernetIP.table = `ethernetip` 142 | EthernetIP.fields = [ 143 | { colName: 'device', colRef: 'device', onDelete: 'CASCADE' }, 144 | { colName: 'host', colType: 'TEXT' }, 145 | { colName: 'slot', colType: 'INTEGER' }, 146 | { colName: 'retryRate', colType: 'INTEGER' }, 147 | ] 148 | EthernetIP.instances = [] 149 | EthernetIP.initialized = false 150 | 151 | class EthernetIPSource extends Model { 152 | static create(ethernetip, tag, tagname) { 153 | const fields = { 154 | ethernetip, 155 | tag, 156 | tagname, 157 | } 158 | return super.create(fields) 159 | } 160 | async init() { 161 | const result = await super.init() 162 | this._ethernetip = result.ethernetip 163 | this._tag = result.tag 164 | this._tagname = result.tagname 165 | this.tagData = new Tag(this._tagname) 166 | } 167 | async read() { 168 | if (this.ethernetip.connected) { 169 | await this.ethernetip.client.readTag(this.tagData).catch((error) => { 170 | logger.error(error) 171 | }) 172 | await this.tag.setValue(this.tagData.value, false) 173 | } 174 | } 175 | async write(value) { 176 | if (this.ethernetip.connected) { 177 | await this.ethernetip.client.writeTag(this.tagData, value) 178 | } 179 | } 180 | get tagname() { 181 | this.checkInit() 182 | return this._tagname 183 | } 184 | setTagname(value) { 185 | return this.update(this.id, 'tagname', value).then( 186 | (result) => (this._tagname = result) 187 | ) 188 | } 189 | } 190 | EthernetIPSource.table = `ethernetipSource` 191 | EthernetIPSource.fields = [ 192 | { colName: 'ethernetip', colRef: 'ethernetip', onDelete: 'CASCADE' }, 193 | { colName: 'tag', colRef: 'tag', onDelete: 'CASCADE' }, 194 | { colName: 'tagname', colType: 'TEXT' }, 195 | ] 196 | EthernetIPSource.instances = [] 197 | EthernetIPSource.initialized = false 198 | 199 | module.exports = { 200 | EthernetIP, 201 | EthernetIPSource, 202 | } 203 | -------------------------------------------------------------------------------- /src/device/index.js: -------------------------------------------------------------------------------- 1 | const { Device } = require('./device') 2 | const { Modbus, ModbusSource } = require('./modbus') 3 | const { EthernetIP, EthernetIPSource } = require('./ethernetip') 4 | const { Opcua, OpcuaSource } = require('./opcua') 5 | 6 | module.exports = { 7 | Device, 8 | Opcua, 9 | OpcuaSource, 10 | Modbus, 11 | ModbusSource, 12 | EthernetIP, 13 | EthernetIPSource, 14 | } 15 | -------------------------------------------------------------------------------- /src/device/modbus.js: -------------------------------------------------------------------------------- 1 | const { Model } = require(`../database`) 2 | const ModbusRTU = require(`modbus-serial`) 3 | const logger = require(`../logger`) 4 | 5 | class Modbus extends Model { 6 | static async initialize(db, pubsub) { 7 | await ModbusSource.initialize(db, pubsub) 8 | const result = await super.initialize(db, pubsub) 9 | if (this.tableExisted && this.version < 4) { 10 | const newColumns = [ 11 | { colName: 'retryRate', colType: 'INTEGER', default: 5000 }, 12 | ] 13 | for (const column of newColumns) { 14 | let sql = `ALTER TABLE "${this.table}" ADD "${column.colName}" ${column.colType}` 15 | if (column.default) { 16 | sql = `${sql} DEFAULT ${column.default}` 17 | } 18 | await this.executeUpdate(sql) 19 | } 20 | } 21 | return result 22 | } 23 | static _createModel(fields) { 24 | return super.create(fields) 25 | } 26 | static async delete(selector) { 27 | const deleted = super.delete(selector) 28 | ModbusSource.getAll() 29 | return deleted 30 | } 31 | constructor(selector, checkExists = true) { 32 | super(selector, checkExists) 33 | this.client = new ModbusRTU() 34 | this.client.setID(0) 35 | } 36 | async init() { 37 | const result = await super.init() 38 | this._device = result.device 39 | this._host = result.host 40 | this._port = result.port 41 | this._reverseBits = result.reverseBits 42 | this._reverseWords = result.reverseWords 43 | this._zeroBased = result.zeroBased 44 | this.client.setTimeout(result.timeout) 45 | this._retryRate = result.retryRate 46 | this.connected = false 47 | this.error = null 48 | this.retryCount = 0 49 | } 50 | async connect() { 51 | if (!this.connected) { 52 | this.error = null 53 | logger.info( 54 | `Connecting to modbus device ${this.device.name}, host: ${this.host}, port: ${this.port}.` 55 | ) 56 | await this.client 57 | .connectTCP(this.host, { port: this.port }) 58 | .catch((error) => { 59 | this.error = error.message 60 | this.connected = false 61 | if (!this.retryInterval) { 62 | this.retryInterval = setInterval(async () => { 63 | logger.info( 64 | `Retrying connection to modbus device ${this.device.name}, retry attempts: ${this.retryCount}.` 65 | ) 66 | this.retryCount += 1 67 | await this.connect() 68 | }, this.retryRate) 69 | } 70 | }) 71 | if (!this.error) { 72 | this.retryCount = 0 73 | this.retryInterval = clearInterval(this.retryInterval) 74 | logger.info( 75 | `Connected to modbus device ${this.device.name}, host: ${this.host}, port: ${this.port}.` 76 | ) 77 | this.connected = true 78 | } else { 79 | this.connected = false 80 | logger.info( 81 | `Connection failed to modbus device ${this.device.name}, host: ${this.host}, port: ${this.port}, with error: ${this.error}.` 82 | ) 83 | } 84 | this.pubsub.publish('deviceUpdate', { 85 | deviceUpdate: this.device, 86 | }) 87 | } 88 | } 89 | async disconnect() { 90 | await new Promise((resolve) => { 91 | this.retryCount = 0 92 | this.retryInterval = clearInterval(this.retryInterval) 93 | logger.info(`Disconnecting from modbus device ${this.device.name}`) 94 | const logText = `Closed connection to modbus device ${this.device.name}.` 95 | if (this.connected) { 96 | this.client.close(() => {}) 97 | logger.info(logText) 98 | resolve() 99 | } else { 100 | logger.info(logText) 101 | resolve() 102 | } 103 | }) 104 | this.connected = false 105 | this.pubsub.publish('deviceUpdate', { 106 | deviceUpdate: this.device, 107 | }) 108 | } 109 | get host() { 110 | this.checkInit() 111 | return this._host 112 | } 113 | setHost(value) { 114 | return this.update(this.id, 'host', value).then( 115 | (result) => (this._host = result) 116 | ) 117 | } 118 | get port() { 119 | this.checkInit() 120 | return this._port 121 | } 122 | setPort(value) { 123 | return this.update(this.id, 'port', value).then( 124 | (result) => (this._port = result) 125 | ) 126 | } 127 | get reverseBits() { 128 | this.checkInit() 129 | return Boolean(this._reverseBits) 130 | } 131 | setReverseBits(value) { 132 | return this.update(this.id, 'reverseBits', value).then((result) => { 133 | this._reverseBits = result 134 | }) 135 | } 136 | get reverseWords() { 137 | this.checkInit() 138 | return Boolean(this._reverseWords) 139 | } 140 | setReverseWords(value) { 141 | return this.update(this.id, 'reverseWords', value).then( 142 | (result) => (this._reverseWords = result) 143 | ) 144 | } 145 | get zeroBased() { 146 | this.checkInit() 147 | return Boolean(this._zeroBased) 148 | } 149 | setZeroBased(value) { 150 | return this.update(this.id, 'zeroBased', value).then( 151 | (result) => (this._zeroBased = result) 152 | ) 153 | } 154 | get timeout() { 155 | this.checkInit() 156 | return this.client.getTimeout() 157 | } 158 | setTimeout(value) { 159 | return this.update(this.id, 'timeout', value).then((result) => 160 | this.client.setTimeout(result) 161 | ) 162 | } 163 | get retryRate() { 164 | this.checkInit() 165 | return this._retryRate 166 | } 167 | setRetryRate(value) { 168 | return this.update(this.id, 'retryRate', value).then( 169 | (result) => (this._retryRate = result) 170 | ) 171 | } 172 | get status() { 173 | if (this.connected) { 174 | return `connected` 175 | } else if (this.error) { 176 | return this.error 177 | } else { 178 | return `connecting` 179 | } 180 | } 181 | } 182 | Modbus.table = `modbus` 183 | Modbus.fields = [ 184 | { colName: 'device', colRef: 'device', onDelete: 'CASCADE' }, 185 | { colName: 'host', colType: 'TEXT' }, 186 | { colName: 'port', colType: 'INTEGER' }, 187 | { colName: 'reverseBits', colType: 'INTEGER' }, 188 | { colName: 'reverseWords', colType: 'INTEGER' }, 189 | { colName: 'zeroBased', colType: 'INTEGER' }, 190 | { colName: 'timeout', colType: 'INTEGER' }, 191 | { colName: 'retryRate', colType: 'INTEGER' }, 192 | ] 193 | Modbus.instances = [] 194 | Modbus.initialized = false 195 | 196 | class ModbusSource extends Model { 197 | static create(modbus, tag, register, registerType) { 198 | const fields = { 199 | modbus, 200 | tag, 201 | register, 202 | registerType, 203 | } 204 | return super.create(fields) 205 | } 206 | async init() { 207 | const result = await super.init() 208 | this._modbus = result.modbus 209 | this._tag = result.tag 210 | this._register = result.register 211 | this._registerType = result.registerType 212 | } 213 | formatValue(data) { 214 | const buffer = new ArrayBuffer(4) 215 | const view = new DataView(buffer) 216 | let value = null 217 | if (this.tag.datatype === `FLOAT`) { 218 | view.setUint16( 219 | 0, 220 | this.modbus.reverseWords ? data[1] : data[0], 221 | this.modbus.reverseBits 222 | ) 223 | view.setUint16( 224 | 2, 225 | this.modbus.reverseWords ? data[0] : data[1], 226 | this.modbus.reverseBits 227 | ) 228 | value = view.getFloat32(0, this.modbus.reverseBits) 229 | } else if (this.tag.datatype === `INT32`) { 230 | view.setInt16( 231 | 0, 232 | this.modbus.reverseWords ? data[1] : data[0], 233 | this.modbus.reverseBits 234 | ) 235 | view.setInt16( 236 | 2, 237 | this.modbus.reverseWords ? data[0] : data[1], 238 | this.modbus.reverseBits 239 | ) 240 | value = view.getInt32(0, this.modbus.reverseBits) 241 | } else if (this.tag.datatype === `INT16`) { 242 | view.setInt16(0, data[0], this.modbus.reverseBits) 243 | value = view.getInt16(0, this.modbus.reverseBits) 244 | } 245 | return value 246 | } 247 | formatOutput(value) { 248 | const buffer = new ArrayBuffer(4) 249 | const view = new DataView(buffer) 250 | let data = [] 251 | if (this.tag.datatype === `FLOAT`) { 252 | view.setFloat32(0, value) 253 | data.push( 254 | view.getUint16( 255 | this.modbus.reverseWords ? 2 : 0, 256 | this.modbus.reverseBits 257 | ) 258 | ) 259 | data.push( 260 | view.getUint16( 261 | this.modbus.reverseWords ? 0 : 2, 262 | this.modbus.reverseBits 263 | ) 264 | ) 265 | } else if (this.tag.datatype === `INT32`) { 266 | view.setInt32(0, value) 267 | data.push( 268 | view.getUint16( 269 | this.modbus.reverseWords ? 0 : 2, 270 | !this.modbus.reverseBits 271 | ) 272 | ) 273 | data.push( 274 | view.getUint16( 275 | this.modbus.reverseWords ? 2 : 0, 276 | !this.modbus.reverseBits 277 | ) 278 | ) 279 | } 280 | return data 281 | } 282 | async read() { 283 | if (this.modbus.connected) { 284 | if (this.registerType === 'INPUT_REGISTER') { 285 | const quantity = this.format === 'INT16' ? 1 : 2 286 | return new Promise((resolve, reject) => { 287 | this.modbus.client.readInputRegisters( 288 | this.register, 289 | quantity, 290 | async (error, data) => { 291 | if (error) { 292 | reject(error) 293 | return 294 | } 295 | if (data) { 296 | await this.tag.setValue(this.formatValue(data.data), false) 297 | } 298 | resolve() 299 | } 300 | ) 301 | }).catch(async (error) => { 302 | if ( 303 | error.name === 'TransactionTimedOutError' || 304 | error.name === 'PortNotOpenError' 305 | ) { 306 | logger.info(`Connection Timed Out on device ${this.device.name}`) 307 | await this.modbus.disconnect() 308 | await this.modbus.connect() 309 | } else { 310 | logger.error(error) 311 | } 312 | }) 313 | } else if (this.registerType === 'HOLDING_REGISTER') { 314 | const quantity = this.format === 'INT16' ? 1 : 2 315 | return new Promise((resolve, reject) => { 316 | this.modbus.client.readHoldingRegisters( 317 | this.register, 318 | quantity, 319 | (error, data) => { 320 | if (error) { 321 | reject(error) 322 | return 323 | } 324 | if (data) { 325 | this.tag.setValue(this.formatValue(data.data), false) 326 | } 327 | resolve() 328 | } 329 | ) 330 | }).catch(async (error) => { 331 | if (error.name === 'TransactionTimedOutError') { 332 | await this.modbus.disconnect() 333 | await this.modbus.connect() 334 | } else { 335 | logger.error(error) 336 | } 337 | }) 338 | } else if (this.registerType === 'DISCRETE_INPUT') { 339 | return new Promise((resolve, reject) => { 340 | this.modbus.client.readDiscreteInputs( 341 | this.register, 342 | 1, 343 | async (error, data) => { 344 | if (error) { 345 | reject(error) 346 | return 347 | } else { 348 | if (data) { 349 | await this.tag.setValue(data.data[0], false) 350 | } 351 | resolve() 352 | } 353 | } 354 | ) 355 | }).catch(async (error) => { 356 | if (error.name === 'TransactionTimedOutError') { 357 | await this.modbus.disconnect() 358 | await this.modbus.connect() 359 | } else { 360 | logger.error(error) 361 | } 362 | }) 363 | } else if (this.registerType === 'COIL') { 364 | return new Promise((resolve, reject) => { 365 | this.modbus.client.readCoils( 366 | this.register, 367 | 1, 368 | async (error, data) => { 369 | if (error) { 370 | reject(error) 371 | return 372 | } else { 373 | if (data) { 374 | await this.tag.setValue(data.data[0], false) 375 | } 376 | resolve() 377 | } 378 | } 379 | ) 380 | }).catch(async (error) => { 381 | if (error.name === 'TransactionTimedOutError') { 382 | await this.modbus.disconnect() 383 | await this.modbus.connect() 384 | } else { 385 | logger.error(error) 386 | } 387 | }) 388 | } 389 | } 390 | } 391 | async write(value) { 392 | if (this.modbus.connected) { 393 | if (this.registerType === 'HOLDING_REGISTER') { 394 | return new Promise((resolve, reject) => { 395 | this.modbus.client.writeRegisters( 396 | this.register, 397 | this.formatOutput(value), 398 | (error) => { 399 | if (error) { 400 | reject(error) 401 | return 402 | } 403 | resolve() 404 | } 405 | ) 406 | }).catch(async (error) => { 407 | if (error.name === 'TransactionTimedOutError') { 408 | await this.modbus.disconnect() 409 | await this.modbus.connect() 410 | } else { 411 | throw error 412 | } 413 | }) 414 | } else if (this.registerType === 'COIL') { 415 | return new Promise((resolve, reject) => { 416 | this.modbus.client.writeCoil( 417 | this.register, 418 | this.value + '' === 'true' 419 | ) 420 | }) 421 | } 422 | } 423 | } 424 | get register() { 425 | this.checkInit() 426 | return this._register 427 | } 428 | setRegister(value) { 429 | return this.update(this.id, 'register', value).then( 430 | (result) => (this._register = result) 431 | ) 432 | } 433 | get registerType() { 434 | this.checkInit() 435 | return this._registerType 436 | } 437 | setRegisterType(value) { 438 | return this.update(this.id, 'registerType', value).then( 439 | (result) => (this._registerType = result) 440 | ) 441 | } 442 | } 443 | ModbusSource.table = `modbusSource` 444 | ModbusSource.fields = [ 445 | { colName: 'modbus', colRef: 'modbus', onDelete: 'CASCADE' }, 446 | { colName: 'tag', colRef: 'tag', onDelete: 'CASCADE' }, 447 | { colName: 'register', colType: 'INTEGER' }, 448 | { colName: 'registerType', colType: 'TEXT' }, 449 | ] 450 | ModbusSource.instances = [] 451 | ModbusSource.initialized = false 452 | 453 | module.exports = { 454 | Modbus, 455 | ModbusSource, 456 | } 457 | -------------------------------------------------------------------------------- /src/device/opcua.js: -------------------------------------------------------------------------------- 1 | const { Model } = require(`../database`) 2 | const { 3 | DataType, 4 | OPCUAClient, 5 | MessageSecurityMode, 6 | SecurityPolicy, 7 | makeBrowsePath, 8 | NodeCrawler, 9 | } = require('node-opcua') 10 | const logger = require('../logger') 11 | const treeify = require('treeify') 12 | 13 | class Opcua extends Model { 14 | static async initialize(db, pubsub) { 15 | await OpcuaSource.initialize(db, pubsub) 16 | const result = await super.initialize(db, pubsub) 17 | return result 18 | } 19 | static _createModel(fields) { 20 | return super.create(fields) 21 | } 22 | static async delete(selector) { 23 | const deleted = super.delete(selector) 24 | return deleted 25 | } 26 | constructor(selector, checkExists = true) { 27 | super(selector, checkExists) 28 | const connectionStrategy = { 29 | initialDelay: 1000, 30 | maxRetry: 1, 31 | } 32 | const options = { 33 | applicationName: 'tentacle', 34 | connectionStrategy: connectionStrategy, 35 | securityMode: MessageSecurityMode.None, 36 | securityPolicy: SecurityPolicy.None, 37 | endpoint_must_exist: false, 38 | } 39 | this.client = OPCUAClient.create(options) 40 | this.client.on('connection_failed', async () => { 41 | if (this.connected) { 42 | await this.disconnect() 43 | await this.connect() 44 | } 45 | }) 46 | this.client.on('connection_lost', async () => { 47 | if (this.connected) { 48 | await this.disconnect() 49 | await this.connect() 50 | } 51 | }) 52 | } 53 | async init() { 54 | const result = await super.init() 55 | this._device = result.device 56 | this._host = result.host 57 | this._port = result.port 58 | this._retryRate = result.retryRate 59 | this.connected = false 60 | this.error = null 61 | this.retryCount = 0 62 | this.nodes = null 63 | } 64 | async connect() { 65 | if (!this.connected) { 66 | this.error = null 67 | logger.info( 68 | `Connecting to opcua device ${this.device.name}, host: ${this.host}, port: ${this.port}.` 69 | ) 70 | await this.client 71 | .connect(`opc.tcp://${this.host}:${this.port}`) 72 | .catch((error) => { 73 | this.error = error.message 74 | this.connected = false 75 | if (!this.retryInterval) { 76 | this.retryInterval = setInterval(async () => { 77 | logger.info( 78 | `Retrying connection to opcua device ${this.device.name}, retry attempts: ${this.retryCount}.` 79 | ) 80 | this.retryCount += 1 81 | await this.connect() 82 | }, this.retryRate) 83 | } 84 | }) 85 | if (!this.error) { 86 | this.retryCount = 0 87 | this.retryInterval = clearInterval(this.retryInterval) 88 | logger.info( 89 | `Connected to opcua device ${this.device.name}, host: ${this.host}, port: ${this.port}.` 90 | ) 91 | this.connected = true 92 | this.session = await this.client.createSession() 93 | } else { 94 | this.connected = false 95 | logger.info( 96 | `Connection failed to opcua device ${this.device.name}, host: ${this.host}, port: ${this.port}, with error: ${this.error}.` 97 | ) 98 | } 99 | this.pubsub.publish('deviceUpdate', { 100 | deviceUpdate: this.device, 101 | }) 102 | } 103 | } 104 | async disconnect() { 105 | this.retryCount = 0 106 | this.retryInterval = clearInterval(this.retryInterval) 107 | logger.info(`Disconnecting from modbus device ${this.device.name}`) 108 | const logText = `Closed connection to modbus device ${this.device.name}.` 109 | if (this.connected) { 110 | await this.session.close() 111 | await this.client.disconnect() 112 | logger.info(logText) 113 | } else { 114 | logger.info(logText) 115 | } 116 | this.connected = false 117 | this.pubsub.publish('deviceUpdate', { 118 | deviceUpdate: this.device, 119 | }) 120 | } 121 | get host() { 122 | this.checkInit() 123 | return this._host 124 | } 125 | setHost(value) { 126 | return this.update(this.id, 'host', value).then( 127 | (result) => (this._host = result) 128 | ) 129 | } 130 | get port() { 131 | this.checkInit() 132 | return this._port 133 | } 134 | setPort(value) { 135 | return this.update(this.id, 'port', value).then( 136 | (result) => (this._port = result) 137 | ) 138 | } 139 | get retryRate() { 140 | this.checkInit() 141 | return this._retryRate 142 | } 143 | setRetryRate(value) { 144 | return this.update(this.id, 'retryRate', value).then( 145 | (result) => (this._retryRate = result) 146 | ) 147 | } 148 | get status() { 149 | if (this.connected) { 150 | return `connected` 151 | } else if (this.error) { 152 | return this.error 153 | } else { 154 | return `connecting` 155 | } 156 | } 157 | async browse(nodeId, flat = false) { 158 | if (this.connected) { 159 | return new Promise((resolve, reject) => { 160 | const crawler = new NodeCrawler(this.session) 161 | let firstScan = true 162 | let flatResult = [] 163 | if (flat) { 164 | crawler.on('browsed', (element) => { 165 | if (element.dataValue) { 166 | flatResult.push({ 167 | nodeId: element.nodeId.toString(), 168 | browseName: `${element.nodeId.toString()},${ 169 | element.browseName.name 170 | }`, 171 | }) 172 | } 173 | }) 174 | } 175 | crawler.read(nodeId || 'ObjectsFolder', (err, obj) => { 176 | if (!err) { 177 | if (flat) { 178 | resolve(flatResult) 179 | } else { 180 | resolve(obj) 181 | } 182 | } else { 183 | reject(err) 184 | } 185 | }) 186 | }) 187 | } else { 188 | return flat ? [] : null 189 | } 190 | } 191 | } 192 | Opcua.table = `opcua` 193 | Opcua.fields = [ 194 | { colName: 'device', colRef: 'device', onDelete: 'CASCADE' }, 195 | { colName: 'host', colType: 'TEXT' }, 196 | { colName: 'port', colType: 'INTEGER' }, 197 | { colName: 'retryRate', colType: 'INTEGER' }, 198 | ] 199 | Opcua.instances = [] 200 | Opcua.initialized = false 201 | 202 | class OpcuaSource extends Model { 203 | static create(opcua, tag, nodeId) { 204 | const fields = { 205 | opcua, 206 | tag, 207 | nodeId, 208 | } 209 | return super.create(fields) 210 | } 211 | async init() { 212 | const result = await super.init() 213 | this._opcua = result.opcua 214 | this._tag = result.tag 215 | this._nodeId = result.nodeId 216 | } 217 | async read() { 218 | if (this.opcua.connected) { 219 | try { 220 | const { 221 | value: { value }, 222 | } = await this.opcua.session 223 | .readVariableValue(this.nodeId) 224 | .catch((error) => logger.error(error)) 225 | await this.tag.setValue(value, false) 226 | } catch (error) { 227 | logger.error(error) 228 | } 229 | } 230 | } 231 | async write(inputValue) { 232 | if (this.opcua.connected) { 233 | let dataType 234 | let value 235 | if (this.tag.datatype === 'BOOLEAN') { 236 | dataType = DataType.Boolean 237 | value = inputValue + '' === 'true' 238 | } else if (this.tag.datatype === 'FLOAT') { 239 | dataType = DataType.Float 240 | value = parseFloat(value) 241 | } else if (this.tag.datatype === 'INT32') { 242 | dataType = DataType.Int32 243 | value = parseInt(value) 244 | } else { 245 | dataType = DataType.String 246 | value = inputValue 247 | } 248 | await this.opcua.session 249 | .writeSingleNode(this.nodeId, { value, dataType }) 250 | .catch((error) => logger.error(error)) 251 | } 252 | } 253 | get nodeId() { 254 | this.checkInit() 255 | return this._nodeId 256 | } 257 | setNodeId(value) { 258 | return this.update(this.id, 'nodeId', value).then( 259 | (result) => (this._nodeId = result) 260 | ) 261 | } 262 | } 263 | OpcuaSource.table = `opcuaSource` 264 | OpcuaSource.fields = [ 265 | { colName: 'opcua', colRef: 'opcua', onDelete: 'CASCADE' }, 266 | { colName: 'tag', colRef: 'tag', onDelete: 'CASCADE' }, 267 | { colName: 'nodeId', colType: 'TEXT' }, 268 | ] 269 | OpcuaSource.instances = [] 270 | OpcuaSource.initialized = false 271 | 272 | module.exports = { 273 | Opcua, 274 | OpcuaSource, 275 | } 276 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('make-promises-safe') 3 | const { start } = require('./server') 4 | start('tentacle-edge') 5 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | const { createLogger, format, transports } = require('winston') 2 | 3 | const logger = createLogger({ 4 | level: 'info', 5 | format: format.combine( 6 | format.timestamp({ 7 | format: 'YYYY-MM-DD HH:mm:ss', 8 | }), 9 | format.errors({ stack: true }), 10 | format.splat(), 11 | format.json() 12 | ), 13 | defaultMeta: { service: 'tentacle' }, 14 | transports: [ 15 | // 16 | // - Write to all logs with level `info` and below to `quick-start-combined.log`. 17 | // - Write all logs error (and below) to `quick-start-error.log`. 18 | // 19 | new transports.File({ filename: 'tentacle-error.log', level: 'error' }), 20 | new transports.File({ filename: 'tentacle.log' }), 21 | ], 22 | exceptionHandlers: [ 23 | new transports.File({ filename: 'tentacle-exceptions.log' }), 24 | ], 25 | }) 26 | 27 | // 28 | // If we're not in production then **ALSO** log to the `console` 29 | // with the colorized simple format. 30 | // 31 | if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') { 32 | logger.add( 33 | new transports.Console({ 34 | format: format.combine(format.colorize(), format.simple()), 35 | }) 36 | ) 37 | } 38 | 39 | module.exports = logger 40 | -------------------------------------------------------------------------------- /src/relations.js: -------------------------------------------------------------------------------- 1 | const { Tag, ScanClass } = require('./tag') 2 | const { 3 | Device, 4 | Opcua, 5 | OpcuaSource, 6 | Modbus, 7 | ModbusSource, 8 | EthernetIP, 9 | EthernetIPSource, 10 | } = require('./device') 11 | const { Service, Mqtt, MqttSource, MqttHistory } = require('./service') 12 | const { User } = require('./auth') 13 | const getUnixTime = require('date-fns/getUnixTime') 14 | const tag = require('./resolvers/Query/tag') 15 | 16 | // This file creates properties and defines methods requiring relationships with other models. 17 | // It is defined here to prevent circular dependencies. 18 | 19 | // ============================== 20 | // Tags 21 | // ============================== 22 | 23 | ScanClass.prototype.scan = async function () { 24 | for (const tag of this.tags) { 25 | if (tag.source) { 26 | await tag.source.read() 27 | } 28 | } 29 | for (const source of MqttSource.instances) { 30 | await source.log(this.id) 31 | } 32 | } 33 | 34 | Object.defineProperties(ScanClass.prototype, { 35 | tags: { 36 | get() { 37 | this.checkInit() 38 | return Tag.instances.filter((instance) => { 39 | return instance.scanClass.id === this.id 40 | }) 41 | }, 42 | }, 43 | createdBy: { 44 | get() { 45 | this.checkInit() 46 | return User.findById(this._createdBy) 47 | }, 48 | }, 49 | }) 50 | 51 | Tag.delete = async function (selector) { 52 | const deleted = await this._deleteModel(selector) 53 | await ModbusSource.getAll() 54 | await EthernetIPSource.getAll() 55 | return deleted 56 | } 57 | 58 | Tag.prototype.setScanClass = async function (id) { 59 | const scanClass = ScanClass.findById(id) 60 | if (scanClass) { 61 | this.scanClass = scanClass 62 | return this.update(this.id, 'scanClass', scanClass.id, Tag).then( 63 | (result) => (this._scanClass = result) 64 | ) 65 | } else { 66 | throw Error(`Scan Class with ${id} does not exist.`) 67 | } 68 | } 69 | 70 | Object.defineProperties(Tag.prototype, { 71 | scanClass: { 72 | get() { 73 | this.checkInit() 74 | return ScanClass.findById(this._scanClass) 75 | }, 76 | }, 77 | source: { 78 | get() { 79 | this.checkInit() 80 | let source = null 81 | source = ModbusSource.instances.find((instance) => { 82 | return instance.tag.id === this.id 83 | }) 84 | if (!source) { 85 | source = EthernetIPSource.instances.find((instance) => { 86 | return instance.tag.id === this.id 87 | }) 88 | } 89 | if (!source) { 90 | source = OpcuaSource.instances.find((instance) => { 91 | return instance.tag.id === this.id 92 | }) 93 | } 94 | return source 95 | }, 96 | }, 97 | createdBy: { 98 | get() { 99 | this.checkInit() 100 | return User.findById(this._createdBy) 101 | }, 102 | }, 103 | }) 104 | 105 | // ============================== 106 | // Devices 107 | // ============================== 108 | 109 | Object.defineProperties(Device.prototype, { 110 | config: { 111 | get() { 112 | this.checkInit() 113 | if (this.type === `opcua`) { 114 | return Opcua.instances.find((instance) => { 115 | return instance._device === this._id 116 | }) 117 | } else if (this.type === `modbus`) { 118 | return Modbus.instances.find((instance) => { 119 | return instance._device === this._id 120 | }) 121 | } else { 122 | //default to EthernetIP 123 | return EthernetIP.instances.find((instance) => { 124 | return instance._device === this._id 125 | }) 126 | } 127 | }, 128 | }, 129 | createdBy: { 130 | get() { 131 | this.checkInit() 132 | return User.findById(this._createdBy) 133 | }, 134 | }, 135 | mqttSource: { 136 | get() { 137 | this.checkInit() 138 | return MqttSource.instances.find((instance) => { 139 | return instance._device === this._id 140 | }) 141 | }, 142 | }, 143 | }) 144 | 145 | // Opcua 146 | Opcua.create = async function ( 147 | name, 148 | description, 149 | host, 150 | port, 151 | retryRate, 152 | createdBy 153 | ) { 154 | const device = await Device.create(name, description, `opcua`, createdBy) 155 | const fields = { 156 | device: device.id, 157 | host, 158 | port, 159 | retryRate, 160 | } 161 | return this._createModel(fields) 162 | } 163 | 164 | Object.defineProperties(Opcua.prototype, { 165 | device: { 166 | get() { 167 | this.checkInit() 168 | return Device.findById(this._device) 169 | }, 170 | }, 171 | sources: { 172 | get() { 173 | this.checkInit() 174 | return OpcuaSource.instances.filter((instance) => { 175 | return instance.opcua.id === this.id 176 | }) 177 | }, 178 | }, 179 | }) 180 | 181 | Object.defineProperties(OpcuaSource.prototype, { 182 | opcua: { 183 | get() { 184 | this.checkInit() 185 | return Opcua.findById(this._opcua) 186 | }, 187 | }, 188 | device: { 189 | get() { 190 | this.checkInit() 191 | return this.opcua.device 192 | }, 193 | }, 194 | tag: { 195 | get() { 196 | this.checkInit() 197 | return Tag.findById(this._tag) 198 | }, 199 | }, 200 | }) 201 | 202 | // Modbus 203 | Modbus.create = async function ( 204 | name, 205 | description, 206 | host, 207 | port, 208 | reverseBits, 209 | reverseWords, 210 | zeroBased, 211 | timeout, 212 | retryRate, 213 | createdBy 214 | ) { 215 | const device = await Device.create(name, description, `modbus`, createdBy) 216 | const fields = { 217 | device: device.id, 218 | host, 219 | port, 220 | reverseBits, 221 | reverseWords, 222 | zeroBased, 223 | timeout, 224 | retryRate, 225 | } 226 | return this._createModel(fields) 227 | } 228 | 229 | Object.defineProperties(Modbus.prototype, { 230 | device: { 231 | get() { 232 | this.checkInit() 233 | return Device.findById(this._device) 234 | }, 235 | }, 236 | sources: { 237 | get() { 238 | this.checkInit() 239 | return ModbusSource.instances.filter((instance) => { 240 | return instance.modbus.id === this.id 241 | }) 242 | }, 243 | }, 244 | }) 245 | 246 | Object.defineProperties(ModbusSource.prototype, { 247 | modbus: { 248 | get() { 249 | this.checkInit() 250 | return Modbus.findById(this._modbus) 251 | }, 252 | }, 253 | device: { 254 | get() { 255 | this.checkInit() 256 | return this.modbus.device 257 | }, 258 | }, 259 | tag: { 260 | get() { 261 | this.checkInit() 262 | return Tag.findById(this._tag) 263 | }, 264 | }, 265 | }) 266 | 267 | // EthernetIP 268 | EthernetIP.create = async function (name, description, host, slot, createdBy) { 269 | const device = await Device.create(name, description, `ethernetip`, createdBy) 270 | const fields = { 271 | device: device.id, 272 | host, 273 | slot, 274 | } 275 | return this._createModel(fields) 276 | } 277 | 278 | Object.defineProperties(EthernetIP.prototype, { 279 | device: { 280 | get() { 281 | this.checkInit() 282 | return Device.findById(this._device) 283 | }, 284 | }, 285 | sources: { 286 | get() { 287 | this.checkInit() 288 | return EthernetIPSource.instances.filter((instance) => { 289 | return instance.ethernetip.id === this.id 290 | }) 291 | }, 292 | }, 293 | }) 294 | 295 | Object.defineProperties(EthernetIPSource.prototype, { 296 | ethernetip: { 297 | get() { 298 | this.checkInit() 299 | return EthernetIP.findById(this._ethernetip) 300 | }, 301 | }, 302 | device: { 303 | get() { 304 | this.checkInit() 305 | return this.ethernetip.device 306 | }, 307 | }, 308 | tag: { 309 | get() { 310 | this.checkInit() 311 | return Tag.findById(this._tag) 312 | }, 313 | }, 314 | }) 315 | 316 | // ============================== 317 | // Services 318 | // ============================== 319 | 320 | Object.defineProperties(Service.prototype, { 321 | config: { 322 | get() { 323 | this.checkInit() 324 | //default to Mqtt 325 | return Mqtt.instances.find((instance) => { 326 | return instance._service === this._id 327 | }) 328 | }, 329 | }, 330 | createdBy: { 331 | get() { 332 | this.checkInit() 333 | return User.findById(this._createdBy) 334 | }, 335 | }, 336 | }) 337 | 338 | Mqtt.create = async function ( 339 | name, 340 | description, 341 | host, 342 | port, 343 | group, 344 | node, 345 | username, 346 | password, 347 | devices, 348 | rate, 349 | encrypt, 350 | recordLimit, 351 | createdBy, 352 | primaryHosts 353 | ) { 354 | const service = await Service.create(name, description, 'mqtt', createdBy) 355 | const fields = { 356 | service: service.id, 357 | host, 358 | port, 359 | group, 360 | node, 361 | username, 362 | password, 363 | rate, 364 | encrypt, 365 | recordLimit, 366 | primaryHosts, 367 | } 368 | const mqtt = await this._createModel(fields) 369 | for (device of devices) { 370 | await MqttSource.create(mqtt.id, device) 371 | } 372 | return mqtt 373 | } 374 | 375 | Mqtt.prototype.publish = async function () { 376 | for (const source of this.sources) { 377 | const payload = source.rtHistory.map((record) => { 378 | const tag = Tag.findById(record.id) 379 | return { 380 | name: tag.name, 381 | value: record.value, 382 | type: tag.datatype, 383 | timestamp: record.timestamp, 384 | } 385 | }) 386 | if (payload.length > 0) { 387 | await this.client.publishDeviceData(`${source.device.name}`, { 388 | timestamp: getUnixTime(new Date()), 389 | metrics: [...payload], 390 | }) 391 | } 392 | source.rtHistory = [] 393 | } 394 | } 395 | 396 | Mqtt.prototype.publishHistory = async function () { 397 | const hosts = this.primaryHosts.filter((host) => { 398 | return host.readyForData 399 | }) 400 | let historyToPublish = [] 401 | for (const host of hosts) { 402 | const history = await host.getHistory(this.recordLimit) 403 | const newRecords = history.filter((record) => { 404 | return !historyToPublish.some((row) => { 405 | return row.id === record.id 406 | }) 407 | }) 408 | historyToPublish = [...historyToPublish, ...newRecords] 409 | } 410 | const devices = historyToPublish.reduce((a, record) => { 411 | const source = MqttSource.findById(record.source) 412 | return a.some((device) => { 413 | return device.id === source.device.id 414 | }) 415 | ? a 416 | : [...a, source.device] 417 | }, []) 418 | for (device of devices) { 419 | const payload = historyToPublish 420 | .filter((record) => { 421 | const source = MqttSource.findById(record.source) 422 | return device.id === source.device.id 423 | }) 424 | .map((record) => { 425 | const tag = Tag.findById(record.tag) 426 | return { 427 | name: tag.name, 428 | value: tag.datatype === 'BOOLEAN' ? !!+record.value : record.value, 429 | timestamp: record.timestamp, 430 | type: tag.datatype, 431 | isHistorical: true, 432 | } 433 | }) 434 | this.client.publishDeviceData(`${device.name}`, { 435 | timestamp: getUnixTime(new Date()), 436 | metrics: [...payload], 437 | }) 438 | } 439 | let sql = `DELETE FROM mqttPrimaryHostHistory WHERE id in (${'?,' 440 | .repeat(historyToPublish.length) 441 | .slice(0, -1)})` 442 | let params = historyToPublish.map((record) => { 443 | return record.id 444 | }) 445 | await this.constructor.executeUpdate(sql, params) 446 | sql = `DELETE FROM mqttHistory 447 | WHERE id IN ( 448 | SELECT a.id AS id 449 | FROM mqttHistory AS a 450 | LEFT JOIN mqttPrimaryHostHistory AS b 451 | on a.id = b.mqttHistory 452 | WHERE b.id IS NULL LIMIT 750 453 | )` 454 | await this.constructor.executeUpdate(sql) 455 | } 456 | 457 | Mqtt.prototype.onDcmd = function (payload) { 458 | const { metrics } = payload 459 | for (metric of metrics) { 460 | const tag = Tag.instances.find((tag) => metric.name === tag.name) 461 | tag.setValue(metric.value) 462 | } 463 | } 464 | 465 | Object.defineProperties(Mqtt.prototype, { 466 | service: { 467 | get() { 468 | this.checkInit() 469 | return Service.findById(this._service) 470 | }, 471 | }, 472 | sources: { 473 | get() { 474 | this.checkInit() 475 | return MqttSource.instances.filter((instance) => { 476 | return instance.mqtt.id === this.id 477 | }) 478 | }, 479 | }, 480 | }) 481 | 482 | MqttSource.prototype.log = async function (scanClassId) { 483 | const scanClass = ScanClass.findById(scanClassId) 484 | const tags = Tag.instances.filter((tag) => { 485 | if (tag.source && !tag.prevChangeWithinDeadband) { 486 | return ( 487 | tag.scanClass.id === scanClass.id && 488 | this.device.id === tag.source.device.id 489 | ) 490 | } else { 491 | return false 492 | } 493 | }) 494 | // TO-DO check how long it has been since last change, if it's been a while, log the previous value before out of deadband was detected. 495 | const preDeadbandExitPoints = tags 496 | .filter((tag) => { 497 | const secondsSinceChange = tag.lastChangeOn - tag.prevChangeOn 498 | return secondsSinceChange >= (tag.scanClass.rate / 1000.0) * 3 499 | }) 500 | .map((tag) => { 501 | return { 502 | id: tag.id, 503 | value: tag.prevValue, 504 | timestamp: tag.lastChangeOn, 505 | } 506 | }) 507 | // The following is to collect realtime history of events to be published without isHistorical 508 | if (tags.length > 0) { 509 | this.rtHistory = [ 510 | ...this.rtHistory, 511 | ...preDeadbandExitPoints, 512 | ...tags.map((tag) => { 513 | return { 514 | id: tag.id, 515 | value: tag.value, 516 | timestamp: getUnixTime(new Date()), 517 | } 518 | }), 519 | ] 520 | console.log(this.rtHistory) 521 | } 522 | // The following is to collect history in the event of a primaryHost going offline 523 | if (tags.length > 0) { 524 | await new Promise((resolve) => { 525 | this.db.serialize(async () => { 526 | let sql = `INSERT INTO mqttHistory (mqttSource, timestamp)` 527 | sql = `${sql} VALUES (?,?);` 528 | let params = [this.id, getUnixTime(new Date())] 529 | const result = await this.constructor.executeUpdate(sql, params) 530 | hostsDown = this.mqtt.primaryHosts.filter((host) => !host.readyForData) 531 | if (hostsDown.length > 0) { 532 | for (host of hostsDown) { 533 | sql = `INSERT INTO mqttPrimaryHostHistory (mqttPrimaryHost, mqttHistory)` 534 | sql = `${sql} VALUES (?,?);` 535 | params = [host.id, result.lastID] 536 | await this.constructor.executeUpdate(sql, params) 537 | this.pubsub.publish('serviceUpdate', { 538 | serviceUpdate: this.mqtt.service, 539 | }) 540 | } 541 | for (const tag of tags.filter( 542 | (tag) => !tag.prevChangeWithinDeadband 543 | )) { 544 | sql = `INSERT INTO mqttHistoryTag (mqttHistory, tag, value)` 545 | sql = `${sql} VALUES (?,?,?);` 546 | params = [result.lastID, tag.id, tag.value] 547 | await this.constructor.executeUpdate(sql, params) 548 | } 549 | } 550 | resolve() 551 | }) 552 | }) 553 | } 554 | } 555 | 556 | Object.defineProperties(MqttSource.prototype, { 557 | mqtt: { 558 | get() { 559 | this.checkInit() 560 | return Mqtt.findById(this._mqtt) 561 | }, 562 | }, 563 | device: { 564 | get() { 565 | this.checkInit() 566 | return Device.findById(this._device) 567 | }, 568 | }, 569 | }) 570 | 571 | module.exports = { 572 | Device, 573 | Opcua, 574 | OpcuaSource, 575 | Modbus, 576 | ModbusSource, 577 | EthernetIP, 578 | EthernetIPSource, 579 | Service, 580 | Mqtt, 581 | MqttSource, 582 | Tag, 583 | ScanClass, 584 | User, 585 | } 586 | -------------------------------------------------------------------------------- /src/resolvers/DeviceConfig.js: -------------------------------------------------------------------------------- 1 | async function __resolveType(parent, args, context, info) { 2 | return parent.constructor.name 3 | } 4 | 5 | module.exports = { 6 | __resolveType, 7 | } 8 | -------------------------------------------------------------------------------- /src/resolvers/MqttPrimaryHost.js: -------------------------------------------------------------------------------- 1 | const recordCount = async function (parent, args, context, info) { 2 | return parent.getRecordCount() 3 | } 4 | 5 | module.exports = { 6 | recordCount, 7 | } 8 | -------------------------------------------------------------------------------- /src/resolvers/Mutation/auth.js: -------------------------------------------------------------------------------- 1 | const { User } = require('../../relations') 2 | 3 | async function login(root, args, context, info) { 4 | return User.login(args.username, args.password) 5 | } 6 | 7 | async function changePassword(root, args, context, info) { 8 | return User.changePassword(context, args.oldPassword, args.newPassword) 9 | } 10 | 11 | module.exports = { 12 | login, 13 | changePassword, 14 | } 15 | -------------------------------------------------------------------------------- /src/resolvers/Mutation/device/ethernetip.js: -------------------------------------------------------------------------------- 1 | const { 2 | Device, 3 | EthernetIP, 4 | EthernetIPSource, 5 | Tag, 6 | User, 7 | } = require('../../../relations') 8 | 9 | async function createEthernetIP(root, args, context, info) { 10 | const user = await User.getUserFromContext(context) 11 | const createdBy = user.id 12 | const ethernetip = await EthernetIP.create( 13 | args.name, 14 | args.description, 15 | args.host, 16 | args.slot, 17 | createdBy 18 | ) 19 | await ethernetip.connect() 20 | return ethernetip.device 21 | } 22 | 23 | async function updateEthernetIP(root, args, context, info) { 24 | const user = await User.getUserFromContext(context) 25 | const device = Device.findById(args.id) 26 | if (device) { 27 | if (args.name) { 28 | await device.setName(args.name) 29 | } 30 | if (args.description) { 31 | await device.setDescription(args.description) 32 | } 33 | if (args.host) { 34 | await device.config.setHost(args.host) 35 | } 36 | if (args.slot) { 37 | await device.config.setSlot(args.slot) 38 | } 39 | await device.config.disconnect() 40 | await device.config.connect() 41 | return device 42 | } else { 43 | throw new Error(`Device with id ${args.id} does not exist.`) 44 | } 45 | } 46 | 47 | async function deleteEthernetIP(root, args, context, info) { 48 | const user = await User.getUserFromContext(context) 49 | const device = Device.findById(args.id) 50 | if (device) { 51 | // await device.config.disconnect() 52 | return device.delete() 53 | } else { 54 | throw new Error(`Device with id ${args.id} does not exist.`) 55 | } 56 | } 57 | 58 | async function createEthernetIPSource(root, args, context, info) { 59 | const user = await User.getUserFromContext(context) 60 | const createdBy = user.id 61 | const device = Device.findById(args.deviceId) 62 | const tag = Tag.findById(args.tagId) 63 | if (device) { 64 | if (device.type === `ethernetip`) { 65 | if (tag) { 66 | const config = { 67 | register: args.register, 68 | registerType: args.registerType, 69 | } 70 | return EthernetIPSource.create( 71 | device.config.id, 72 | tag.id, 73 | args.tagname, 74 | createdBy 75 | ) 76 | } else { 77 | throw Error(`There is no tag with id ${args.tagId}`) 78 | } 79 | } else { 80 | throw Error( 81 | `The device named ${device.name} is not an ethernetip device.` 82 | ) 83 | } 84 | } else { 85 | throw Error(`There is no device with id ${args.deviceId}`) 86 | } 87 | } 88 | 89 | async function updateEthernetIPSource(root, args, context, info) { 90 | const user = await User.getUserFromContext(context) 91 | const tag = Tag.findById(args.tagId) 92 | if (tag) { 93 | if (args.tagname) { 94 | await tag.source.setTagname(args.tagname) 95 | } 96 | return tag.source 97 | } else { 98 | throw new Error(`Tag with id ${args.id} does not exist.`) 99 | } 100 | } 101 | 102 | async function deleteEthernetIPSource(root, args, context, info) { 103 | const user = await User.getUserFromContext(context) 104 | const tag = Tag.findById(args.tagId) 105 | if (tag) { 106 | return tag.source.delete() 107 | } else { 108 | throw new Error(`Tag with id ${args.id} does not exist.`) 109 | } 110 | } 111 | 112 | module.exports = { 113 | createEthernetIP, 114 | updateEthernetIP, 115 | deleteEthernetIP, 116 | createEthernetIPSource, 117 | updateEthernetIPSource, 118 | deleteEthernetIPSource, 119 | } 120 | -------------------------------------------------------------------------------- /src/resolvers/Mutation/device/index.js: -------------------------------------------------------------------------------- 1 | const opcua = require('./opcua') 2 | const modbus = require('./modbus') 3 | const ethernetip = require('./ethernetip') 4 | 5 | module.exports = { 6 | ...opcua, 7 | ...modbus, 8 | ...ethernetip, 9 | } 10 | -------------------------------------------------------------------------------- /src/resolvers/Mutation/device/modbus.js: -------------------------------------------------------------------------------- 1 | const { 2 | Device, 3 | Modbus, 4 | ModbusSource, 5 | Tag, 6 | User, 7 | } = require('../../../relations') 8 | 9 | async function createModbus(root, args, context, info) { 10 | const user = await User.getUserFromContext(context) 11 | const createdBy = user.id 12 | const modbus = await Modbus.create( 13 | args.name, 14 | args.description, 15 | args.host, 16 | args.port, 17 | args.reverseBits, 18 | args.reverseWords, 19 | args.zeroBased, 20 | args.timeout, 21 | args.retryRate, 22 | createdBy 23 | ) 24 | await modbus.connect() 25 | return modbus.device 26 | } 27 | 28 | async function updateModbus(root, args, context, info) { 29 | const user = await User.getUserFromContext(context) 30 | const device = Device.findById(args.id) 31 | if (device) { 32 | if (args.name) { 33 | await device.setName(args.name) 34 | } 35 | if (args.description) { 36 | await device.setDescription(args.description) 37 | } 38 | if (args.host) { 39 | await device.config.setHost(args.host) 40 | } 41 | if (args.port) { 42 | await device.config.setPort(args.port) 43 | } 44 | if (args.reverseBits !== undefined) { 45 | await device.config.setReverseBits(args.reverseBits) 46 | } 47 | if (args.reverseWords !== undefined) { 48 | await device.config.setReverseWords(args.reverseWords) 49 | } 50 | if (args.zeroBased !== undefined) { 51 | await device.config.setZeroBased(args.zeroBased) 52 | } 53 | if (args.timeout) { 54 | await device.config.setTimeout(args.timeout) 55 | } 56 | if (args.retryRate) { 57 | await device.config.setRetryRate(args.retryRate) 58 | } 59 | await device.config.disconnect() 60 | await device.config.connect() 61 | return device 62 | } else { 63 | throw new Error(`Device with id ${args.id} does not exist.`) 64 | } 65 | } 66 | 67 | async function deleteModbus(root, args, context, info) { 68 | const user = await User.getUserFromContext(context) 69 | const device = Device.findById(args.id) 70 | if (device) { 71 | // await device.config.disconnect() 72 | return device.delete() 73 | } else { 74 | throw new Error(`Device with id ${args.id} does not exist.`) 75 | } 76 | } 77 | 78 | async function createModbusSource(root, args, context, info) { 79 | const user = await User.getUserFromContext(context) 80 | const createdBy = user.id 81 | const device = Device.findById(args.deviceId) 82 | const tag = Tag.findById(args.tagId) 83 | if (device) { 84 | if (device.type === `modbus`) { 85 | if (tag) { 86 | const config = { 87 | register: args.register, 88 | registerType: args.registerType, 89 | } 90 | return ModbusSource.create( 91 | device.config.id, 92 | tag.id, 93 | args.register, 94 | args.registerType, 95 | createdBy 96 | ) 97 | } else { 98 | throw Error(`There is no tag with id ${args.tagId}`) 99 | } 100 | } else { 101 | throw Error(`The device named ${device.name} is not a modbus device.`) 102 | } 103 | } else { 104 | throw Error(`There is no device with id ${args.deviceId}`) 105 | } 106 | } 107 | 108 | async function updateModbusSource(root, args, context, info) { 109 | const user = await User.getUserFromContext(context) 110 | const tag = Tag.findById(args.tagId) 111 | if (tag) { 112 | if (args.register) { 113 | await tag.source.setRegister(args.register) 114 | } 115 | if (args.registerType) { 116 | await tag.source.setRegisterType(args.registerType) 117 | } 118 | return tag.source 119 | } else { 120 | throw new Error(`Tag with id ${args.tagId} does not exist.`) 121 | } 122 | } 123 | 124 | async function deleteModbusSource(root, args, context, info) { 125 | const user = await User.getUserFromContext(context) 126 | const tag = Tag.findById(args.tagId) 127 | if (tag) { 128 | return tag.source.delete() 129 | } else { 130 | throw new Error(`Tag with id ${args.tagId} does not exist.`) 131 | } 132 | } 133 | 134 | module.exports = { 135 | createModbus, 136 | updateModbus, 137 | deleteModbus, 138 | createModbusSource, 139 | updateModbusSource, 140 | deleteModbusSource, 141 | } 142 | -------------------------------------------------------------------------------- /src/resolvers/Mutation/device/opcua.js: -------------------------------------------------------------------------------- 1 | const { Device, Opcua, OpcuaSource, Tag, User } = require('../../../relations') 2 | 3 | async function createOpcua(root, args, context, info) { 4 | const user = await User.getUserFromContext(context) 5 | const createdBy = user.id 6 | const opcua = await Opcua.create( 7 | args.name, 8 | args.description, 9 | args.host, 10 | args.port, 11 | args.retryRate, 12 | createdBy 13 | ) 14 | await opcua.connect() 15 | return opcua.device 16 | } 17 | 18 | async function updateOpcua(root, args, context, info) { 19 | const user = await User.getUserFromContext(context) 20 | const device = Device.findById(args.id) 21 | if (device) { 22 | if (args.name) { 23 | await device.setName(args.name) 24 | } 25 | if (args.description) { 26 | await device.setDescription(args.description) 27 | } 28 | if (args.host) { 29 | await device.config.setHost(args.host) 30 | } 31 | if (args.port) { 32 | await device.config.setPort(args.port) 33 | } 34 | if (args.retryRate) { 35 | await device.config.setRetryRate(args.retryRate) 36 | } 37 | await device.config.disconnect() 38 | await device.config.connect() 39 | return device 40 | } else { 41 | throw new Error(`Device with id ${args.id} does not exist.`) 42 | } 43 | } 44 | 45 | async function deleteOpcua(root, args, context, info) { 46 | const user = await User.getUserFromContext(context) 47 | const device = Device.findById(args.id) 48 | if (device) { 49 | // await device.config.disconnect() 50 | return device.delete() 51 | } else { 52 | throw new Error(`Device with id ${args.id} does not exist.`) 53 | } 54 | } 55 | 56 | async function createOpcuaSource(root, args, context, info) { 57 | const user = await User.getUserFromContext(context) 58 | const createdBy = user.id 59 | const device = Device.findById(args.deviceId) 60 | const tag = Tag.findById(args.tagId) 61 | if (device) { 62 | if (device.type === `opcua`) { 63 | if (tag) { 64 | const config = { 65 | register: args.register, 66 | registerType: args.registerType, 67 | } 68 | return OpcuaSource.create( 69 | device.config.id, 70 | tag.id, 71 | args.nodeId, 72 | createdBy 73 | ) 74 | } else { 75 | throw Error(`There is no tag with id ${args.tagId}`) 76 | } 77 | } else { 78 | throw Error(`The device named ${device.name} is not an opcua device.`) 79 | } 80 | } else { 81 | throw Error(`There is no device with id ${args.deviceId}`) 82 | } 83 | } 84 | 85 | async function updateOpcuaSource(root, args, context, info) { 86 | const user = await User.getUserFromContext(context) 87 | const tag = Tag.findById(args.tagId) 88 | if (tag) { 89 | if (args.nodeId) { 90 | await tag.source.setNodeId(args.nodeId) 91 | } 92 | return tag.source 93 | } else { 94 | throw new Error(`Tag with id ${args.id} does not exist.`) 95 | } 96 | } 97 | 98 | async function deleteOpcuaSource(root, args, context, info) { 99 | const user = await User.getUserFromContext(context) 100 | const tag = Tag.findById(args.tagId) 101 | if (tag) { 102 | return tag.source.delete() 103 | } else { 104 | throw new Error(`Tag with id ${args.id} does not exist.`) 105 | } 106 | } 107 | 108 | module.exports = { 109 | createOpcua, 110 | updateOpcua, 111 | deleteOpcua, 112 | createOpcuaSource, 113 | updateOpcuaSource, 114 | deleteOpcuaSource, 115 | } 116 | -------------------------------------------------------------------------------- /src/resolvers/Mutation/index.js: -------------------------------------------------------------------------------- 1 | const auth = require('./auth') 2 | const tag = require('./tag') 3 | const device = require('./device') 4 | const service = require('./service') 5 | 6 | module.exports = { 7 | ...auth, 8 | ...tag, 9 | ...device, 10 | ...service, 11 | } 12 | -------------------------------------------------------------------------------- /src/resolvers/Mutation/service/index.js: -------------------------------------------------------------------------------- 1 | const mqtt = require('./mqtt') 2 | 3 | module.exports = { 4 | ...mqtt, 5 | } 6 | -------------------------------------------------------------------------------- /src/resolvers/Mutation/service/mqtt.js: -------------------------------------------------------------------------------- 1 | const { Service, Mqtt, Tag, User } = require('../../../relations') 2 | 3 | async function createMqtt(root, args, context, info) { 4 | const user = await User.getUserFromContext(context) 5 | const createdBy = user.id 6 | const mqtt = await Mqtt.create( 7 | args.name, 8 | args.description, 9 | args.host, 10 | args.port, 11 | args.group, 12 | args.node, 13 | args.username, 14 | args.password, 15 | args.devices, 16 | args.rate, 17 | args.encrypt, 18 | args.recordLimit, 19 | createdBy, 20 | args.primaryHosts ? args.primaryHosts : [] 21 | ) 22 | await mqtt.connect() 23 | return mqtt.service 24 | } 25 | 26 | async function updateMqtt(root, args, context, info) { 27 | const user = await User.getUserFromContext(context) 28 | const service = Service.findById(args.id) 29 | if (service) { 30 | if (args.name) { 31 | await service.setName(args.name) 32 | } 33 | if (args.description) { 34 | await service.setDescription(args.description) 35 | } 36 | if (args.host) { 37 | await service.config.setHost(args.host) 38 | } 39 | if (args.port) { 40 | await service.config.setPort(args.port) 41 | } 42 | if (args.group) { 43 | await service.config.setGroup(args.group) 44 | } 45 | if (args.node) { 46 | await service.config.setNode(args.node) 47 | } 48 | if (args.username) { 49 | await service.config.setUsername(args.username) 50 | } 51 | if (args.password) { 52 | await service.config.setPassword(args.password) 53 | } 54 | if (args.rate) { 55 | await service.config.setRate(args.rate) 56 | } 57 | if (args.encrypt !== undefined) { 58 | await service.config.setEncrypt(args.encrypt) 59 | } 60 | if (args.recordLimit !== undefined) { 61 | await service.config.setRecordLimit(args.recordLimit) 62 | } 63 | await service.config.disconnect() 64 | await service.config.connect() 65 | return service 66 | } else { 67 | throw new Error(`Service with id ${args.id} does not exist.`) 68 | } 69 | } 70 | 71 | async function deleteMqtt(root, args, context, info) { 72 | const user = await User.getUserFromContext(context) 73 | const service = Service.findById(args.id) 74 | if (service) { 75 | await service.config.disconnect() 76 | return service.delete() 77 | } else { 78 | throw new Error(`Service with id ${args.id} does not exist.`) 79 | } 80 | } 81 | async function addMqttPrimaryHost(root, args, context, info) { 82 | const user = await User.getUserFromContext(context) 83 | const service = Service.findById(args.id) 84 | if (service) { 85 | if (service.type === `mqtt`) { 86 | return service.config.addPrimaryHost(args.name) 87 | } else { 88 | throw new Error( 89 | `Service with id ${args.id} is not an mqtt service. It's type ${service.type}` 90 | ) 91 | } 92 | } else { 93 | throw new Error(`Service with id ${args.id} does not exist.`) 94 | } 95 | } 96 | async function deleteMqttPrimaryHost(root, args, context, info) { 97 | const user = await User.getUserFromContext(context) 98 | const service = Service.findById(args.id) 99 | if (service) { 100 | if (service.type === `mqtt`) { 101 | return service.config.deletePrimaryHost(args.name) 102 | } else { 103 | throw new Error( 104 | `Service with id ${args.id} is not an mqtt service. It's type ${service.type}` 105 | ) 106 | } 107 | } else { 108 | throw new Error(`Service with id ${args.id} does not exist.`) 109 | } 110 | } 111 | 112 | async function addMqttSource(root, args, context, info) { 113 | await User.getUserFromContext(context) 114 | const service = Service.findById(args.id) 115 | if (service) { 116 | if (service.type === `mqtt`) { 117 | await service.config.addSource(args.deviceId) 118 | return service 119 | } else { 120 | throw new Error( 121 | `Service with id ${args.id} is not an mqtt service. It's type ${service.type}` 122 | ) 123 | } 124 | } else { 125 | throw new Error(`Service with id ${args.id} does not exist.`) 126 | } 127 | } 128 | 129 | async function deleteMqttSource(root, args, context, info) { 130 | await User.getUserFromContext(context) 131 | const service = Service.findById(args.id) 132 | if (service) { 133 | if (service.type === `mqtt`) { 134 | await service.config.deleteSource(args.deviceId) 135 | return service 136 | } else { 137 | throw new Error( 138 | `Service with id ${args.id} is not an mqtt service. It's type ${service.type}` 139 | ) 140 | } 141 | } else { 142 | throw new Error(`Service with id ${args.id} does not exist.`) 143 | } 144 | } 145 | 146 | module.exports = { 147 | createMqtt, 148 | updateMqtt, 149 | deleteMqtt, 150 | addMqttPrimaryHost, 151 | deleteMqttPrimaryHost, 152 | addMqttSource, 153 | deleteMqttSource, 154 | } 155 | -------------------------------------------------------------------------------- /src/resolvers/Mutation/tag.js: -------------------------------------------------------------------------------- 1 | const { User, Tag, ScanClass } = require('../../relations') 2 | 3 | async function createScanClass(root, args, context, info) { 4 | const user = await User.getUserFromContext(context) 5 | const createdBy = user.id 6 | const scanClass = await ScanClass.create( 7 | args.name, 8 | args.description, 9 | args.rate, 10 | createdBy 11 | ) 12 | scanClass.startScan() 13 | return scanClass 14 | } 15 | 16 | async function updateScanClass(root, args, context, info) { 17 | const user = await User.getUserFromContext(context) 18 | const scanClass = ScanClass.findById(args.id) 19 | if (scanClass) { 20 | if (args.name) { 21 | await scanClass.setName(args.name) 22 | } 23 | if (args.description) { 24 | await scanClass.setDescription(args.description) 25 | } 26 | if (args.rate) { 27 | await scanClass.setRate(args.rate) 28 | } 29 | scanClass.stopScan() 30 | scanClass.startScan() 31 | return scanClass 32 | } else { 33 | throw new Error(`Scan Class with id ${args.id} does not exist.`) 34 | } 35 | } 36 | 37 | async function deleteScanClass(root, args, context, info) { 38 | const user = await User.getUserFromContext(context) 39 | const scanClass = ScanClass.findById(args.id) 40 | if (scanClass) { 41 | scanClass.stopScan() 42 | return scanClass.delete() 43 | } else { 44 | throw new Error(`Scan Class with id ${args.id} does not exist.`) 45 | } 46 | } 47 | 48 | async function createTag(root, args, context, info) { 49 | const user = await User.getUserFromContext(context) 50 | const createdBy = user.id 51 | const tag = await Tag.create( 52 | args.name, 53 | args.description, 54 | args.value, 55 | args.scanClassId, 56 | createdBy, 57 | args.datatype, 58 | args.max, 59 | args.min, 60 | args.deadband, 61 | args.units 62 | ) 63 | return tag 64 | } 65 | 66 | async function updateTag(root, args, context, info) { 67 | const user = await User.getUserFromContext(context) 68 | const tag = Tag.findById(args.id) 69 | if (tag) { 70 | if (args.name) { 71 | await tag.setName(args.name) 72 | } 73 | if (args.description) { 74 | await tag.setDescription(args.description) 75 | } 76 | if (args.datatype) { 77 | await tag.setDatatype(args.datatype) 78 | } 79 | if (args.value) { 80 | await tag.setValue(args.value) 81 | } 82 | if (args.scanClassId) { 83 | await tag.setScanClass(args.scanClassId) 84 | } 85 | if (args.min !== undefined) { 86 | await tag.setMin(args.min) 87 | } 88 | if (args.max !== undefined) { 89 | await tag.setMax(args.max) 90 | } 91 | if (args.deadband !== undefined) { 92 | await tag.setDeadband(args.deadband) 93 | } 94 | if (args.units) { 95 | await tag.setUnits(args.units) 96 | } 97 | return tag 98 | } else { 99 | throw new Error(`Tag with id ${args.id} does not exist.`) 100 | } 101 | } 102 | 103 | async function deleteTag(root, args, context, info) { 104 | const user = await User.getUserFromContext(context) 105 | const tag = Tag.findById(args.id) 106 | if (tag) { 107 | return tag.delete() 108 | } else { 109 | throw new Error(`Tag with id ${args.id} does not exist.`) 110 | } 111 | } 112 | 113 | module.exports = { 114 | createScanClass, 115 | updateScanClass, 116 | deleteScanClass, 117 | createTag, 118 | updateTag, 119 | deleteTag, 120 | } 121 | -------------------------------------------------------------------------------- /src/resolvers/Opcua.js: -------------------------------------------------------------------------------- 1 | const nodes = async function (parent, args, context, info) { 2 | return parent.browse() 3 | } 4 | 5 | const flatNodes = async function (parent, args, context, info) { 6 | return parent.browse(null, true) 7 | } 8 | 9 | module.exports = { 10 | nodes, 11 | flatNodes, 12 | } 13 | -------------------------------------------------------------------------------- /src/resolvers/OpcuaNode.js: -------------------------------------------------------------------------------- 1 | const id = function (parent, args, context, info) { 2 | return parent.nodeId 3 | } 4 | 5 | const name = function (parent, args, context, info) { 6 | return parent.browseName 7 | } 8 | 9 | const children = function (parent, args, context, info) { 10 | const hasProperty = parent.hasProperty ? parent.hasProperty : [] 11 | const hasComponent = parent.hasComponent ? parent.hasComponent : [] 12 | const organizes = parent.organizes ? parent.organizes : [] 13 | const hasInputVar = parent.hasInputVar ? parent.hasInputVar : [] 14 | const hasOutputVar = parent.hasOutputVar ? parent.hasOutputVar : [] 15 | const hasLocalVar = parent.hasLocalVar ? parent.hasLocalVar : [] 16 | return [ 17 | ...hasProperty, 18 | ...hasComponent, 19 | ...organizes, 20 | ...hasInputVar, 21 | ...hasOutputVar, 22 | ...hasLocalVar, 23 | ] 24 | } 25 | 26 | const datatype = function (parent, args, context, info) { 27 | return parent.dataValue && parent.dataValue.value 28 | ? parent.dataValue.value.dataType 29 | : null 30 | } 31 | 32 | const value = function (parent, args, context, info) { 33 | return parent.dataValue && parent.dataValue.value 34 | ? JSON.stringify(parent.dataValue.value.value) 35 | : null 36 | } 37 | 38 | module.exports = { 39 | id, 40 | name, 41 | children, 42 | datatype, 43 | value, 44 | } 45 | -------------------------------------------------------------------------------- /src/resolvers/Query/device.js: -------------------------------------------------------------------------------- 1 | const { User } = require(`../../auth`) 2 | 3 | async function devices(root, args, context, info) { 4 | const user = await User.getUserFromContext(context) 5 | return context.devices 6 | } 7 | 8 | module.exports = { 9 | devices, 10 | } 11 | -------------------------------------------------------------------------------- /src/resolvers/Query/index.js: -------------------------------------------------------------------------------- 1 | const user = require('./user') 2 | const tag = require('./tag') 3 | const device = require('./device') 4 | const service = require('./service') 5 | 6 | module.exports = { 7 | ...user, 8 | ...tag, 9 | ...device, 10 | ...service, 11 | } 12 | -------------------------------------------------------------------------------- /src/resolvers/Query/service.js: -------------------------------------------------------------------------------- 1 | const { User } = require(`../../auth`) 2 | 3 | async function services(root, args, context, info) { 4 | const user = await User.getUserFromContext(context) 5 | return context.services 6 | } 7 | 8 | module.exports = { 9 | services, 10 | } 11 | -------------------------------------------------------------------------------- /src/resolvers/Query/tag.js: -------------------------------------------------------------------------------- 1 | const { User } = require('../../auth') 2 | 3 | async function tags(root, args, context, info) { 4 | const user = await User.getUserFromContext(context) 5 | return context.tags 6 | } 7 | 8 | async function scanClasses(root, args, context, info) { 9 | const user = await User.getUserFromContext(context) 10 | return context.scanClasses 11 | } 12 | 13 | module.exports = { 14 | tags, 15 | scanClasses, 16 | } 17 | -------------------------------------------------------------------------------- /src/resolvers/Query/user.js: -------------------------------------------------------------------------------- 1 | const { User } = require('../../auth') 2 | 3 | async function user(root, args, context, info) { 4 | return User.getUserFromContext(context) 5 | } 6 | 7 | module.exports = { 8 | user, 9 | } 10 | -------------------------------------------------------------------------------- /src/resolvers/Source.js: -------------------------------------------------------------------------------- 1 | async function __resolveType(parent, args, context, info) { 2 | return parent.constructor.name 3 | } 4 | 5 | module.exports = { 6 | __resolveType, 7 | } 8 | -------------------------------------------------------------------------------- /src/resolvers/Subscription/device.js: -------------------------------------------------------------------------------- 1 | const deviceUpdate = { 2 | subscribe: (root, args, context) => { 3 | return context.pubsub.asyncIterator(`deviceUpdate`) 4 | }, 5 | } 6 | 7 | module.exports = { 8 | deviceUpdate, 9 | } 10 | -------------------------------------------------------------------------------- /src/resolvers/Subscription/index.js: -------------------------------------------------------------------------------- 1 | const tag = require('./tag') 2 | const device = require('./device') 3 | const service = require('./service') 4 | 5 | module.exports = { 6 | ...device, 7 | ...service, 8 | ...tag, 9 | } 10 | -------------------------------------------------------------------------------- /src/resolvers/Subscription/service.js: -------------------------------------------------------------------------------- 1 | const serviceUpdate = { 2 | subscribe: (root, args, context) => { 3 | return context.pubsub.asyncIterator(`serviceUpdate`) 4 | }, 5 | } 6 | 7 | module.exports = { 8 | serviceUpdate, 9 | } 10 | -------------------------------------------------------------------------------- /src/resolvers/Subscription/tag.js: -------------------------------------------------------------------------------- 1 | const tagUpdate = { 2 | subscribe: (root, args, context) => { 3 | return context.pubsub.asyncIterator(`tagUpdate`) 4 | }, 5 | } 6 | 7 | module.exports = { 8 | tagUpdate, 9 | } 10 | -------------------------------------------------------------------------------- /src/resolvers/index.js: -------------------------------------------------------------------------------- 1 | const Query = require('./Query') 2 | const Mutation = require('./Mutation') 3 | const Subscription = require('./Subscription') 4 | const Source = require('./Source') 5 | const DeviceConfig = require('./DeviceConfig') 6 | const MqttPrimaryHost = require('./MqttPrimaryHost') 7 | const Opcua = require('./Opcua') 8 | const OpcuaNode = require('./OpcuaNode') 9 | 10 | module.exports = { 11 | Query, 12 | Mutation, 13 | Subscription, 14 | Source, 15 | DeviceConfig, 16 | MqttPrimaryHost, 17 | Opcua, 18 | OpcuaNode, 19 | } 20 | -------------------------------------------------------------------------------- /src/schema.graphql: -------------------------------------------------------------------------------- 1 | """Used to deliver timestamp values.""" 2 | scalar DateTime 3 | 4 | """Modbus register types for use with modbus sources per the modbus specification.""" 5 | enum ModbusRegisterType { 6 | DISCRETE_INPUT 7 | COIL 8 | INPUT_REGISTER 9 | HOLDING_REGISTER 10 | } 11 | 12 | """Tag datatypes allowing for clients to properly parse tag values.""" 13 | enum Datatype { 14 | BOOLEAN, 15 | INT16, 16 | INT32, 17 | FLOAT 18 | } 19 | 20 | """Sources store the configuration to be used for tag value updates from devices.""" 21 | union Source = ModbusSource | EthernetIPSource | OpcuaSource 22 | """DeviceConfig stores the protocol specific configuration and status for each device.""" 23 | union DeviceConfig = Modbus | EthernetIP | Opcua 24 | 25 | """Credentials used to identify who is logging into the gateway.""" 26 | type User { 27 | id: ID! 28 | username: String! 29 | } 30 | 31 | """A Device is a something that can serve data to be used for updating tag values, such as a modbus TCP or Ethernet/IP server.""" 32 | type Device { 33 | id: ID! 34 | """Identifier for the device that will be also used for external services, such as MQTT Sparkplug B.""" 35 | name: String! 36 | """Description to allow for users to give the device more context.""" 37 | description: String! 38 | """Configuration is specific to the protocol used by the device, such as modbus or Ethernet/IP""" 39 | config: DeviceConfig 40 | """User who created the tag.""" 41 | createdBy: User 42 | """Date/time the tag was created.""" 43 | createdOn: DateTime! 44 | } 45 | 46 | type OpcuaNode { 47 | name: String! 48 | id: String! 49 | datatype: String 50 | value: String 51 | organizes: [OpcuaNode!] 52 | hasProperty: [OpcuaNode!] 53 | hasComponent: [OpcuaNode!] 54 | children: [OpcuaNode!]! 55 | } 56 | 57 | """Opcua is a device config allowing for access to data for updating tag values per the Opcua specification.""" 58 | type Opcua { 59 | id: ID! 60 | """Device for this Opcua configuration.""" 61 | device: Device! 62 | """Host or IP address of the modbus device.""" 63 | host: String! 64 | """Port of the modbus device.""" 65 | port: String! 66 | """Status of the modbus device connection. Will be connected if connection is successful or an error message if connection failed.""" 67 | status: String 68 | """Milliseconds between retries when connection is interrupted.""" 69 | retryRate: Int! 70 | """Nodes""" 71 | nodes: OpcuaNode 72 | """Non-heirarchical list""" 73 | flatNodes: [OpcuaNode!]! 74 | """List of sources (tag/nodeId) pairs that are using this opcua device.""" 75 | sources: [OpcuaSource!]! 76 | } 77 | 78 | """An OPC UA source reads an tag from an OPC UA device and updates a tag value per the tags scan class.""" 79 | type OpcuaSource { 80 | id: ID! 81 | """The OPC UA device this source uses to get the register values.""" 82 | opcua: Opcua! 83 | """The tag to update.""" 84 | tag: Tag! 85 | """The node id of the tag in the OPC UA device""" 86 | nodeId: String! 87 | } 88 | 89 | """Modbus is a device config allowing for access to data for updating tag values per the Modbus TCP specification.""" 90 | type Modbus { 91 | id: ID! 92 | """Device for this modbus configuration.""" 93 | device: Device! 94 | """Host or IP address of the modbus device.""" 95 | host: String! 96 | """Port of the modbus device.""" 97 | port: String! 98 | """Whether registers are stored as Big Endian (false) or Little Endian (true).""" 99 | reverseBits: Boolean! 100 | """Whether multiregister sources should use the lowest register first (false) or the highest register first (true).""" 101 | reverseWords: Boolean! 102 | """How long to wait to for connection with the device to complete before throwing an error.""" 103 | timeout: Int! 104 | """List of sources (tag/register) pairs that are using this modbus device.""" 105 | sources: [ModbusSource!]! 106 | """Status of the modbus device connection. Will be connected if connection is successful or an error message if connection failed.""" 107 | status: String 108 | """Whether registers start from zero or one. Can be used to make sure device addresses and those configured in the gateway match (and are not one off from eachother)""" 109 | zeroBased: Boolean! 110 | """Milliseconds between retries when connection is interrupted.""" 111 | retryRate: Int! 112 | } 113 | 114 | """A Mobus source reads a register from a modbus TCP device and updates a tag value per the tags scan class.""" 115 | type ModbusSource { 116 | id: ID! 117 | """The modbus device this source uses to get the register values.""" 118 | modbus: Modbus! 119 | """The tag to update.""" 120 | tag: Tag! 121 | """The starting register to read from the modbus device.""" 122 | register: Int! 123 | """The register type per the modbus specification. Can be `HOLDING_REGISTER`, `INPUT_REGISTER`, `DISCRETE_INPUT`, or `COIL`.""" 124 | registerType: ModbusRegisterType! 125 | } 126 | 127 | """Ethernet/IP is a device config allowing for access to data for updating tag values per the ODVA Ethernet/IP specification.""" 128 | type EthernetIP { 129 | id: ID! 130 | """Device for this Ethernet/IP configuration.""" 131 | device: Device! 132 | """Host or IP address of the Ethernet/IP device. Port is fixed at 44818.""" 133 | host: String! 134 | """Slot of the PLC. It is typically zero for devices that do no have slots or where the PLC is fixed.""" 135 | slot: String! 136 | """List of sources (tag/register) pairs that are using this Ethernet/IP device.""" 137 | sources: [EthernetIPSource!]! 138 | """Status of the Ethernet/IP device connection. Will be connected if connection is successful or an error message if connection failed.""" 139 | status: String 140 | } 141 | 142 | """An Ethernet/IP source reads an tag from an Ethernet/IP device and updates a tag value per the tags scan class.""" 143 | type EthernetIPSource { 144 | id: ID! 145 | """The ethernet/IP device this source uses to get the register values.""" 146 | ethernetip: EthernetIP! 147 | """The tag to update.""" 148 | tag: Tag! 149 | """The tagname of the tag in the Ethernet/IP device (not to be confused with the name of the tag in this gateway)""" 150 | tagname: String! 151 | } 152 | 153 | """A scan class allows for groups of tags to be updated at the same pre-defined rate.""" 154 | type ScanClass { 155 | id: ID! 156 | """Identifier for the scan class, used as a brief descriptor""" 157 | name: String! 158 | """Description to allow for users to give the scan class more context.""" 159 | description: String! 160 | """Rate at which to update that tags assigned to this scan class from their device source.""" 161 | rate: Int! 162 | """List of tags assigned to this scan class""" 163 | tags: [Tag!]! 164 | """The number of times this scan class has been scanned since the scan class scan started. This values clears to zero when the periodic scan is stopped.""" 165 | scanCount: Int! 166 | } 167 | 168 | """A Tag stores data point values. It's value can be updated from a device source per it's scan class, and it's value can be made available to external services like MQTT.""" 169 | type Tag { 170 | id: ID! 171 | """Identifier for the tag that will be also used for external services, such as MQTT Sparkplug B.""" 172 | name: String! 173 | """Description to allow for users to give the tag more context.""" 174 | description: String! 175 | """Tag value, which is updated at the scan class rate from the assigned source and also delivered to services that use this tag as a source.""" 176 | value: String 177 | """Format of that tag value, allowing clients to parse the value appropriately.""" 178 | datatype: Datatype! 179 | """Assigned scan class which determines the rate at which the tag is updated from it's assigned source.""" 180 | scanClass: ScanClass! 181 | """User who created the tag.""" 182 | createdBy: User 183 | """Date/time the tag was created.""" 184 | createdOn: DateTime! 185 | """Source from which this tag value is updated.""" 186 | source: Source 187 | """Maximum tag value (meant for use if the tag is numeric). Can be used to generate out of range indication and for graphical displays""" 188 | max: Float 189 | """Minimum tag value (meant for use if the tag is numeric). Can be used to generate out of range indication and for graphical displays""" 190 | min: Float 191 | """Deadband, used to determine whether to publish a change or write to history. If the change in value is less than the deadband, the update is ignored. If this value is zero, all changes are published and write to history.""" 192 | deadband: Float 193 | """Engineering units of the tag. Meant to be used for user displays to give context to a numerical value.""" 194 | units: String 195 | } 196 | 197 | """A service makes data available to external services by acting as a server or publishing the data as is done with MQTT.""" 198 | type Service { 199 | id: ID! 200 | """Identifier for the service.""" 201 | name: String! 202 | """Description to allow for users to give the service more context.""" 203 | description: String! 204 | """Configuration is specific to the protocol used by the service, such as MQTT""" 205 | config: Mqtt 206 | """User who created the service.""" 207 | createdBy: User 208 | """Date/time the service was created.""" 209 | createdOn: DateTime! 210 | } 211 | 212 | """MQTT is a service that allows for publishing tag values to an MQTT broker using the sparkplug B specification, which will server data to other subscribing nodes. One broker per service.""" 213 | type MqttPrimaryHost { 214 | id: ID! 215 | """Primary Host ID, used to verify primary host state for store and forward""" 216 | name: String! 217 | """UKNOWN before STATE has been received from broker, ONLINE/OFFLINE otherwise, indicating status""" 218 | status: String! 219 | """Number of historical records stored, awaiting forwarding""" 220 | recordCount: Int! 221 | } 222 | 223 | """MQTT is a service that allows for publishing tag values to an MQTT broker using the sparkplug B specification, which will server data to other subscribing nodes. One broker per service.""" 224 | type Mqtt { 225 | id: ID! 226 | """Hostname or IP address of the MQTT broker""" 227 | host: String! 228 | """Port for the service on the MQTT broker""" 229 | port: String! 230 | """Identifies a logical grouping of edge devices.""" 231 | group: String! 232 | """Identifies the edge device pushing data to the MQTT broker.""" 233 | node: String! 234 | """MQTT Broker username.""" 235 | username: String! 236 | """MQTT Broker password.""" 237 | password: String! 238 | """List of MQTT source devices that will be publishing to this broker.""" 239 | sources: [MqttSource!]! 240 | """Publishing rate in milliseconds""" 241 | rate: Int! 242 | """True if ssl:// is to be used, otherwise tcp:// will be used.""" 243 | encrypt: Boolean! 244 | """Maximum number of records to publish at one time while forwarding historical data.""" 245 | recordLimit: Int! 246 | """Primary host IDs. This is used for store and forward to detect if the consumers are online. The gateway will store data if any consumer is offline.""" 247 | primaryHosts: [MqttPrimaryHost!]! 248 | } 249 | 250 | """An MQTT source publishes data from all tags with the same device source. The device name will be used as the `Device` field in sparkplug B.""" 251 | type MqttSource { 252 | id: ID! 253 | """MQTT service (broker)""" 254 | mqtt: Mqtt! 255 | """Source device. All tags updating their values from this device will be published at the MQTT services configured scan rate.""" 256 | device: Device! 257 | """Number of historical records stored, awaiting forwarding""" 258 | recordCount: Int! 259 | } 260 | 261 | """The data returned after a successful login attempt.""" 262 | type AuthPayload { 263 | """Bearer token to be added to the Authorization header for future requests.""" 264 | token: String 265 | """User that successfully logged in.""" 266 | user: User 267 | } 268 | 269 | """Read only queries""" 270 | type Query { 271 | """Gets user based on authentication header and returns relevant data""" 272 | user: User 273 | """Requires a valid authorization token. List of all tags configured in this gateway""" 274 | tags: [Tag!]! 275 | """Requires a valid authorization token. ist of all scan classes configured in this gateway""" 276 | scanClasses: [ScanClass!]! 277 | """Requires a valid authorization token. List of all devices configured in this gateway""" 278 | devices(type: String): [Device!]! 279 | """Requires a valid authorization token. List of all services configured in this gateway""" 280 | services(type: String): [Service!]! 281 | } 282 | 283 | """Read/Write queries""" 284 | type Mutation { 285 | """If a valid username and password is provided, this will return an auth payload with a java web token to be used for future requests 286 | and information about the user that successfully logged in.""" 287 | login(username: String!, password: String!): AuthPayload 288 | """Allows the user to change their password""" 289 | changePassword(oldPassword: String!, newPassword: String!): User 290 | """Requires a valid authorization token. Creates a new scan class""" 291 | createScanClass(name: String!, description: String!, rate: Int!): ScanClass 292 | """Requires a valid authorization token. Updates an existing scan class""" 293 | updateScanClass(id: ID!, name: String, description: String, rate: Int!): ScanClass 294 | """Requires a valid authorization token. Deletes a scan class. Will not be successfully if there are tags currently assigned to this scan class.""" 295 | deleteScanClass(id: ID!): ScanClass 296 | """Requires a valid authorization token. Creates a new tag""" 297 | createTag(name: String!, description: String!, datatype: Datatype!, value: String!, scanClassId: ID!, min: Float, max: Float, deadband: Float, units: String): Tag 298 | """Requires a valid authorization token. Updates an existing tag""" 299 | updateTag(id: ID!, name: String, description: String, datatype: Datatype, value: String, scanClassId: ID, min: Float, max: Float, deadband: Float, units: String): Tag 300 | """Requires a valid authorization token. Deletes a tag. Will delete any source assigned to this tag, and tag will no longer be scanned.""" 301 | deleteTag(id: ID!): Tag 302 | """Requires a valid authorization token. Creates a opcua device, and automatically starts a connection.""" 303 | createOpcua( 304 | name: String!, 305 | description: String!, 306 | host: String!, 307 | port: Int!, 308 | retryRate: Int! 309 | ): Device 310 | """Requires a valid authorization token. Updates an existing opcua device and refreshes the connection.""" 311 | updateOpcua( 312 | id: ID!, 313 | name: String, 314 | description: String, 315 | host: String, 316 | port: Int, 317 | retryRate: Int 318 | ): Device 319 | """Requires a valid authorization token. Deletes a opcua device. All sources assigned to this device are deleted and tags using 320 | this device as a source will have their sources set to null, making their values static.""" 321 | deleteOpcua(id: ID!): Device 322 | """Requires a valid authorization token. Creates a OPC UA source. The tag value will then be updated to the value at the source register per the scan class""" 323 | createOpcuaSource(deviceId: ID!, tagId: ID!, nodeId: String!): OpcuaSource 324 | """Requires a valid authorization token. Updates a OPC UA source register. The tag value will then update per the new register at the rate of the scan class.""" 325 | updateOpcuaSource(tagId: ID!, nodeId: String!): OpcuaSource 326 | """Requires a valid authorization token. Deletes a OPC UA source. The tag value will then be static.""" 327 | deleteOpcuaSource(tagId: ID!): OpcuaSource 328 | """Requires a valid authorization token. Creates an Modbus TCP/IP device, and automatically starts a connection.""" 329 | createModbus( 330 | name: String!, 331 | description: String!, 332 | host: String!, 333 | port: Int!, 334 | reverseBits: Boolean!, 335 | reverseWords: Boolean!, 336 | zeroBased: Boolean!, 337 | timeout: Int!, 338 | retryRate: Int! 339 | ): Device 340 | """Requires a valid authorization token. Updates an existing modbus device and refreshes the connection.""" 341 | updateModbus( 342 | id: ID!, 343 | name: String, 344 | description: String, 345 | host: String, 346 | port: Int, 347 | reverseBits: Boolean, 348 | reverseWords: Boolean, 349 | zeroBased: Boolean, 350 | timeout: Int, 351 | retryRate: Int 352 | ): Device 353 | """Requires a valid authorization token. Deletes a modbus device. All sources assigned to this device are deleted and tags using 354 | this device as a source will have their sources set to null, making their values static.""" 355 | deleteModbus(id: ID!): Device 356 | """Requires a valid authorization token. Creates a modbus source. The tag value will then be updated to the value at the source register per the scan class""" 357 | createModbusSource(deviceId: ID!, tagId: ID!, register: Int!, registerType: ModbusRegisterType): ModbusSource 358 | """Requires a valid authorization token. Updates a modbus source register. The tag value will then update per the new register at the rate of the scan class.""" 359 | updateModbusSource(tagId: ID!, register: Int!, registerType: ModbusRegisterType): ModbusSource 360 | """Requires a valid authorization token. Deletes a modbus source. The tag value will then be static.""" 361 | deleteModbusSource(tagId: ID!): ModbusSource 362 | """Requires a valid authorization token. Creates an Ethernet/IP device, and automatically starts a connection.""" 363 | createEthernetIP( 364 | name: String!, 365 | description: String!, 366 | host: String!, 367 | slot: Int!, 368 | ): Device 369 | """Requires a valid authorization token. Updates an existing Ethernet/IP device and refreshes the connection.""" 370 | updateEthernetIP( 371 | id: ID!, 372 | name: String, 373 | description: String, 374 | host: String, 375 | slot: Int, 376 | ): Device 377 | """Requires a valid authorization token. Deletes an Ethernet/IP device. All sources assigned to this device are deleted and tags using 378 | this device as a source will have their sources set to null, making their values static.""" 379 | deleteEthernetIP(id: ID!): Device 380 | """Requires a valid authorization token. Creates an Ethernet/IP source. The tag value will then be updated to the value at the source register per the scan class""" 381 | createEthernetIPSource(deviceId: ID!, tagId: ID!, tagname: String): EthernetIPSource 382 | """Requires a valid authorization token. Updates an Ethernet/IP source tagname. The tag value will then update per the new tagname at the rate of the scan class.""" 383 | updateEthernetIPSource(tagId: ID!, tagname: String): EthernetIPSource 384 | """Requires a valid authorization token. Deletes an Ethernet/IP source. The tag value will then be static.""" 385 | deleteEthernetIPSource(tagId: ID!): EthernetIPSource 386 | """Requires a valid authorization token. Creates an MQTT Sparkplug B service (tied to a single MQTT broker).""" 387 | createMqtt( 388 | name: String!, 389 | description: String!, 390 | host: String!, 391 | port: Int!, 392 | group: String!, 393 | node: String!, 394 | username: String!, 395 | password: String!, 396 | devices: [ID!]!, 397 | rate: Int!, 398 | encrypt: Boolean!, 399 | recordLimit: Int!, 400 | primaryHosts: [String!] 401 | ): Service 402 | """Requires a valid authorization token. Updates an MQTT Sparkplug B service and restarts the connection.""" 403 | updateMqtt( 404 | id: ID!, 405 | name: String, 406 | description: String, 407 | host: String, 408 | port: Int, 409 | group: String, 410 | node: String, 411 | username: String, 412 | password: String, 413 | rate: Int, 414 | recordLimit: Int, 415 | encrypt: Boolean 416 | ): Service 417 | """Requires a valid authorization token. Adds a device to an MQTT service. All the tags with sources from that device will be published at the rate of the MQTT service configuration.""" 418 | addMqttSource(id: ID!, deviceId: ID!): Service 419 | """Requires a valid authorization token. Deletes a device from an MQTT service. The tags for the removed device will no longer be published to the broker (but the device and tags will still exist).""" 420 | deleteMqttSource(id: ID!, deviceId: ID!): Service 421 | """Requires a valid authorization token. Adds a primary host id to monitor (for store & forward)""" 422 | addMqttPrimaryHost(id: ID!, name: String): MqttPrimaryHost 423 | """Requires a valid authorization token. Deletes a primary host id to monitor (for store & forward)""" 424 | deleteMqttPrimaryHost(id: ID!, name: String): MqttPrimaryHost 425 | """Requires a valid authorization token. Deletes an MQTT Service. All devices assigned to this service will no longer have their tags published.""" 426 | deleteMqtt(id: ID!): Service 427 | } 428 | 429 | type Subscription { 430 | tagUpdate: Tag, 431 | deviceUpdate: Device, 432 | serviceUpdate: Service 433 | } -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const http = require('http') 4 | const express = require('express') 5 | const path = require('path') 6 | const sqlite3 = require('sqlite3').verbose() 7 | const { ApolloServer, PubSub, gql } = require('apollo-server-express') 8 | const resolvers = require('./resolvers') 9 | const { User, Tag, ScanClass, Device, Service } = require('./relations') 10 | const { executeQuery } = require('./database/model') 11 | const fs = require('fs') 12 | const logger = require('./logger') 13 | 14 | const app = express() 15 | 16 | const desiredUserVersion = 7 17 | 18 | let db = undefined 19 | let httpServer = undefined 20 | let server = undefined 21 | start = async function (dbFilename) { 22 | const dir = './database' 23 | 24 | if (!fs.existsSync(dir)) { 25 | fs.mkdirSync(dir) 26 | } 27 | let fileExisted = false 28 | // Create database 29 | if (dbFilename === `:memory:`) { 30 | db = new sqlite3.Database(`:memory:`, (error) => { 31 | if (error) { 32 | throw error 33 | } 34 | }) 35 | } else { 36 | if (fs.existsSync(`${dir}/${dbFilename}.db`)) { 37 | fileExisted = true 38 | } 39 | db = new sqlite3.cached.Database(`${dir}/${dbFilename}.db`, (error) => { 40 | if (error) { 41 | throw error 42 | } 43 | }) 44 | } 45 | const pubsub = new PubSub() 46 | server = new ApolloServer({ 47 | typeDefs: gql` 48 | ${fs.readFileSync(__dirname.concat('/schema.graphql'), 'utf8')} 49 | `, 50 | resolvers, 51 | subscriptions: { 52 | path: '/', 53 | }, 54 | context: (req) => ({ 55 | ...req, 56 | request: req, 57 | pubsub, 58 | db, 59 | users: User.instances, 60 | tags: Tag.instances, 61 | scanClasses: ScanClass.instances, 62 | devices: Device.instances, 63 | services: Service.instances, 64 | }), 65 | }) 66 | server.applyMiddleware({ app, path: '/' }) 67 | 68 | httpServer = http.createServer(app) 69 | server.installSubscriptionHandlers(httpServer) 70 | 71 | await new Promise(async (resolve, reject) => { 72 | httpServer.listen(4000, async () => { 73 | const context = server.context() 74 | await executeQuery(context.db, 'PRAGMA foreign_keys = ON', [], true) 75 | const { user_version: userVersion } = await executeQuery( 76 | context.db, 77 | 'PRAGMA user_version', 78 | [], 79 | true 80 | ) 81 | if ( 82 | dbFilename !== ':memory:' && 83 | fileExisted && 84 | userVersion !== desiredUserVersion 85 | ) { 86 | fs.copyFileSync( 87 | `${dir}/${dbFilename}.db`, 88 | `${dir}/${dbFilename}-backup-${new Date().toISOString()}.db` 89 | ) 90 | } 91 | //Check for administrator account and initialize one if it doesn't exist. 92 | await User.initialize(context.db, context.pubsub) 93 | await Tag.initialize(context.db, context.pubsub) 94 | await Device.initialize(context.db, context.pubsub) 95 | await Service.initialize(context.db, context.pubsub) 96 | for (device of Device.instances) { 97 | await device.config.connect() 98 | } 99 | for (service of Service.instances) { 100 | await service.config.connect() 101 | } 102 | for (scanClass of ScanClass.instances) { 103 | await scanClass.startScan() 104 | } 105 | await context.db.get(`PRAGMA user_version = ${desiredUserVersion}`) 106 | resolve() 107 | }) 108 | }) 109 | process.on('SIGINT', async () => { 110 | await stop() 111 | }) 112 | } 113 | 114 | stop = async function () { 115 | ScanClass.instances.forEach((instance) => { 116 | instance.stopScan() 117 | }) 118 | Device.instances.forEach((instance) => { 119 | instance.config.disconnect() 120 | }) 121 | Service.instances.forEach((instance) => { 122 | instance.config.disconnect() 123 | }) 124 | try { 125 | db.close() 126 | } catch (error) {} 127 | httpServer.close() 128 | } 129 | 130 | module.exports = { start, stop } 131 | -------------------------------------------------------------------------------- /src/service/__tests__/service.js: -------------------------------------------------------------------------------- 1 | jest.mock(`tentacle-sparkplug-client`) 2 | const sparkplug = require(`tentacle-sparkplug-client`) 3 | const getTime = require('date-fns/getTime') 4 | const parseISO = require('date-fns/parseISO') 5 | 6 | const { createTestDb, deleteTestDb } = require('../../../test/db') 7 | const { 8 | ScanClass, 9 | Tag, 10 | User, 11 | Device, 12 | Modbus, 13 | ModbusSource, 14 | Service, 15 | Mqtt, 16 | MqttSource, 17 | } = require('../../relations') 18 | const fromUnixTime = require('date-fns/fromUnixTime') 19 | const { matchesProperty } = require('lodash') 20 | 21 | const mockSparkplug = { 22 | on: jest.fn(), 23 | publishNodeBirth: jest.fn(), 24 | publishDeviceBirth: jest.fn(), 25 | publishDeviceData: jest.fn(), 26 | publishDeviceDeath: jest.fn(), 27 | stop: jest.fn(), 28 | on: jest.fn(), 29 | } 30 | 31 | const pubsub = {} 32 | let db = undefined 33 | beforeAll(async () => { 34 | db = await createTestDb() 35 | await ScanClass.initialize(db, pubsub) 36 | await User.initialize(db, pubsub) 37 | await Tag.initialize(db, pubsub) 38 | await Device.initialize(db, pubsub) 39 | modbus = await Modbus.create( 40 | `testDevice`, 41 | `Test Device`, 42 | `localhost`, 43 | 502, 44 | true, 45 | true, 46 | true, 47 | User.instances[0].id 48 | ) 49 | sparkplug.newClient.mockImplementation(() => { 50 | return mockSparkplug 51 | }) 52 | }) 53 | 54 | afterAll(async () => { 55 | await deleteTestDb(db) 56 | }) 57 | 58 | beforeEach(() => { 59 | jest.useFakeTimers() 60 | }) 61 | 62 | afterEach(() => { 63 | jest.clearAllMocks() 64 | }) 65 | 66 | test(`Initializing Service, also initializes Mqtt, MqttSource and MqttHistory.`, async () => { 67 | await Service.initialize(db, pubsub) 68 | expect(Service.initialized).toBe(true) 69 | expect(Mqtt.initialized).toBe(true) 70 | expect(MqttSource.initialized).toBe(true) 71 | }) 72 | let service = null 73 | test(`Mqtt: create creates a service with service config`, async () => { 74 | user = User.instances[0] 75 | const name = `aMqtt` 76 | const description = `A MQTT` 77 | const host = `localhost` 78 | const port = 1883 79 | const group = `aGroup` 80 | const node = `aNode` 81 | const username = `aUsername` 82 | const password = `aPassword` 83 | const devices = [1] 84 | const rate = 1000 85 | const encrypt = true 86 | const recordLimit = 50 87 | const createdBy = user.id 88 | const primaryHosts = ['aPrimaryHost', 'AnotherPrimaryHost'] 89 | const mqtt = await Mqtt.create( 90 | name, 91 | description, 92 | host, 93 | port, 94 | group, 95 | node, 96 | username, 97 | password, 98 | devices, 99 | rate, 100 | encrypt, 101 | recordLimit, 102 | createdBy, 103 | primaryHosts 104 | ) 105 | service = mqtt.service 106 | expect(mqtt.service).toBe(Service.instances[0]) 107 | expect(mqtt.service.name).toBe(name) 108 | expect(mqtt.service.description).toBe(description) 109 | expect(mqtt.host).toBe(host) 110 | expect(mqtt.port).toBe(port) 111 | expect(mqtt.group).toBe(group) 112 | expect(mqtt.node).toBe(node) 113 | expect(mqtt.username).toBe(username) 114 | expect(mqtt.password).toBe(password) 115 | expect(mqtt.sources.map((source) => source.device.id)).toEqual(devices) 116 | expect(mqtt.rate).toBe(rate) 117 | expect(mqtt.encrypt).toBe(encrypt) 118 | expect(mqtt.service.createdBy.id).toBe(user.id) 119 | expect(mqtt.primaryHosts.map((host) => host.name)).toEqual(primaryHosts) 120 | }) 121 | test(`Mqtt: add primary host, adds a primary host`, async () => { 122 | const mqtt = service.config 123 | const newHostName = `yetAnotherPrimaryHost` 124 | const prevPrimaryHosts = mqtt.primaryHosts.map((host) => host) 125 | const newHost = await mqtt.addPrimaryHost(newHostName) 126 | expect(mqtt.primaryHosts).toEqual([...prevPrimaryHosts, newHost]) 127 | }) 128 | test(`Mqtt: delete primary host, deletes a primary host`, async () => { 129 | const mqtt = service.config 130 | const deletedHost = `yetAnotherPrimaryHost` 131 | const primaryHosts = mqtt.primaryHosts.map((host) => host) 132 | await mqtt.deletePrimaryHost(deletedHost) 133 | expect(mqtt.primaryHosts).toEqual( 134 | primaryHosts.filter((host) => host.name !== deletedHost) 135 | ) 136 | }) 137 | test(`Mqtt: deleting a primary host, that doesn't exist throws an error`, async () => { 138 | const mqtt = service.config 139 | const deletedHost = `iDoNotExist` 140 | const primaryHosts = mqtt.primaryHosts.map((host) => host) 141 | expect( 142 | await mqtt.deletePrimaryHost(deletedHost).catch((e) => e) 143 | ).toMatchInlineSnapshot( 144 | `[Error: This mqtt service does not have a primary host named iDoNotExist]` 145 | ) 146 | expect(mqtt.primaryHosts).toEqual(primaryHosts) 147 | }) 148 | describe(`Service: `, () => { 149 | test(`check that init sets the appropriate underscore fields.`, async () => { 150 | Service.instances = [] 151 | const uninitService = new Service(service.id) 152 | await uninitService.init() 153 | expect(uninitService._name).toBe(service._name) 154 | expect(uninitService._description).toBe(service._description) 155 | expect(uninitService._type).toBe(service._type) 156 | expect(uninitService._createdBy).toBe(service._createdBy) 157 | expect(uninitService._CreatedOn).toBe(service._CreatedOn) 158 | await Service.getAll() 159 | }) 160 | test(`Getters all return their underscore values.`, async () => { 161 | expect(service.name).toBe(service._name) 162 | expect(service.description).toBe(service._description) 163 | expect(service.type).toBe(service._type) 164 | expect(service.createdBy.id).toBe(service._createdBy) 165 | expect(service.createdOn).toStrictEqual(fromUnixTime(service._createdOn)) 166 | }) 167 | test(`Setters all set the values appropriately.`, async () => { 168 | const name = `newName` 169 | const description = `New description` 170 | await service.setName(name) 171 | await service.setDescription(description) 172 | expect(service.name).toBe(name) 173 | expect(service.description).toBe(description) 174 | }) 175 | }) 176 | 177 | // ============================== 178 | // Mqtt 179 | // ============================== 180 | 181 | describe(`MQTT: `, () => { 182 | let mqtt = null 183 | test(`check that init sets the appropriate underscore fields.`, async () => { 184 | const mqttId = service.config.id 185 | Mqtt.instances = [] 186 | mqtt = new Mqtt(mqttId) 187 | await mqtt.init() 188 | expect(mqtt._host).toBe(service.config._host) 189 | expect(mqtt._port).toBe(service.config._port) 190 | expect(mqtt._group).toBe(service.config._group) 191 | expect(mqtt._node).toBe(service.config._node) 192 | expect(mqtt._username).toBe(service.config._username) 193 | expect(mqtt._password).toBe(service.config._password) 194 | expect(mqtt._devices).toBe(service.config._devices) 195 | expect(mqtt._rate).toBe(service.config._rate) 196 | expect(mqtt._encrypt).toBe(service.config._encrypt) 197 | expect(mqtt.connected).toBe(false) 198 | expect(mqtt.error).toBe(null) 199 | await Mqtt.getAll() 200 | }) 201 | test(`Connect sets up the sparkplug client.`, async () => { 202 | await mqtt.connect() 203 | expect(sparkplug.newClient).toBeCalledTimes(1) 204 | expect(mockSparkplug.on).toBeCalledTimes(6) 205 | expect(mockSparkplug.on).toBeCalledWith('reconnect', expect.any(Function)) 206 | expect(mockSparkplug.on).toBeCalledWith('error', expect.any(Function)) 207 | expect(mockSparkplug.on).toBeCalledWith('offline', expect.any(Function)) 208 | expect(mockSparkplug.on).toBeCalledWith('birth', expect.any(Function)) 209 | }) 210 | test(`onBirth calls all appropriate device births and starts publishing.`, () => { 211 | mqtt.onBirth() 212 | expect(mockSparkplug.publishNodeBirth).toBeCalledTimes(1) 213 | expect(mockSparkplug.publishNodeBirth).toBeCalledWith({ 214 | timestamp: expect.any(Number), 215 | metrics: expect.any(Object), 216 | }) 217 | expect(mockSparkplug.publishDeviceBirth).toBeCalledTimes( 218 | mqtt.sources.length 219 | ) 220 | mqtt.sources.forEach((source) => { 221 | expect(mockSparkplug.publishDeviceBirth).toBeCalledWith( 222 | `${source.device.name}`, 223 | { 224 | timestamp: expect.any(Number), 225 | metrics: source.device.config.sources.map((deviceSource) => { 226 | return { 227 | name: deviceSource.tag.name, 228 | value: `${deviceSource.tag.value}`, 229 | type: 'string', 230 | } 231 | }), 232 | } 233 | ) 234 | }) 235 | expect(setInterval).toBeCalledTimes(1) 236 | expect(setInterval).toBeCalledWith(expect.any(Function), mqtt.rate) 237 | }) 238 | test(`onOffline stops publishing.`, () => { 239 | mqtt.onOffline() 240 | expect(clearInterval).toBeCalledTimes(1) 241 | expect(clearInterval).toBeCalledWith(mqtt.interval) 242 | }) 243 | test(`onReconnect stops and then starts publishing.`, () => { 244 | mqtt.onReconnect() 245 | expect(clearInterval).toBeCalledTimes(2) 246 | expect(setInterval).toBeCalledTimes(1) 247 | expect(setInterval).toBeCalledWith(expect.any(Function), mqtt.rate) 248 | }) 249 | test(`onError publishing stops and mqtt error property is set.`, () => { 250 | const error = new Error('A really bad error') 251 | mqtt.onError(error) 252 | expect(mqtt.error.message).toBe(error.message) 253 | expect(clearInterval).toBeCalledTimes(1) 254 | mqtt.disconnect() 255 | mqtt.connect() 256 | }) 257 | test(`disconnect stops publishing, publishes death, and clears client`, () => { 258 | mqtt.disconnect() 259 | expect(clearInterval).toBeCalledTimes(1) 260 | expect(mockSparkplug.publishDeviceDeath).toBeCalledTimes( 261 | mqtt.sources.length 262 | ) 263 | mqtt.sources.forEach((source) => { 264 | expect(mockSparkplug.publishDeviceDeath).toBeCalledWith( 265 | `${source.device.name}`, 266 | { 267 | timestamp: expect.any(Number), 268 | } 269 | ) 270 | }) 271 | expect(mockSparkplug.stop).toBeCalledTimes(1) 272 | expect(mqtt.client).toBe(undefined) 273 | }) 274 | }) 275 | 276 | describe(`MQTT Source: `, () => { 277 | test(`Create creates instance and adds to Modbus.sources.`, async () => { 278 | const mqtt = Mqtt.instances[0] 279 | const anotherModbus = await Modbus.create( 280 | `testDevice2`, 281 | `Test Device 2`, 282 | `localhost`, 283 | 502, 284 | true, 285 | true, 286 | true, 287 | User.instances[0].id 288 | ) 289 | const mqttSource = await MqttSource.create(mqtt.id, anotherModbus.id) 290 | expect(MqttSource.instances[1].id).toBe(mqttSource.id) 291 | expect(mqtt.sources[1].id).toBe(mqttSource.id) 292 | }) 293 | test(`check that init sets the appropriate underscore fields.`, async () => { 294 | const id = MqttSource.instances[1].id 295 | const mqttId = MqttSource.instances[1]._mqtt 296 | const deviceId = MqttSource.instances[1]._device 297 | MqttSource.instances = [] 298 | mqttSource = new MqttSource(id) 299 | await mqttSource.init() 300 | expect(mqttSource._mqtt).toBe(mqttId) 301 | expect(mqttSource._device).toBe(deviceId) 302 | await MqttSource.getAll() 303 | }) 304 | }) 305 | 306 | test(`Tag: scan calls tag.source.read for each tag with a source and mqttSource.log`, async () => { 307 | spyOn(ModbusSource.prototype, 'read') 308 | spyOn(MqttSource.prototype, 'log') 309 | await ScanClass.create('default', 'default scan class', 3000) 310 | await ScanClass.instances[0].scan() 311 | expect(ModbusSource.prototype.read).toBeCalledTimes( 312 | ScanClass.instances[0].tags.filter((tag) => tag.source).length 313 | ) 314 | expect(MqttSource.prototype.log).toBeCalledTimes(MqttSource.instances.length) 315 | }) 316 | -------------------------------------------------------------------------------- /src/service/index.js: -------------------------------------------------------------------------------- 1 | const { Service } = require('./service') 2 | const { Mqtt, MqttSource, MqttHistory } = require('./mqtt') 3 | 4 | module.exports = { 5 | Service, 6 | Mqtt, 7 | MqttSource, 8 | MqttHistory, 9 | } 10 | -------------------------------------------------------------------------------- /src/service/mqtt.js: -------------------------------------------------------------------------------- 1 | const { Model, executeUpdate } = require(`../database`) 2 | const sparkplug = require(`tentacle-sparkplug-client`) 3 | const getUnixTime = require('date-fns/getUnixTime') 4 | const _ = require('lodash') 5 | const logger = require('../logger') 6 | 7 | const createTable = function (db, tableName, fields) { 8 | let sql = `CREATE TABLE IF NOT EXISTS "${tableName}" (` 9 | sql = `${sql} "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE` 10 | fields.forEach((field) => { 11 | if (field.colRef) { 12 | sql = `${sql}, "${field.colName}" INTEGER` 13 | } else { 14 | sql = `${sql}, "${field.colName}" ${field.colType}` 15 | } 16 | }) 17 | fields.forEach((field) => { 18 | if (field.colRef) { 19 | sql = `${sql}, FOREIGN KEY("${field.colName}") REFERENCES "${field.colRef}"("id") ON DELETE ${field.onDelete}` 20 | } 21 | }) 22 | sql = `${sql});` 23 | return executeUpdate(db, sql) 24 | } 25 | 26 | class Mqtt extends Model { 27 | static async upgradeToV3() { 28 | if (this.tableExisted && this.version < 3) { 29 | const newColumns = [{ colName: 'recordLimit', colType: 'TEXT' }] 30 | for (const column of newColumns) { 31 | let sql = `ALTER TABLE "${this.table}" ADD "${column.colName}" ${column.colType}` 32 | await this.executeUpdate(sql) 33 | } 34 | } 35 | } 36 | static async upgradeToV5() { 37 | let history = undefined 38 | let sql = undefined 39 | if (this.version < 5) { 40 | sql = `DROP TABLE mqttHistory` 41 | await this.executeUpdate(sql) 42 | sql = `DROP TABLE mqttPrimaryHostHistory` 43 | await this.executeUpdate(sql) 44 | } 45 | } 46 | static async initialize(db, pubsub) { 47 | await MqttSource.initialize(db, pubsub) 48 | await MqttPrimaryHost.initialize(db, pubsub) 49 | const result = await super.initialize(db, pubsub) 50 | await this.upgradeToV3() 51 | await this.upgradeToV5() 52 | let history = undefined 53 | let sql = undefined 54 | const mqttHistoryFields = [ 55 | { colName: 'mqttSource', colRef: 'mqttSource', onDelete: 'CASCADE' }, 56 | { colName: 'timestamp', colType: 'INTEGER' }, 57 | ] 58 | await createTable(this.db, 'mqttHistory', mqttHistoryFields) 59 | const mqttHistoryTagFields = [ 60 | { colName: 'mqttHistory', colRef: 'mqttHistory', onDelete: 'CASCADE' }, 61 | { colName: 'tag', colRef: 'tag', onDelete: 'CASCADE' }, 62 | { colName: 'value', colType: 'TEXT' }, 63 | ] 64 | await createTable(this.db, 'mqttHistoryTag', mqttHistoryTagFields) 65 | const mqttPrimaryHostHistoryFields = [ 66 | { 67 | colName: 'mqttPrimaryHost', 68 | colRef: 'mqttPrimaryHost', 69 | onDelete: 'CASCADE', 70 | }, 71 | { colName: 'mqttHistory', colRef: 'mqttHistory', onDelete: 'CASCADE' }, 72 | ] 73 | await createTable( 74 | this.db, 75 | 'mqttPrimaryHostHistory', 76 | mqttPrimaryHostHistoryFields 77 | ) 78 | return result 79 | } 80 | static async _createModel(fields) { 81 | const mqtt = await super.create(_.omit(fields, 'primaryHosts')) 82 | if (fields.primaryHosts) { 83 | for (const primaryHost of fields.primaryHosts) { 84 | await MqttPrimaryHost.create(mqtt.id, primaryHost) 85 | } 86 | } 87 | return mqtt 88 | } 89 | async init(async) { 90 | const result = await super.init(async) 91 | this._service = result.service 92 | this._host = result.host 93 | this._port = result.port 94 | this._group = result.group 95 | this._node = result.node 96 | this._username = result.username 97 | this._password = result.password 98 | this._rate = result.rate 99 | this._encrypt = result.encrypt 100 | this._recordLimit = result.recordLimit 101 | this.error = null 102 | } 103 | connect() { 104 | if (!this.client) { 105 | logger.info(`Mqtt service ${this.service.name} is connecting.`) 106 | const config = { 107 | serverUrl: `${this.encrypt ? 'ssl' : 'tcp'}://${this.host}:${ 108 | this.port 109 | }`, 110 | username: this.username, 111 | password: this.password, 112 | groupId: this.group, 113 | edgeNode: this.node, 114 | clientId: this.node, 115 | version: 'spBv1.0', 116 | publishDeath: true, 117 | } 118 | this.client = sparkplug.newClient(config) 119 | this.client.on('reconnect', () => { 120 | this.onReconnect() 121 | }) 122 | this.client.on('error', (error) => { 123 | this.onError(error) 124 | }) 125 | this.client.on('offline', () => { 126 | this.onOffline() 127 | }) 128 | this.client.on('birth', () => { 129 | this.onBirth() 130 | }) 131 | this.client.on('dcmd', (deviceId, payload) => { 132 | logger.info( 133 | `Mqtt service ${this.service.name} received a dcmd for ${deviceId}.` 134 | ) 135 | this.onDcmd(payload) 136 | }) 137 | this.client.on('ncmd', (payload) => { 138 | if (payload.metrics) { 139 | const rebirth = payload.metrics.find( 140 | (metric) => metric.name === `Node Control/Rebirth` 141 | ) 142 | if (rebirth) { 143 | if (rebirth.value) { 144 | logger.info( 145 | `Rebirth request detected from mqtt service ${this.service.name}. Reinitializing...` 146 | ) 147 | this.disconnect() 148 | this.connect() 149 | } 150 | } 151 | } 152 | }) 153 | } 154 | } 155 | onBirth() { 156 | const payload = { 157 | timestamp: getUnixTime(new Date()), 158 | metrics: [], 159 | } 160 | this.client.publishNodeBirth(payload) 161 | this.sources.forEach((source) => { 162 | this.client.publishDeviceBirth(`${source.device.name}`, { 163 | timestamp: getUnixTime(new Date()), 164 | metrics: source.device.config.sources.map((source) => { 165 | return { 166 | name: source.tag.name, 167 | value: `${source.tag.value}`, 168 | type: source.tag.datatype, 169 | timestamp: getUnixTime(new Date()), 170 | } 171 | }), 172 | }) 173 | }) 174 | this.primaryHosts.forEach((host) => { 175 | if (host.status === `ONLINE` || host.status === `UNKNOWN`) { 176 | host.readyForData = true 177 | } 178 | }) 179 | this.client.on('state', (primaryHostId, state) => { 180 | if (primaryHostId) { 181 | const primaryHost = this.primaryHosts 182 | .filter((host) => host.name === primaryHostId) 183 | .forEach((host) => { 184 | logger.info( 185 | `On ${this.service.name}, received state: ${state} for primary host: ${primaryHostId}.` 186 | ) 187 | if (host) { 188 | host.status = `${state}` 189 | if (`${state}` === `OFFLINE`) { 190 | host.readyForData = false 191 | } 192 | } 193 | this.pubsub.publish('serviceUpdate', { 194 | serviceUpdate: this.service, 195 | }) 196 | }) 197 | } 198 | }) 199 | this.startPublishing() 200 | } 201 | onReconnect() { 202 | this.stopPublishing() 203 | this.startPublishing() 204 | } 205 | onError(error) { 206 | this.error = error 207 | this.stopPublishing() 208 | } 209 | onOffline() { 210 | logger.info(`Mqtt service ${this.service.name} is offline.`) 211 | this.stopPublishing() 212 | } 213 | startPublishing() { 214 | this.interval = clearInterval(this.interval) 215 | this.interval = setInterval(() => { 216 | this.publish() 217 | this.publishHistory() 218 | this.pubsub.publish('serviceUpdate', { 219 | serviceUpdate: this.service, 220 | }) 221 | }, this.rate) 222 | } 223 | stopPublishing() { 224 | clearInterval(this.interval) 225 | } 226 | disconnect() { 227 | if (this.client) { 228 | logger.info(`Mqtt service ${this.service.name} is disconnecting.`) 229 | this.stopPublishing() 230 | const payload = { 231 | timestamp: getUnixTime(new Date()), 232 | } 233 | this.sources.forEach((source) => { 234 | if (this.testNumber) { 235 | this.testNumber += this.testNumber 236 | } else { 237 | this.testNumber = 1 238 | } 239 | try { 240 | this.client.publishDeviceDeath(`${source.device.name}`, payload) 241 | } catch (error) { 242 | console.log(source) 243 | } 244 | }) 245 | this.client.stop() 246 | this.client = undefined 247 | } 248 | } 249 | get primaryHosts() { 250 | this.checkInit() 251 | return MqttPrimaryHost.instances.filter((host) => { 252 | return host._mqtt === this.id 253 | }) 254 | } 255 | async addPrimaryHost(name) { 256 | return MqttPrimaryHost.create(this.id, name) 257 | } 258 | async deletePrimaryHost(name) { 259 | const primaryHost = MqttPrimaryHost.instances.find((instance) => { 260 | return instance._mqtt === this.id && instance.name === name 261 | }) 262 | if (!primaryHost) { 263 | throw Error( 264 | `This mqtt service does not have a primary host named ${name}` 265 | ) 266 | } 267 | return await primaryHost.delete() 268 | } 269 | async addSource(deviceId) { 270 | return MqttSource.create(this.id, deviceId) 271 | } 272 | async deleteSource(deviceId) { 273 | const source = this.sources.find((source) => { 274 | return source.device.id === parseInt(deviceId) 275 | }) 276 | if (source) { 277 | return source.delete() 278 | } else { 279 | throw Error( 280 | `The mqtt service ${this.service.name} is not using a source with device id: ${deviceId}` 281 | ) 282 | } 283 | } 284 | get host() { 285 | this.checkInit() 286 | return this._host 287 | } 288 | setHost(value) { 289 | return this.update(this.id, 'host', value).then( 290 | (result) => (this._host = result) 291 | ) 292 | } 293 | get port() { 294 | this.checkInit() 295 | return this._port 296 | } 297 | setPort(value) { 298 | return this.update(this.id, 'port', value).then( 299 | (result) => (this._port = result) 300 | ) 301 | } 302 | get group() { 303 | this.checkInit() 304 | return this._group 305 | } 306 | setGroup(value) { 307 | return this.update(this.id, 'group', value).then( 308 | (result) => (this._group = result) 309 | ) 310 | } 311 | get node() { 312 | this.checkInit() 313 | return this._node 314 | } 315 | setNode(value) { 316 | return this.update(this.id, 'node', value).then( 317 | (result) => (this._node = result) 318 | ) 319 | } 320 | get username() { 321 | this.checkInit() 322 | return this._username 323 | } 324 | setUsername(value) { 325 | return this.update(this.id, 'username', value).then( 326 | (result) => (this._username = result) 327 | ) 328 | } 329 | get password() { 330 | this.checkInit() 331 | return this._password 332 | } 333 | setPassword(value) { 334 | return this.update(this.id, 'password', value).then( 335 | (result) => (this._password = result) 336 | ) 337 | } 338 | get rate() { 339 | this.checkInit() 340 | return this._rate 341 | } 342 | setRate(value) { 343 | return this.update(this.id, 'rate', value).then( 344 | (result) => (this._rate = result) 345 | ) 346 | } 347 | get encrypt() { 348 | this.checkInit() 349 | return Boolean(this._encrypt) 350 | } 351 | setEncrypt(value) { 352 | return this.update(this.id, 'encrypt', value).then((result) => { 353 | this._encrypt = result 354 | }) 355 | } 356 | get recordLimit() { 357 | this.checkInit() 358 | return this._recordLimit 359 | } 360 | setRecordLimit(value) { 361 | return this.update(this.id, 'recordLimit', value).then((result) => { 362 | this._recordLimit = result 363 | }) 364 | } 365 | get connected() { 366 | this.checkInit() 367 | if (this.client) { 368 | return this.client.connected 369 | } else { 370 | return false 371 | } 372 | } 373 | } 374 | Mqtt.table = `mqtt` 375 | Mqtt.fields = [ 376 | { colName: 'service', colRef: 'service', onDelete: 'CASCADE' }, 377 | { colName: 'host', colType: 'TEXT' }, 378 | { colName: 'port', colType: 'INTEGER' }, 379 | { colName: 'group', colType: 'TEXT' }, 380 | { colName: 'node', colType: 'TEXT' }, 381 | { colName: 'username', colType: 'TEXT' }, 382 | { colName: 'password', colType: 'TEXT' }, 383 | { colName: 'rate', colType: 'INTEGER' }, 384 | { colName: 'encrypt', colType: 'INTEGER' }, 385 | { colName: 'primaryHost', colType: 'TEXT' }, 386 | { colName: 'recordLimit', colType: 'INTEGER' }, 387 | ] 388 | Mqtt.instances = [] 389 | Mqtt.initialized = false 390 | Mqtt.connected = false 391 | 392 | class MqttSource extends Model { 393 | static async initialize(db, pubsub) { 394 | return super.initialize(db, pubsub) 395 | } 396 | static create(mqtt, device) { 397 | const fields = { 398 | mqtt, 399 | device, 400 | } 401 | return super.create(fields) 402 | } 403 | async init() { 404 | const result = await super.init() 405 | this._mqtt = result.mqtt 406 | this._device = result.device 407 | this.rtHistory = [] 408 | } 409 | } 410 | MqttSource.table = `mqttSource` 411 | MqttSource.fields = [ 412 | { colName: 'mqtt', colRef: 'mqtt', onDelete: 'CASCADE' }, 413 | { colName: 'device', colRef: 'device', onDelete: 'CASCADE' }, 414 | ] 415 | MqttSource.instances = [] 416 | MqttSource.initialized = false 417 | 418 | class MqttPrimaryHost extends Model { 419 | static create(mqtt, name) { 420 | const fields = { 421 | mqtt, 422 | name, 423 | } 424 | return super.create(fields) 425 | } 426 | async init(async = false) { 427 | const result = await super.init(async) 428 | this._mqtt = result.mqtt 429 | this._name = result.name 430 | this.status = `UNKNOWN` 431 | this.readyForData = false 432 | } 433 | get name() { 434 | this.checkInit() 435 | return this._name 436 | } 437 | get mqtt() { 438 | this.checkInit() 439 | return Mqtt.instances.find((instance) => { 440 | instance.id === this._mqtt 441 | }) 442 | } 443 | async getRecordCount() { 444 | let sql = `SELECT COUNT(id) AS count FROM "mqttPrimaryHostHistory" WHERE mqttPrimaryHost=?` 445 | const result = await this.constructor.executeQuery(sql, [this.id], true) 446 | return result.count 447 | } 448 | getHistory(limit) { 449 | let sql = `SELECT 450 | a.id as id, 451 | a.mqttPrimaryHost as hostId, 452 | b.id as historyId, 453 | b.mqttSource as source, 454 | c.tag as tag, 455 | b.timestamp as timestamp, 456 | c.value as value 457 | FROM mqttPrimaryHostHistory AS a 458 | JOIN mqttHistory AS b 459 | ON a.mqttHistory=b.id 460 | JOIN mqttHistoryTag AS c 461 | ON b.id=c.mqttHistory 462 | WHERE a.mqttPrimaryHost=?` 463 | if (limit) { 464 | sql = `${sql} LIMIT ${limit}` 465 | } 466 | return this.constructor.executeQuery(sql, [this.id]) 467 | } 468 | } 469 | MqttPrimaryHost.table = `mqttPrimaryHost` 470 | MqttPrimaryHost.fields = [ 471 | { colName: 'mqtt', colRef: 'mqtt', onDelete: 'CASCADE' }, 472 | { colName: 'name', colType: 'TEXT' }, 473 | ] 474 | MqttPrimaryHost.instances = [] 475 | MqttPrimaryHost.initialized = false 476 | 477 | module.exports = { 478 | Mqtt, 479 | MqttSource, 480 | MqttPrimaryHost, 481 | } 482 | -------------------------------------------------------------------------------- /src/service/service.js: -------------------------------------------------------------------------------- 1 | const { Model } = require(`../database`) 2 | const { Mqtt, MqttSource, MqttPrimaryHost } = require(`./mqtt`) 3 | const getUnixTime = require('date-fns/getUnixTime') 4 | const fromUnixTime = require('date-fns/fromUnixTime') 5 | 6 | class Service extends Model { 7 | static async initialize(db, pubsub) { 8 | await Mqtt.initialize(db, pubsub) 9 | return super.initialize(db, pubsub, Service) 10 | } 11 | static create(name, description, type, createdBy) { 12 | const createdOn = getUnixTime(new Date()) 13 | const fields = { 14 | name, 15 | description, 16 | type, 17 | createdBy, 18 | createdOn, 19 | } 20 | return super.create(fields) 21 | } 22 | static async delete(selector) { 23 | const deleted = await super.delete(selector) 24 | await Mqtt.getAll() 25 | await MqttSource.getAll() 26 | await MqttPrimaryHost.getAll() 27 | return deleted 28 | } 29 | static findById(id) { 30 | return super.findById(id, Service) 31 | } 32 | constructor(selector, checkExists = true) { 33 | super(selector, Service, checkExists) 34 | } 35 | async init(async) { 36 | const result = await super.init(async) 37 | this._name = result.name 38 | this._description = result.description 39 | this._type = result.type 40 | this._createdBy = result.createdBy 41 | this._createdOn = result.createdOn 42 | } 43 | get name() { 44 | this.checkInit() 45 | return this._name 46 | } 47 | setName(value) { 48 | return this.update(this.id, 'name', value).then( 49 | (result) => (this._name = result) 50 | ) 51 | } 52 | get description() { 53 | this.checkInit() 54 | return this._description 55 | } 56 | setDescription(value) { 57 | return this.update(this.id, 'description', value).then( 58 | (result) => (this._description = result) 59 | ) 60 | } 61 | get type() { 62 | this.checkInit() 63 | return this._type 64 | } 65 | get createdOn() { 66 | this.checkInit() 67 | return fromUnixTime(this._createdOn) 68 | } 69 | } 70 | Service.table = `service` 71 | Service.instances = [] 72 | Service.fields = [ 73 | { colName: 'name', colType: 'TEXT' }, 74 | { colName: 'description', colType: 'TEXT' }, 75 | { colName: 'type', colType: 'TEXT' }, 76 | { colName: 'createdBy', colRef: 'user', onDelete: 'SET NULL' }, 77 | { colName: 'createdOn', colType: 'INTEGER' }, 78 | ] 79 | Service.initialized = false 80 | Service.connected = false 81 | 82 | module.exports = { 83 | Service, 84 | } 85 | -------------------------------------------------------------------------------- /src/tag.js: -------------------------------------------------------------------------------- 1 | const { Model } = require('./database') 2 | const { User } = require('./auth') 3 | const getUnixTime = require('date-fns/getUnixTime') 4 | const fromUnixTime = require('date-fns/fromUnixTime') 5 | 6 | class Tag extends Model { 7 | static async initialize(db, pubsub) { 8 | ScanClass.initialize(db, pubsub) 9 | const result = await super.initialize(db, pubsub) 10 | const newColumns = [] 11 | if (this.tableExisted && this.version === 0) { 12 | newColumns.push({ colName: 'units', colType: 'TEXT' }) 13 | newColumns.push({ colName: 'max', colType: 'REAL' }) 14 | newColumns.push({ colName: 'min', colType: 'REAL' }) 15 | } 16 | if (this.tableExisted && this.version < 7) { 17 | newColumns.push({ colName: 'deadband', colType: 'REAL' }) 18 | } 19 | for (const column of newColumns) { 20 | let sql = `ALTER TABLE "${this.table}" ADD "${column.colName}" ${column.colType}` 21 | await this.executeUpdate(sql) 22 | } 23 | return result 24 | } 25 | static create( 26 | name, 27 | description, 28 | value, 29 | scanClass, 30 | createdBy, 31 | datatype, 32 | max, 33 | min, 34 | deadband, 35 | units 36 | ) { 37 | const createdOn = getUnixTime(new Date()) 38 | const fields = { 39 | name, 40 | description, 41 | value, 42 | scanClass, 43 | createdBy, 44 | createdOn, 45 | datatype, 46 | max, 47 | min, 48 | deadband, 49 | units, 50 | } 51 | return super.create(fields) 52 | } 53 | static _deleteModel(selector) { 54 | return super.delete(selector) 55 | } 56 | async init(async) { 57 | const result = await super.init(async) 58 | this._name = result.name 59 | this._description = result.description 60 | this._value = result.value 61 | this._datatype = result.datatype 62 | this._scanClass = result.scanClass 63 | this._createdBy = result.createdBy 64 | this._createdOn = result.createdOn 65 | this._datatype = result.datatype 66 | this._max = result.max 67 | this._min = result.min 68 | this._units = result.units 69 | this._deadband = result.deadband 70 | this.prevValue = null 71 | this.lastChangeOn = getUnixTime(new Date()) 72 | this.prevChangeWithinDeadband = false 73 | this.prevChangeOn = getUnixTime(new Date()) 74 | } 75 | get name() { 76 | this.checkInit() 77 | return this._name 78 | } 79 | setName(value) { 80 | return this.update(this.id, 'name', value, Tag).then( 81 | (result) => (this._name = result) 82 | ) 83 | } 84 | get description() { 85 | this.checkInit() 86 | return this._description 87 | } 88 | setDescription(value) { 89 | return this.update(this.id, 'description', value, Tag).then( 90 | (result) => (this._description = result) 91 | ) 92 | } 93 | get value() { 94 | this.checkInit() 95 | if (this.datatype === 'INT32') { 96 | return parseInt(this._value) 97 | } else { 98 | return this._value 99 | } 100 | } 101 | async setValue(value, write = true) { 102 | this.checkInit() 103 | const lastValue = this.value 104 | this.lastChangeOn = this.timestamp 105 | if (!this.prevChangeWithinDeadband) { 106 | this.prevValue = lastValue 107 | this.prevChangeOn = this.lastChangeOn 108 | } 109 | if (this.source && write) { 110 | this.source.write(value) 111 | } 112 | const result = this.update(this.id, 'value', value, Tag).then((result) => { 113 | this._value = result 114 | this.pubsub.publish('tagUpdate', { tagUpdate: this }) 115 | }) 116 | this.timestamp = getUnixTime(new Date()) 117 | this.prevChangeWithinDeadband = 118 | this.datatype === 'BOOLEAN' 119 | ? this.value === this.prevValue 120 | : Math.abs(this.value - this.prevValue) < this.deadband 121 | if (!this.prevChangeWithinDeadband) { 122 | this.prevChangeWithinDeadband = false 123 | } 124 | return result 125 | } 126 | get createdOn() { 127 | this.checkInit() 128 | return fromUnixTime(this._createdOn) 129 | } 130 | get datatype() { 131 | this.checkInit() 132 | return this._datatype 133 | } 134 | setDatatype(datatype) { 135 | this.checkInit() 136 | return this.update(this.id, 'datatype', datatype, Tag).then( 137 | (result) => (this._datatype = result) 138 | ) 139 | } 140 | get max() { 141 | this.checkInit() 142 | return this._max 143 | } 144 | setMax(value) { 145 | this.checkInit() 146 | return this.update(this.id, 'max', value, Tag).then( 147 | (result) => (this._max = result) 148 | ) 149 | } 150 | get min() { 151 | this.checkInit() 152 | return this._min 153 | } 154 | setMin(value) { 155 | this.checkInit() 156 | return this.update(this.id, 'min', value, Tag).then( 157 | (result) => (this._min = result) 158 | ) 159 | } 160 | get units() { 161 | this.checkInit() 162 | return this._units 163 | } 164 | setUnits(value) { 165 | this.checkInit() 166 | return this.update(this.id, 'units', value, Tag).then( 167 | (result) => (this._units = result) 168 | ) 169 | } 170 | get deadband() { 171 | this.checkInit() 172 | return this._deadband 173 | } 174 | setDeadband(value) { 175 | this.checkInit() 176 | return this.update(this.id, 'deadband', value, Tag).then( 177 | (result) => (this._deadband = result) 178 | ) 179 | } 180 | } 181 | Tag.table = `tag` 182 | Tag.fields = [ 183 | { colName: 'name', colType: 'TEXT' }, 184 | { colName: 'description', colType: 'TEXT' }, 185 | { colName: 'scanClass', colRef: 'scanClass', onDelete: 'CASCADE' }, 186 | { colName: 'value', colType: 'TEXT' }, 187 | { colName: 'createdBy', colRef: 'user', onDelete: 'SET NULL' }, 188 | { colName: 'createdOn', colType: 'INTEGER' }, 189 | { colName: 'datatype', colType: 'TEXT' }, 190 | { colName: 'units', colType: 'TEXT' }, 191 | { colName: 'quality', colType: 'TEXT' }, 192 | { colName: 'max', colType: 'REAL' }, 193 | { colName: 'min', colType: 'REAL' }, 194 | { colName: 'deadband', colType: 'REAL' }, 195 | ] 196 | Tag.instances = [] 197 | Tag.initialized = false 198 | 199 | class ScanClass extends Model { 200 | static async initialize(db, pubsub) { 201 | const result = await super.initialize(db, pubsub) 202 | if (this.tableExisted && this.version < 2) { 203 | const newColumns = [ 204 | { colName: 'name', colType: 'TEXT' }, 205 | { colName: 'description', colType: 'TEXT' }, 206 | ] 207 | for (const column of newColumns) { 208 | let sql = `ALTER TABLE "${this.table}" ADD "${column.colName}" ${column.colType}` 209 | await this.executeUpdate(sql) 210 | } 211 | } 212 | return result 213 | } 214 | static create(name, description, rate, createdBy) { 215 | const createdOn = getUnixTime(new Date()) 216 | const fields = { 217 | name, 218 | description, 219 | rate, 220 | createdOn, 221 | createdBy, 222 | } 223 | return super.create(fields) 224 | } 225 | async init(async) { 226 | const result = await super.init(async) 227 | this._name = result.name 228 | this._description = result.description 229 | this._rate = result.rate 230 | this._createdBy = result.createdBy 231 | this._createdOn = result.createdOn 232 | this.scanCount = 0 233 | } 234 | startScan() { 235 | this.interval = clearInterval(this.interval) 236 | this.interval = setInterval(async () => { 237 | await this.scan() 238 | this.scanCount += 1 239 | }, this.rate) 240 | } 241 | stopScan() { 242 | if (this.interval) { 243 | clearInterval(this.interval) 244 | } 245 | this.scanCount = 0 246 | } 247 | get rate() { 248 | this.checkInit() 249 | return this._rate 250 | } 251 | setRate(value) { 252 | return this.update(this.id, 'rate', value, ScanClass).then( 253 | (result) => (this._rate = result) 254 | ) 255 | } 256 | get name() { 257 | this.checkInit() 258 | return this._name 259 | } 260 | setName(value) { 261 | return this.update(this.id, 'name', value, ScanClass).then( 262 | (result) => (this._name = result) 263 | ) 264 | } 265 | get description() { 266 | this.checkInit() 267 | return this._description 268 | } 269 | setDescription(value) { 270 | return this.update(this.id, 'description', value, ScanClass).then( 271 | (result) => (this._description = result) 272 | ) 273 | } 274 | get createdOn() { 275 | this.checkInit() 276 | return fromUnixTime(this._createdOn) 277 | } 278 | } 279 | ScanClass.table = `scanClass` 280 | ScanClass.fields = [ 281 | { colName: 'name', colType: 'TEXT' }, 282 | { colName: 'description', colType: 'TEXT' }, 283 | { colName: 'rate', colType: 'INTEGER' }, 284 | { colName: 'createdBy', colRef: 'user', onDelete: 'SET NULL' }, 285 | { colName: 'createdOn', colType: 'INTEGER' }, 286 | ] 287 | ScanClass.instances = [] 288 | ScanClass.initialized = false 289 | 290 | module.exports = { 291 | Tag, 292 | ScanClass, 293 | } 294 | -------------------------------------------------------------------------------- /test/db.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const sqlite3 = require('sqlite3') 4 | const { executeQuery } = require('../src/database/model') 5 | 6 | async function createTestDb(user_version = 2) { 7 | const db = await new Promise((resolve, reject) => { 8 | const database = new sqlite3.Database(':memory:', (error) => { 9 | if (error) { 10 | throw error 11 | } else { 12 | resolve(database) 13 | } 14 | }) 15 | }) 16 | await executeQuery(db, 'PRAGMA foreign_keys = ON') 17 | await executeQuery(db, `PRAGMA user_version = ${user_version}`) 18 | return db 19 | } 20 | 21 | async function deleteTestDb(db) { 22 | if (db) { 23 | await new Promise((resolve, reject) => { 24 | return db.close((error) => { 25 | if (error) { 26 | reject(error) 27 | } else { 28 | resolve() 29 | } 30 | }) 31 | }) 32 | // await new Promise((resolve, reject) => { 33 | // fs.unlink(db.filename, (error) => { 34 | // if (error) { 35 | // reject(error) 36 | // } else { 37 | // resolve() 38 | // } 39 | // }) 40 | // }).catch((error) => { 41 | // throw error 42 | // }) 43 | } else { 44 | console.log(db) 45 | } 46 | } 47 | 48 | module.exports = { 49 | createTestDb, 50 | deleteTestDb, 51 | } 52 | -------------------------------------------------------------------------------- /test/graphql/fragment.js: -------------------------------------------------------------------------------- 1 | const scalarTag = ` 2 | fragment ScalarTag on Tag { 3 | id 4 | name 5 | description 6 | datatype 7 | value 8 | createdBy { 9 | id 10 | username 11 | } 12 | createdOn 13 | max 14 | min 15 | deadband 16 | units 17 | } 18 | ` 19 | 20 | const scalarScanClass = ` 21 | fragment ScalarScanClass on ScanClass { 22 | id 23 | name 24 | description 25 | rate 26 | } 27 | ` 28 | 29 | const tag = ` 30 | fragment FullTag on Tag { 31 | ...ScalarTag 32 | scanClass { 33 | ...ScalarScanClass 34 | } 35 | } 36 | ${scalarTag} 37 | ${scalarScanClass} 38 | ` 39 | 40 | const scanClass = ` 41 | fragment FullScanClass on ScanClass { 42 | id 43 | rate 44 | tags { 45 | ...scalarTag 46 | } 47 | } 48 | ${scalarTag} 49 | ` 50 | const device = ` 51 | fragment FullDevice on Device { 52 | id 53 | name 54 | description 55 | createdBy { 56 | id 57 | username 58 | } 59 | createdOn 60 | config { 61 | ... on Modbus { 62 | id 63 | host 64 | port 65 | reverseBits 66 | reverseWords 67 | status 68 | zeroBased 69 | timeout 70 | retryRate 71 | sources { 72 | tag { 73 | ...ScalarTag 74 | } 75 | } 76 | } 77 | } 78 | config { 79 | ... on EthernetIP { 80 | id 81 | host 82 | slot 83 | sources { 84 | tag { 85 | ...ScalarTag 86 | } 87 | tagname 88 | } 89 | status 90 | } 91 | } 92 | config { 93 | ... on Opcua { 94 | id 95 | host 96 | port 97 | retryRate 98 | sources { 99 | tag { 100 | ...ScalarTag 101 | } 102 | nodeId 103 | } 104 | status 105 | nodes { 106 | id 107 | name 108 | datatype 109 | value 110 | children { 111 | id 112 | name 113 | datatype 114 | value 115 | } 116 | } 117 | flatNodes { 118 | id 119 | name 120 | datatype 121 | value 122 | } 123 | } 124 | } 125 | } 126 | ${scalarTag} 127 | ` 128 | 129 | const service = ` 130 | fragment FullService on Service { 131 | id 132 | name 133 | description 134 | createdBy { 135 | id 136 | username 137 | } 138 | createdOn 139 | config { 140 | ... on Mqtt { 141 | id 142 | host 143 | port 144 | group 145 | node 146 | username 147 | password 148 | rate 149 | encrypt 150 | recordLimit 151 | primaryHosts { 152 | id 153 | name 154 | status 155 | recordCount 156 | } 157 | sources { 158 | device { 159 | id 160 | } 161 | } 162 | } 163 | } 164 | } 165 | ` 166 | 167 | module.exports = { 168 | tag, 169 | scanClass, 170 | device, 171 | service, 172 | } 173 | -------------------------------------------------------------------------------- /test/graphql/index.js: -------------------------------------------------------------------------------- 1 | const fragment = require('./fragment') 2 | const query = require('./query') 3 | const mutation = require('./mutation') 4 | 5 | module.exports = { 6 | fragment, 7 | query, 8 | mutation, 9 | } 10 | -------------------------------------------------------------------------------- /test/graphql/mutation.js: -------------------------------------------------------------------------------- 1 | const fragment = require('./fragment') 2 | 3 | const createTag = ` 4 | mutation CreateTag( 5 | $name: String! 6 | $description: String! 7 | $value: String! 8 | $datatype: Datatype! 9 | $scanClassId: ID! 10 | $max: Float 11 | $min: Float 12 | $deadband: Float 13 | $units: String 14 | ) { 15 | createTag( 16 | name: $name 17 | description: $description 18 | value: $value 19 | datatype: $datatype 20 | scanClassId: $scanClassId 21 | max: $max 22 | min: $min 23 | deadband: $deadband 24 | units: $units 25 | ) { 26 | ...FullTag 27 | } 28 | } 29 | ${fragment.tag} 30 | ` 31 | 32 | const updateTag = `mutation UpdateTag( 33 | $id: ID!, 34 | $name: String 35 | $description: String 36 | $datatype: Datatype 37 | $value: String 38 | $max: Float 39 | $min: Float 40 | $units: String 41 | ) { 42 | updateTag( 43 | id: $id, 44 | name: $name 45 | description: $description 46 | datatype: $datatype 47 | value: $value 48 | max: $max 49 | min: $min 50 | units: $units 51 | ) { 52 | ...FullTag 53 | } 54 | } 55 | ${fragment.tag}` 56 | 57 | const deleteTag = `mutation DeleteTag( 58 | $id: ID!, 59 | ) { 60 | deleteTag( 61 | id: $id, 62 | ) { 63 | ...FullTag 64 | } 65 | } 66 | ${fragment.tag}` 67 | 68 | const createModbus = `mutation CreateModbus ( 69 | $name: String! 70 | $description: String! 71 | $host: String! 72 | $port: Int! 73 | $reverseBits: Boolean! 74 | $reverseWords: Boolean! 75 | $zeroBased: Boolean! 76 | $timeout: Int! 77 | $retryRate: Int! 78 | ){ 79 | createModbus( 80 | name: $name 81 | description: $description 82 | host: $host 83 | port: $port 84 | reverseBits: $reverseBits 85 | reverseWords: $reverseWords 86 | zeroBased: $zeroBased 87 | timeout: $timeout 88 | retryRate: $retryRate 89 | ) { 90 | ... FullDevice 91 | } 92 | } 93 | ${fragment.device}` 94 | 95 | const updateModbus = `mutation UpdateModbus ( 96 | $id: ID! 97 | $name: String 98 | $description: String 99 | $host: String 100 | $port: Int 101 | $reverseBits: Boolean 102 | $reverseWords: Boolean 103 | $zeroBased: Boolean 104 | $timeout: Int 105 | $retryRate: Int 106 | ){ 107 | updateModbus( 108 | id: $id 109 | name: $name 110 | description: $description 111 | host: $host 112 | port: $port 113 | reverseBits: $reverseBits 114 | reverseWords: $reverseWords 115 | zeroBased: $zeroBased 116 | timeout: $timeout 117 | retryRate: $retryRate 118 | ) { 119 | ... FullDevice 120 | } 121 | } 122 | ${fragment.device}` 123 | 124 | const deleteModbus = `mutation DeleteModbus ( 125 | $id: ID! 126 | ){ 127 | deleteModbus( 128 | id: $id 129 | ) { 130 | ... FullDevice 131 | } 132 | } 133 | ${fragment.device}` 134 | 135 | const createEthernetIP = `mutation CreateEthernetIP ( 136 | $name: String! 137 | $description: String! 138 | $host: String! 139 | $slot: Int! 140 | ){ 141 | createEthernetIP( 142 | name: $name 143 | description: $description 144 | host: $host 145 | slot: $slot 146 | ) { 147 | ... FullDevice 148 | } 149 | } 150 | ${fragment.device}` 151 | 152 | const updateEthernetIP = `mutation UpdateEthernetIP ( 153 | $id: ID! 154 | $name: String 155 | $description: String 156 | $host: String 157 | $slot: Int 158 | ){ 159 | updateEthernetIP( 160 | id: $id 161 | name: $name 162 | description: $description 163 | host: $host 164 | slot: $slot 165 | ) { 166 | ... FullDevice 167 | } 168 | } 169 | ${fragment.device}` 170 | 171 | const deleteEthernetIP = `mutation DeleteEthernetIP ( 172 | $id: ID! 173 | ){ 174 | deleteEthernetIP( 175 | id: $id 176 | ) { 177 | ... FullDevice 178 | } 179 | } 180 | ${fragment.device}` 181 | 182 | const createOpcua = `mutation CreateOpcua ( 183 | $name: String! 184 | $description: String! 185 | $host: String! 186 | $port: Int! 187 | $retryRate: Int! 188 | ){ 189 | createOpcua( 190 | name: $name 191 | description: $description 192 | host: $host 193 | port: $port 194 | retryRate: $retryRate 195 | ) { 196 | ... FullDevice 197 | } 198 | } 199 | ${fragment.device}` 200 | 201 | const updateOpcua = `mutation UpdateOpcua ( 202 | $id: ID! 203 | $name: String 204 | $description: String 205 | $host: String 206 | $port: Int 207 | $retryRate: Int 208 | ){ 209 | updateOpcua( 210 | id: $id 211 | name: $name 212 | description: $description 213 | host: $host 214 | port: $port 215 | retryRate: $retryRate 216 | ) { 217 | ... FullDevice 218 | } 219 | } 220 | ${fragment.device}` 221 | 222 | const deleteOpcua = `mutation DeleteOpcua ( 223 | $id: ID! 224 | ){ 225 | deleteOpcua( 226 | id: $id 227 | ) { 228 | ... FullDevice 229 | } 230 | } 231 | ${fragment.device}` 232 | 233 | const createMqtt = `mutation CreateMqtt ( 234 | $name: String! 235 | $description: String! 236 | $host: String! 237 | $port: Int! 238 | $group: String! 239 | $node: String! 240 | $username: String! 241 | $password: String! 242 | $devices: [ID!]! 243 | $rate: Int! 244 | $encrypt: Boolean! 245 | $recordLimit: Int! 246 | $primaryHosts: [String!] 247 | ){ 248 | createMqtt( 249 | name: $name 250 | description: $description 251 | host: $host 252 | port: $port 253 | group: $group 254 | node: $node 255 | username: $username 256 | password: $password 257 | devices: $devices 258 | rate: $rate 259 | encrypt: $encrypt 260 | recordLimit: $recordLimit 261 | primaryHosts: $primaryHosts 262 | ) { 263 | ... FullService 264 | } 265 | } 266 | ${fragment.service}` 267 | 268 | const updateMqtt = `mutation UpdateMqtt ( 269 | $id: ID! 270 | $name: String 271 | $description: String 272 | $host: String 273 | $port: Int 274 | $group: String 275 | $node: String 276 | $username: String 277 | $password: String 278 | $rate: Int 279 | $encrypt: Boolean 280 | $recordLimit: Int 281 | ){ 282 | updateMqtt( 283 | id: $id 284 | name: $name 285 | description: $description 286 | host: $host 287 | port: $port 288 | group: $group 289 | node: $node 290 | username: $username 291 | password: $password 292 | rate: $rate 293 | encrypt: $encrypt 294 | recordLimit: $recordLimit 295 | ) { 296 | ... FullService 297 | } 298 | } 299 | ${fragment.service}` 300 | 301 | const deleteMqtt = `mutation DeleteMqtt ( 302 | $id: ID! 303 | ){ 304 | deleteMqtt( 305 | id: $id 306 | ) { 307 | ... FullService 308 | } 309 | } 310 | ${fragment.service}` 311 | 312 | module.exports = { 313 | createTag, 314 | updateTag, 315 | deleteTag, 316 | createModbus, 317 | updateModbus, 318 | deleteModbus, 319 | createEthernetIP, 320 | updateEthernetIP, 321 | deleteEthernetIP, 322 | createOpcua, 323 | updateOpcua, 324 | deleteOpcua, 325 | createMqtt, 326 | updateMqtt, 327 | deleteMqtt, 328 | } 329 | -------------------------------------------------------------------------------- /test/graphql/query.js: -------------------------------------------------------------------------------- 1 | const fragment = require('./fragment') 2 | 3 | const tags = ` 4 | query Tags { 5 | tags { 6 | ...FullTag 7 | } 8 | } 9 | ${fragment.tag} 10 | ` 11 | 12 | const devices = ` 13 | query Devices { 14 | devices { 15 | ...FullDevice 16 | } 17 | } 18 | ${fragment.device} 19 | ` 20 | 21 | const services = ` 22 | query Services { 23 | services { 24 | ...FullService 25 | } 26 | } 27 | ${fragment.service} 28 | ` 29 | module.exports = { 30 | tags, 31 | devices, 32 | services, 33 | } 34 | --------------------------------------------------------------------------------