├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── bin └── opcua-commander ├── buildsnap.sh ├── create_certificate.js ├── docs ├── demo.gif ├── large.png ├── large1.png ├── screenshot.png ├── screenshot1.png ├── small.png └── small2.png ├── lib ├── controler │ └── controller.ts ├── index.ts ├── make_certificate.ts ├── model │ └── model.ts ├── utils │ ├── extract_browse_path.ts │ └── utils.ts ├── view │ ├── address_space_explorer.ts │ ├── alarm_box.ts │ ├── main_menu.ts │ └── view.ts └── widget │ ├── tree_item.ts │ └── widget_tree.ts ├── package-lock.json ├── package.json ├── readme.md ├── snap └── snapcraft.yaml └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "node": true, 6 | "es6": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "parserOptions": { 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | 14 | "no-unused-vars": [ 15 | "warn", 16 | { "vars": "all", "args": "after-used", "ignoreRestSiblings": false } 17 | ], 18 | "no-console": [ 19 | "warn" 20 | ], 21 | 22 | "indent": [ 23 | "warn", 24 | 4, 25 | {SwitchCase: 1} 26 | ], 27 | "linebreak-style": [ 28 | "off", 29 | "unix" 30 | ], 31 | "quotes": [ 32 | "error", 33 | "double" 34 | ], 35 | "semi": [ 36 | "error", 37 | "always" 38 | ], 39 | 40 | "no-var": 2, 41 | 42 | /* Node.js */ 43 | "callback-return": "error", //enforce return after a callback 44 | "global-require": 2, //enforce require() on top-level module scope 45 | "handle-callback-err": 2, //enforce error handling in callbacks 46 | "no-mixed-requires": 2, //disallow mixing regular variable and require declarations 47 | "no-new-require": 2, //disallow use of new operator with the require function 48 | "no-path-concat": "error", //disallow string concatenation with __dirname and __filename 49 | "no-process-exit": 2, //disallow process.exit() 50 | "no-restricted-modules": [1, ""], //restrict usage of specified node modules 51 | "no-sync": 1, //disallow use of synchronous methods 52 | } 53 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | index2.js 3 | certificates 4 | .idea 5 | /*.iml 6 | .vscode/ 7 | pki/ 8 | build/ 9 | dist/ 10 | *.snap 11 | bin/bundle.* 12 | credentials* 13 | *.tgz 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | docs/demo.gif 2 | *.tgz 3 | snap/ 4 | *.snap 5 | buildsnap.sh 6 | Dockerfile 7 | .vscode/ 8 | lib 9 | dist 10 | *.map 11 | tsconfig.json 12 | .dockerignore 13 | .eslintrc* 14 | .prett* 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 132, 3 | "trailingComma": "es5" 4 | 5 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine3.18 2 | 3 | RUN apk add dos2unix 4 | 5 | # Create app directory 6 | WORKDIR /opt/opcuacommander 7 | 8 | # Install app dependencies 9 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 10 | # where available (npm@5+) 11 | # Bundle app source 12 | COPY . . 13 | COPY package*.json ./ 14 | RUN dos2unix bin/opcua-commander 15 | 16 | # If you are building your code for production 17 | # The set registry can help in situations behind a firewall with scrict security settings and own CA Certificates. 18 | RUN npm config set registry http://registry.npmjs.org/ && npm install -g typescript && npm ci --mit=dev --unsafe-perm=true --allow-root && npm run build 19 | 20 | ENTRYPOINT [ "./bin/opcua-commander" ] 21 | # to build 22 | # docker build . -t commander 23 | # to run 24 | # docker run -it commander -e opc.tcp://localhost:26543 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Etienne Rossignon 2016-2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /bin/opcua-commander: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require("./bundle"); 4 | -------------------------------------------------------------------------------- /buildsnap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | npm run build 3 | npm run bundle 4 | VERSION=`jq '.version' package.json` 5 | echo version from package.json = $VERSION 6 | 7 | echo "----------------------------------------------------------------------" 8 | sed -i "s/^version: .*/version: ${VERSION}/" snap/snapcraft.yaml 9 | cat snap/snapcraft.yaml 10 | echo "----------------------------------------------------------------------" 11 | npm install 12 | snapcraft 13 | echo to nstall locally: snap install opcua-commander_${VERSION}_amd64.snap --dangerous 14 | echo to publish: snapcraft upload --release=stable opcua-commander_${VERSION}_amd64.snap 15 | 16 | -------------------------------------------------------------------------------- /create_certificate.js: -------------------------------------------------------------------------------- 1 | require("node-opcua-pki/bin/crypto_create_CA"); 2 | 3 | -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-opcua/opcua-commander/329613cc71497a31bbdfb5efdf486fbe9049f100/docs/demo.gif -------------------------------------------------------------------------------- /docs/large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-opcua/opcua-commander/329613cc71497a31bbdfb5efdf486fbe9049f100/docs/large.png -------------------------------------------------------------------------------- /docs/large1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-opcua/opcua-commander/329613cc71497a31bbdfb5efdf486fbe9049f100/docs/large1.png -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-opcua/opcua-commander/329613cc71497a31bbdfb5efdf486fbe9049f100/docs/screenshot.png -------------------------------------------------------------------------------- /docs/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-opcua/opcua-commander/329613cc71497a31bbdfb5efdf486fbe9049f100/docs/screenshot1.png -------------------------------------------------------------------------------- /docs/small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-opcua/opcua-commander/329613cc71497a31bbdfb5efdf486fbe9049f100/docs/small.png -------------------------------------------------------------------------------- /docs/small2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/node-opcua/opcua-commander/329613cc71497a31bbdfb5efdf486fbe9049f100/docs/small2.png -------------------------------------------------------------------------------- /lib/controler/controller.ts: -------------------------------------------------------------------------------- 1 | 2 | /* MVVM*/ 3 | 4 | export interface Controller { 5 | 6 | } 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-console: off , no-process-exit: off*/ 2 | require("source-map-support/register"); 3 | import { promisify } from "util"; 4 | import chalk from "chalk"; 5 | import { Model, makeUserIdentity } from "./model/model"; 6 | import { View } from "./view/view"; 7 | import { MessageSecurityMode, SecurityPolicy } from "node-opcua-client"; 8 | import { makeCertificate } from "./make_certificate"; 9 | import fs from "fs"; 10 | import path from "path"; 11 | 12 | const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8")); 13 | const version = packageJson.version; 14 | 15 | const check = require("check-node-version"); 16 | 17 | async function check_nodejs() { 18 | try { 19 | const result = await promisify(check)({ node: ">=12" }); 20 | if (result.isSatisfied) { 21 | return; 22 | } 23 | console.error("Some package version(s) failed!"); 24 | 25 | for (const packageName of Object.keys(result.versions)) { 26 | if (!result.versions[packageName].isSatisfied) { 27 | console.error(`Incorrect ${packageName} version. 28 | your version : ${result.versions[packageName].version.version} 29 | expected minimum version: ${result.versions[packageName].wanted.range}`); 30 | // ${JSON.stringify(result.versions[packageName],null, " ")} 31 | } 32 | } 33 | process.exit(); 34 | } catch (err) { 35 | console.error(err); 36 | process.exit(); 37 | } 38 | } 39 | 40 | // xx const updateNotifier = require("update-notifier"); 41 | const pkg = require("../package.json"); 42 | 43 | const argv = require("yargs") 44 | .wrap(132) 45 | 46 | .demand("endpoint") 47 | .string("endpoint") 48 | .describe("endpoint", "the end point to connect to ") 49 | 50 | .string("securityMode") 51 | .describe("securityMode", "the security mode") 52 | 53 | .string("securityPolicy") 54 | .describe("securityPolicy", "the policy mode") 55 | 56 | .string("userName") 57 | .describe("userName", "specify the user name of a UserNameIdentityToken ") 58 | 59 | .string("password") 60 | .describe("password", "specify the password of a UserNameIdentityToken") 61 | 62 | .string("node") 63 | .describe("node", "the nodeId of the value to monitor") 64 | 65 | .string("history") 66 | .describe("history", "make an historical read") 67 | 68 | .string("userCertificate") 69 | .describe("userCertificate", "X509 user certificate (PEM format)") 70 | 71 | .string("userCertificatePrivateKey") 72 | .describe("userCertificatePrivateKey", "X509 private key associated with the user certificate") 73 | 74 | .boolean("verbose") 75 | .describe("verbose", "display extra information") 76 | 77 | .alias("e", "endpoint") 78 | .alias("s", "securityMode") 79 | .alias("P", "securityPolicy") 80 | .alias("u", "userName") 81 | .alias("p", "password") 82 | .alias("n", "node") 83 | .alias("t", "timeout") 84 | .alias("v", "verbose") 85 | .alias("c", "userCertificate") 86 | .alias("x", "userCertificatePrivateKey") 87 | 88 | .example("opcua-commander --endpoint opc.tcp://localhost:49230 -P=Basic256 -s=Sign") 89 | .example("opcua-commander -e opc.tcp://localhost:49230 -P=Basic256 -s=Sign -u JoeDoe -p P@338@rd ") 90 | .example('opcua-commander --endpoint opc.tcp://localhost:49230 -n="ns=0;i=2258"').argv; 91 | 92 | const securityMode: MessageSecurityMode = MessageSecurityMode[argv.securityMode || "None"] as any as MessageSecurityMode; 93 | if (!securityMode) { 94 | throw new Error( 95 | `Invalid Security mode , was ${chalk.magenta(argv.securityMode)}\nshould be ${chalk.cyan( 96 | Object.values(MessageSecurityMode).filter(isNaN).join(",") 97 | )}` 98 | ); 99 | } 100 | 101 | const securityPolicy = (SecurityPolicy as any)[argv.securityPolicy || "None"]; 102 | if (!securityPolicy) { 103 | throw new Error( 104 | `Invalid securityPolicy\nwas : ${chalk.magenta(argv.securityPolicy)}\nshould be : ${chalk.cyan( 105 | Object.keys(SecurityPolicy).filter((k) => typeof k === "string" && k !== "Invalid" && !k.match(/PubSub/)) 106 | )}` 107 | ); 108 | } 109 | 110 | const endpointUrl = argv.endpoint || "opc.tcp://localhost:26543"; 111 | const yargs = require("yargs"); 112 | if (!endpointUrl) { 113 | yargs.showHelp(); 114 | // xx updateNotifier({ pkg }).notify(); 115 | process.exit(0); 116 | } 117 | 118 | (async () => { 119 | await check_nodejs(); 120 | 121 | const { certificateFile, clientCertificateManager, applicationUri, applicationName } = await makeCertificate(); 122 | 123 | const model = new Model(); 124 | const view = new View(model); 125 | await model.initialize( 126 | endpointUrl, 127 | securityMode, 128 | securityPolicy, 129 | certificateFile, 130 | clientCertificateManager, 131 | applicationName, 132 | applicationUri 133 | ); 134 | 135 | const node_opcua_version = require("node-opcua-client/package.json").version; 136 | 137 | console.log(chalk.green(" Welcome to Node-OPCUA Commander ") + version); 138 | console.log(chalk.green(" node-opcua = ") + node_opcua_version); 139 | console.log(chalk.cyan(" endpoint url = "), endpointUrl.toString()); 140 | console.log(chalk.cyan(" securityMode = "), MessageSecurityMode[securityMode]); 141 | console.log(chalk.cyan(" securityPolicy = "), securityPolicy.toString()); 142 | console.log(chalk.cyan(" certificate file = "), certificateFile); 143 | console.log(chalk.cyan(" trusted certificate folder = "), clientCertificateManager.trustedFolder); 144 | const userIdentity = makeUserIdentity(argv); 145 | model.doConnect(endpointUrl, userIdentity); 146 | 147 | model.on("connectionError", (err) => { 148 | console.log(chalk.red(" exiting")); 149 | view.logWindow.focus(); 150 | setTimeout(() => process.exit(-1), 10000); 151 | }); 152 | })(); 153 | -------------------------------------------------------------------------------- /lib/make_certificate.ts: -------------------------------------------------------------------------------- 1 | 2 | import envPaths from "env-paths"; 3 | import { makeApplicationUrn } from "node-opcua-client"; 4 | import { OPCUACertificateManager } from "node-opcua-certificate-manager"; 5 | import fs from "fs"; 6 | import path from "path"; 7 | import os from "os"; 8 | 9 | const paths = envPaths("opcua-commander"); 10 | 11 | export async function makeCertificate() { 12 | 13 | const configFolder = paths.config; 14 | 15 | const pkiFolder = path.join(configFolder, "pki"); 16 | const certificateManager = new OPCUACertificateManager({ 17 | rootFolder: pkiFolder 18 | }); 19 | 20 | console.log("PKI Folder = ", pkiFolder); 21 | 22 | const clientCertificateManager = new OPCUACertificateManager({ 23 | rootFolder: pkiFolder, 24 | automaticallyAcceptUnknownCertificate: true, 25 | name: "pki" 26 | }); 27 | 28 | await clientCertificateManager.initialize(); 29 | 30 | const certificateFile = path.join(pkiFolder, "own/certs/opcua_commander_certificate.pem"); 31 | const privateKeyFile = clientCertificateManager.privateKey; 32 | if (!fs.existsSync(privateKeyFile)) { 33 | throw new Error("Cannot find privateKeyFile " + privateKeyFile); 34 | } 35 | 36 | const applicationName = "OPCUA-COMMANDER"; 37 | const applicationUri = makeApplicationUrn(os.hostname(),applicationName); 38 | 39 | if (!fs.existsSync(certificateFile)) { 40 | 41 | await certificateManager.createSelfSignedCertificate({ 42 | applicationUri, 43 | outputFile: certificateFile, 44 | subject: `/CN=${applicationName}/O=Sterfive;/L=France`, 45 | dns: [], 46 | // ip: [], 47 | startDate: new Date(), 48 | validity: 365 * 10, 49 | }); 50 | } 51 | 52 | return { certificateFile, clientCertificateManager, applicationName, applicationUri }; 53 | } -------------------------------------------------------------------------------- /lib/model/model.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import os from "os"; 3 | import { 4 | bgBlueBright, 5 | yellow, 6 | cyanBright, 7 | greenBright, 8 | magenta, bgCyanBright, cyan, bgGreenBright, bgWhiteBright, yellowBright, green, magentaBright, red 9 | } 10 | from "chalk"; 11 | import { 12 | accessLevelFlagToString, 13 | AttributeIds, 14 | browseAll, 15 | BrowseDirection, 16 | ClientAlarmList, 17 | ClientMonitoredItem, 18 | ClientSession, 19 | ClientSubscription, 20 | DataType, 21 | DataTypeIds, 22 | DataValue, 23 | installAlarmMonitoring, 24 | MessageSecurityMode, 25 | MonitoringMode, 26 | NodeClass, 27 | NodeId, 28 | OPCUAClient, 29 | ReadValueIdOptions, 30 | ReferenceDescription, 31 | resolveNodeId, 32 | SecurityPolicy, 33 | TimestampsToReturn, 34 | UserIdentityInfo, 35 | UserTokenType, 36 | Variant, 37 | VariantArrayType, 38 | WriteValue, 39 | } from "node-opcua-client"; 40 | import { OPCUACertificateManager } from "node-opcua-certificate-manager"; 41 | import { StatusCodes } from "node-opcua-status-code"; 42 | import { findBasicDataType } from "node-opcua-pseudo-session"; 43 | 44 | import { w } from "../utils/utils"; 45 | import { extractBrowsePath } from "../utils/extract_browse_path"; 46 | import { TreeItem } from "../widget/tree_item"; 47 | 48 | const attributeKeys: string[] = []; 49 | for (let i = 1; i <= AttributeIds.AccessLevelEx - 1; i++) { 50 | attributeKeys.push(AttributeIds[i]); 51 | } 52 | 53 | const data = { 54 | reconnectionCount: 0, 55 | tokenRenewalCount: 0, 56 | receivedBytes: 0, 57 | sentBytes: 0, 58 | sentChunks: 0, 59 | receivedChunks: 0, 60 | backoffCount: 0, 61 | transactionCount: 0, 62 | }; 63 | 64 | export interface NodeChild { 65 | arrow: string; 66 | displayName: string; 67 | nodeId: NodeId; 68 | nodeClass: NodeClass; 69 | } 70 | 71 | export function makeUserIdentity(argv: any): UserIdentityInfo { 72 | let userIdentity: UserIdentityInfo = { type: UserTokenType.Anonymous }; // anonymous 73 | 74 | if (argv.userName && argv.password) { 75 | userIdentity = { 76 | type: UserTokenType.UserName, 77 | userName: argv.userName, 78 | password: argv.password, 79 | }; 80 | } else if (argv.userCertificate && argv.userCertificatePrivateKey) { 81 | userIdentity = { 82 | type: UserTokenType.Certificate, 83 | certificateData: argv.userCertificate, 84 | privateKey: "todo", 85 | }; 86 | } 87 | return userIdentity; 88 | } 89 | 90 | export interface Model { 91 | on(eventName: "connectionError", eventHandler: (err: Error) => void): this; 92 | on(eventName: "alarmChanged", eventHandler: (list: ClientAlarmList) => void): this; 93 | on(eventName: "monitoredItemListUpdated", eventHandler: (monitoredItemsListData: any) => void): this; 94 | on(eventName: "monitoredItemChanged", eventHandler: (monitoredItemsListData: any, node: any, dataValue: DataValue) => void): this; 95 | on(eventName: "nodeChanged", eventHandler: (nodeId: NodeId) => void): this; 96 | } 97 | 98 | const hasComponentNodeId = resolveNodeId("HasComponent").toString(); 99 | const hasPropertyNodeId = resolveNodeId("HasProperty").toString(); 100 | const hasSubTypeNodeId = resolveNodeId("HasSubtype").toString(); 101 | const organizesNodeId = resolveNodeId("Organizes").toString(); 102 | function referenceToSymbol(ref: ReferenceDescription) { 103 | // "+-->" // aggregate 104 | switch (ref.referenceTypeId.toString()) { 105 | case organizesNodeId: 106 | return "─o──"; 107 | case hasComponentNodeId: 108 | return "──┼"; 109 | case hasPropertyNodeId: 110 | return "──╫"; 111 | case hasSubTypeNodeId: 112 | return "───▷"; 113 | default: 114 | return "-->"; 115 | } 116 | } 117 | function symbol(ref: ReferenceDescription) { 118 | const s = " "; 119 | if (ref.typeDefinition.toString() === "ns=0;i=61") { 120 | return [yellow("[F]"), yellow("[F]")]; // ["🗀", "🗁"]; // "📁⧇Ⓞ" 121 | } 122 | switch (ref.nodeClass) { 123 | case NodeClass.Object: 124 | return [cyanBright("[O]"), cyanBright("[O]")]; 125 | case NodeClass.Variable: 126 | return [greenBright("[V]"), greenBright("[V]")]; 127 | case NodeClass.Method: 128 | return [magenta("[M]"), magenta("[M]")]; 129 | case NodeClass.ObjectType: 130 | return [bgCyanBright("[O]"), cyan("[OT]")]; 131 | case NodeClass.VariableType: 132 | return [bgGreenBright("[V]"), yellow("Ⓥ")]; 133 | case NodeClass.ReferenceType: 134 | return [bgWhiteBright.black("[R]"), yellowBright("➾")]; 135 | case NodeClass.DataType: 136 | return [bgBlueBright("[D]"), bgBlueBright("Ⓓ")]; 137 | case NodeClass.View: 138 | return [magentaBright("[V]"), magentaBright("Ⓓ")]; 139 | } 140 | return s; 141 | } 142 | 143 | export class Model extends EventEmitter { 144 | private client?: OPCUAClient; 145 | private session?: ClientSession; 146 | private subscription?: ClientSubscription; 147 | private userIdentity: UserIdentityInfo = { type: UserTokenType.Anonymous }; 148 | public verbose: boolean = false; 149 | private endpointUrl: string = ""; 150 | private monitoredItemsListData: any[] = []; 151 | private clientAlarms: ClientAlarmList = new ClientAlarmList(); 152 | 153 | public data: any; 154 | public constructor() { 155 | super(); 156 | this.data = data; 157 | } 158 | 159 | public async initialize( 160 | endpoint: string, 161 | securityMode: MessageSecurityMode, 162 | securityPolicy: SecurityPolicy, 163 | certificateFile: string, 164 | clientCertificateManager: OPCUACertificateManager, 165 | applicationName: string, 166 | applicationUri: string 167 | ) { 168 | this.endpointUrl = this.endpointUrl; 169 | 170 | this.client = OPCUAClient.create({ 171 | endpointMustExist: false, 172 | 173 | securityMode, 174 | securityPolicy, 175 | 176 | defaultSecureTokenLifetime: 40000, // 40 seconds 177 | 178 | certificateFile, 179 | 180 | clientCertificateManager, 181 | 182 | applicationName, 183 | applicationUri, 184 | 185 | clientName: "Opcua-Commander-" + os.hostname(), 186 | keepSessionAlive: true, 187 | }); 188 | 189 | this.client.on("send_request", function () { 190 | data.transactionCount++; 191 | }); 192 | 193 | this.client.on("send_chunk", function (chunk) { 194 | data.sentBytes += chunk.length; 195 | data.sentChunks++; 196 | }); 197 | 198 | this.client.on("receive_chunk", function (chunk) { 199 | data.receivedBytes += chunk.length; 200 | data.receivedChunks++; 201 | }); 202 | 203 | this.client.on("backoff", function (number, delay) { 204 | data.backoffCount += 1; 205 | console.log(yellow(`backoff attempt #${number} retrying in ${delay / 1000.0} seconds`)); 206 | }); 207 | 208 | this.client.on("start_reconnection", () => { 209 | console.log(red(" !!!!!!!!!!!!!!!!!!!!!!!! Starting reconnection !!!!!!!!!!!!!!!!!!! " + this.endpointUrl)); 210 | }); 211 | 212 | this.client.on("connection_reestablished", () => { 213 | console.log(red(" !!!!!!!!!!!!!!!!!!!!!!!! CONNECTION RE-ESTABLISHED !!!!!!!!!!!!!!!!!!! " + this.endpointUrl)); 214 | data.reconnectionCount++; 215 | }); 216 | 217 | // monitoring des lifetimes 218 | this.client.on("lifetime_75", (token) => { 219 | if (this.verbose) { 220 | console.log(red("received lifetime_75 on " + this.endpointUrl)); 221 | } 222 | }); 223 | 224 | this.client.on("security_token_renewed", () => { 225 | data.tokenRenewalCount += 1; 226 | if (this.verbose) { 227 | console.log(green(" security_token_renewed on " + this.endpointUrl)); 228 | } 229 | }); 230 | } 231 | public async create_subscription() { 232 | if (!this.session) { 233 | throw new Error("Invalid Session"); 234 | } 235 | const parameters = { 236 | requestedPublishingInterval: 500, 237 | requestedLifetimeCount: 1000, 238 | requestedMaxKeepAliveCount: 12, 239 | maxNotificationsPerPublish: 100, 240 | publishingEnabled: true, 241 | priority: 10, 242 | }; 243 | try { 244 | this.subscription = await this.session.createSubscription2(parameters); 245 | console.log("subscription created"); 246 | } catch (err) { 247 | console.log("Cannot create subscription"); 248 | } 249 | } 250 | 251 | public async doConnect(endpointUrl: string, userIdentity: UserIdentityInfo) { 252 | this.userIdentity = userIdentity; 253 | console.log("connecting to ....", endpointUrl); 254 | try { 255 | await this.client!.connect(endpointUrl); 256 | } catch (err) { 257 | console.log(" Cannot connect", err.toString()); 258 | if (this.client!.securityMode !== MessageSecurityMode.None && err.message.match(/has been disconnected by third party/)) { 259 | console.log( 260 | "Because you are using a secure connection, you need to make sure that the certificate\n" + 261 | "of opcua-commander is trusted by the server you're trying to connect to.\n" + 262 | "Please see the documentation for instructions on how to import a certificate into the CA store of the server.\n" + 263 | `The opcua-commander certificate is in the folder \n${cyan(this.client!.certificateFile)}` 264 | ); 265 | } 266 | this.emit("connectionError", err); 267 | return; 268 | } 269 | 270 | try { 271 | this.session = await this.client!.createSession(this.userIdentity); 272 | } catch (err) { 273 | console.log(" Cannot create session ", err.toString()); 274 | console.log(red(" exiting")); 275 | setTimeout(function () { 276 | return process.exit(-1); 277 | }, 25000); 278 | return; 279 | } 280 | this.session.on("session_closed", () => { 281 | console.log(" Warning => Session closed"); 282 | }); 283 | this.session.on("keepalive", () => { 284 | console.log("session keepalive"); 285 | }); 286 | this.session.on("keepalive_failure", () => { 287 | console.log("session keepalive failure"); 288 | }); 289 | console.log("connected to ....", endpointUrl); 290 | await this.create_subscription(); 291 | } 292 | 293 | public async disconnect(): Promise { 294 | if (this.session) { 295 | const session = this.session; 296 | this.session = undefined; 297 | await session.close(); 298 | } 299 | await this.client!.disconnect(); 300 | } 301 | 302 | public request_write_item(treeItem: any) { 303 | if (!this.subscription) return; 304 | const node = treeItem.node; 305 | return treeItem; 306 | } 307 | 308 | public async writeNode(node: { nodeId: NodeId }, data: any) { 309 | const dataTypeIdDataValue = await this.session.read({ nodeId: node.nodeId, attributeId: AttributeIds.DataType }); 310 | const arrayDimensionDataValue = await this.session.read({ nodeId: node.nodeId, attributeId: AttributeIds.ArrayDimensions }); 311 | const valueRankDataValue = await this.session.read({ nodeId: node.nodeId, attributeId: AttributeIds.ValueRank }); 312 | 313 | const dataTypeId = dataTypeIdDataValue.value.value as NodeId; 314 | const dataType = await findBasicDataType(this.session, dataTypeId); 315 | 316 | const arrayDimension = arrayDimensionDataValue.value.value as null | number[]; 317 | const valueRank = valueRankDataValue.value.value as number; 318 | 319 | const coerceBoolean = (data: any) => { 320 | return data === "true" || data === "1" || data === true; 321 | }; 322 | const coerceNumber = (data: any) => { 323 | return parseInt(data, 10); 324 | }; 325 | const coerceNumberR = (data: any) => { 326 | return parseFloat(data); 327 | }; 328 | 329 | const coerceNoop = (data: any) => data; 330 | 331 | const coerceFunc = (dataType: DataType) => { 332 | switch (dataType) { 333 | case DataType.Boolean: 334 | return coerceBoolean; 335 | case DataType.Int16: 336 | case DataType.Int32: 337 | case DataType.Int64: 338 | case DataType.UInt16: 339 | case DataType.UInt32: 340 | case DataType.UInt64: 341 | return coerceNumber; 342 | case DataType.Double: 343 | case DataType.Float: 344 | return coerceNumberR; 345 | default: 346 | return coerceNoop; 347 | } 348 | }; 349 | 350 | 351 | if (dataType) { 352 | try { 353 | const arrayType = 354 | valueRank === -1 ? VariantArrayType.Scalar : valueRank === 1 ? VariantArrayType.Array : VariantArrayType.Matrix; 355 | const dimensions = arrayType === VariantArrayType.Matrix ? arrayDimension : undefined; 356 | 357 | function coerceStringToDataType(data: any) { 358 | const c = coerceFunc(dataType); 359 | if (arrayType === VariantArrayType.Scalar) { 360 | return c(data); 361 | } else { 362 | return data.map((d: any) => c(d)); 363 | } 364 | } 365 | const value = new Variant({ 366 | dataType, 367 | arrayType, 368 | dimensions, 369 | value: coerceStringToDataType(data), 370 | }); 371 | const writeValue = new WriteValue({ 372 | nodeId: node.nodeId, 373 | attributeId: AttributeIds.Value, 374 | value: { 375 | value, 376 | }, 377 | }); 378 | let statusCode = await this.session.write(writeValue); 379 | console.log("writing ", writeValue.toString()); 380 | console.log("statusCode ", statusCode.toString()); 381 | this.emit("nodeChanged", node.nodeId); 382 | return statusCode; 383 | } catch (err) { 384 | return StatusCodes.BadInternalError; 385 | } 386 | } 387 | 388 | return false; 389 | } 390 | 391 | public async extractBrowsePath(nodeId: NodeId): Promise { 392 | return await extractBrowsePath(this.session, nodeId); 393 | } 394 | public async readNode(node: any) { 395 | return await this.session.read(node); 396 | } 397 | public async readNodeValue(node: any) { 398 | if (!this.session) { 399 | return null; 400 | } 401 | 402 | const dataValues = await this.readNode(node); 403 | if (dataValues.statusCode == StatusCodes.Good) { 404 | if (dataValues.value.value) { 405 | switch (dataValues.value.arrayType) { 406 | case VariantArrayType.Scalar: 407 | return "" + dataValues.value.value; 408 | case VariantArrayType.Array: 409 | return dataValues.value.value.join(","); 410 | default: 411 | return ""; 412 | } 413 | } 414 | } 415 | return null; 416 | } 417 | 418 | public monitor_item(treeItem: TreeItem) { 419 | if (!this.subscription) return; 420 | const node = treeItem.node; 421 | 422 | this.subscription.monitor( 423 | { 424 | nodeId: node.nodeId, 425 | attributeId: AttributeIds.Value, 426 | //, dataEncoding: { namespaceIndex: 0, name:null } 427 | }, 428 | { 429 | samplingInterval: 1000, 430 | discardOldest: true, 431 | queueSize: 100, 432 | }, 433 | TimestampsToReturn.Both, 434 | MonitoringMode.Reporting, 435 | (err: Error | null, monitoredItem: ClientMonitoredItem) => { 436 | if (err) { 437 | console.log("cannot create monitored item", err.message); 438 | return; 439 | } 440 | 441 | node.monitoredItem = monitoredItem; 442 | 443 | const monitoredItemData = [node.displayName, node.nodeId.toString(), "Q"]; 444 | 445 | this.monitoredItemsListData.push(monitoredItemData); 446 | 447 | this.emit("monitoredItemListUpdated", this.monitoredItemsListData); 448 | // xxx monitoredItemsList.setRows(monitoredItemsListData); 449 | 450 | monitoredItem.on("changed", (dataValue: DataValue) => { 451 | console.log(" value ", node.browseName, node.nodeId.toString(), " changed to ", green(dataValue.value.toString())); 452 | if (dataValue.value.value.toFixed) { 453 | node.valueAsString = w(dataValue.value.value.toFixed(3), 16, " "); 454 | } else { 455 | node.valueAsString = w(dataValue.value.value.toString(), 16, " "); 456 | } 457 | monitoredItemData[2] = node.valueAsString; 458 | 459 | this.emit("monitoredItemChanged", this.monitoredItemsListData, node, dataValue); 460 | }); 461 | } 462 | ); 463 | } 464 | 465 | public unmonitor_item(treeItem: TreeItem) { 466 | const node = treeItem.node; 467 | 468 | // terminate subscription 469 | node.monitoredItem.terminate(() => { 470 | let index = -1; 471 | this.monitoredItemsListData.forEach((entry, i) => { 472 | if (entry[1] == node.nodeId.toString()) { 473 | index = i; 474 | } 475 | }); 476 | if (index > -1) { 477 | this.monitoredItemsListData.splice(index, 1); 478 | } 479 | 480 | node.monitoredItem = null; 481 | this.emit("monitoredItemListUpdated", this.monitoredItemsListData); 482 | }); 483 | } 484 | 485 | public async installAlarmMonitoring() { 486 | if (!this.session) { 487 | return; 488 | } 489 | this.clientAlarms = await installAlarmMonitoring(this.session); 490 | this.clientAlarms.on("alarmChanged", () => { 491 | this.clientAlarms.purgeUnusedAlarms(); 492 | this.emit("alarmChanged", this.clientAlarms); 493 | }); 494 | } 495 | 496 | public async readNodeAttributes(nodeId: NodeId): Promise<{ attribute: string, text: string }[]> { 497 | if (!this.session) { 498 | return []; 499 | } 500 | const nodesToRead: ReadValueIdOptions[] = attributeKeys.map((attributeId: string) => ({ 501 | nodeId, 502 | attributeId: ((AttributeIds as any)[attributeId as any]) as AttributeIds, 503 | })); 504 | 505 | try { 506 | 507 | const dataValues = await this.session!.read(nodesToRead); 508 | const results: { attribute: string, text: string }[] = []; 509 | 510 | for (let i = 0; i < nodesToRead.length; i++) { 511 | const nodeToRead = nodesToRead[i]; 512 | const dataValue = dataValues[i]; 513 | 514 | if (dataValue.statusCode !== StatusCodes.Good) { 515 | continue; 516 | } 517 | const s = toString1(nodeToRead.attributeId, dataValue); 518 | results.push({ 519 | attribute: attributeIdToString[nodeToRead.attributeId], 520 | text: s, 521 | }); 522 | } 523 | return results; 524 | } catch (err) { 525 | console.log(err); 526 | return []; 527 | } 528 | } 529 | 530 | public async expand_opcua_node(node: any): Promise { 531 | if (!this.session) { 532 | throw new Error("No Session yet"); 533 | } 534 | if (this.session.isReconnecting) { 535 | throw new Error("Session is not available (reconnecting)"); 536 | } 537 | 538 | const children: NodeChild[] = []; 539 | 540 | const nodesToBrowse = [ 541 | { 542 | nodeId: node.nodeId, 543 | referenceTypeId: "Organizes", 544 | includeSubtypes: true, 545 | browseDirection: BrowseDirection.Forward, 546 | resultMask: 0x3f, 547 | }, 548 | { 549 | nodeId: node.nodeId, 550 | referenceTypeId: "Aggregates", 551 | includeSubtypes: true, 552 | browseDirection: BrowseDirection.Forward, 553 | resultMask: 0x3f, 554 | }, 555 | { 556 | nodeId: node.nodeId, 557 | referenceTypeId: "HasSubtype", 558 | includeSubtypes: true, 559 | browseDirection: BrowseDirection.Forward, 560 | resultMask: 0x3f, 561 | }, 562 | ]; 563 | 564 | try { 565 | const results = await browseAll(this.session, nodesToBrowse); 566 | 567 | // organized 568 | let result = results[0]; 569 | 570 | if (result.references) { 571 | for (let i = 0; i < result.references.length; i++) { 572 | const ref = result.references[i]; 573 | 574 | children.push({ 575 | arrow: referenceToSymbol(ref) + symbol(ref)[0], 576 | displayName: ref.displayName.text || ref.browseName.toString(), 577 | nodeId: ref.nodeId, 578 | nodeClass: ref.nodeClass as number, 579 | }); 580 | } 581 | } 582 | // Aggregates 583 | result = results[1]; 584 | if (result.references) { 585 | for (let i = 0; i < result.references.length; i++) { 586 | const ref = result.references[i]; 587 | children.push({ 588 | arrow: referenceToSymbol(ref) + symbol(ref)[0], 589 | displayName: ref.displayName.text || ref.browseName.toString(), 590 | nodeId: ref.nodeId, 591 | nodeClass: ref.nodeClass as number, 592 | }); 593 | } 594 | } 595 | // HasSubType 596 | result = results[2]; 597 | if (result.references) { 598 | for (let i = 0; i < result.references.length; i++) { 599 | const ref = result.references[i]; 600 | children.push({ 601 | arrow: referenceToSymbol(ref) + symbol(ref)[0], 602 | displayName: ref.displayName.text || ref.browseName.toString(), 603 | nodeId: ref.nodeId, 604 | nodeClass: ref.nodeClass as number, 605 | }); 606 | } 607 | } 608 | 609 | return children; 610 | } catch (err) { 611 | console.log(err); 612 | return []; 613 | } 614 | } 615 | } 616 | function invert(o: Record) { 617 | const r: Record = {}; 618 | for (const [k, v] of Object.entries(o)) { 619 | r[v.toString()] = k; 620 | } 621 | return r; 622 | } 623 | const attributeIdToString = invert(AttributeIds); 624 | const DataTypeIdsToString = invert(DataTypeIds); 625 | 626 | function dataValueToString(dataValue: DataValue) { 627 | if (!dataValue.value || dataValue.value.value === null) { 628 | return " : " + dataValue.statusCode.toString(); 629 | } 630 | switch (dataValue.value.arrayType) { 631 | case VariantArrayType.Scalar: 632 | return dataValue.toString(); 633 | case VariantArrayType.Array: 634 | return dataValue.toString(); 635 | default: 636 | return ""; 637 | } 638 | } 639 | 640 | function toString1(attribute: AttributeIds, dataValue: DataValue | null) { 641 | if (!dataValue || !dataValue.value || !dataValue.value.hasOwnProperty("value")) { 642 | return ""; 643 | } 644 | switch (attribute) { 645 | case AttributeIds.DataType: 646 | return DataTypeIdsToString[dataValue.value.value.value] + " (" + dataValue.value.value.toString() + ")"; 647 | case AttributeIds.NodeClass: 648 | return NodeClass[dataValue.value.value] + " (" + dataValue.value.value + ")"; 649 | case AttributeIds.IsAbstract: 650 | case AttributeIds.Historizing: 651 | case AttributeIds.EventNotifier: 652 | return dataValue.value.value ? "true" : "false"; 653 | case AttributeIds.WriteMask: 654 | case AttributeIds.UserWriteMask: 655 | return " (" + dataValue.value.value + ")"; 656 | case AttributeIds.NodeId: 657 | case AttributeIds.BrowseName: 658 | case AttributeIds.DisplayName: 659 | case AttributeIds.Description: 660 | case AttributeIds.ValueRank: 661 | case AttributeIds.ArrayDimensions: 662 | case AttributeIds.Executable: 663 | case AttributeIds.UserExecutable: 664 | case AttributeIds.MinimumSamplingInterval: 665 | if (!dataValue.value.value) { 666 | return "null"; 667 | } 668 | return dataValue.value.value.toString(); 669 | case AttributeIds.UserAccessLevel: 670 | case AttributeIds.AccessLevel: 671 | if (!dataValue.value.value) { 672 | return "null"; 673 | } 674 | return accessLevelFlagToString(dataValue.value.value) + " (" + dataValue.value.value + ")"; 675 | default: 676 | return dataValueToString(dataValue); 677 | } 678 | } 679 | -------------------------------------------------------------------------------- /lib/utils/extract_browse_path.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IBasicSessionAsync, 3 | NodeId, 4 | AttributeIds, 5 | QualifiedName, 6 | getOptionsForSymmetricSignAndEncrypt, 7 | BrowseDirection, 8 | StatusCodes, 9 | resolveNodeId, 10 | sameNodeId, 11 | makeBrowsePath, 12 | } from "node-opcua-client"; 13 | 14 | async function readBrowseName(session: IBasicSessionAsync, nodeId: NodeId): Promise { 15 | const node = await session.read({ nodeId, attributeId: AttributeIds.BrowseName }); 16 | return node.value.value; 17 | } 18 | async function getParent(session: IBasicSessionAsync, nodeId: NodeId): Promise<{ sep: string; parentNodeId: NodeId } | null> { 19 | let browseResult = await session.browse({ 20 | browseDirection: BrowseDirection.Inverse, 21 | includeSubtypes: true, 22 | nodeId, 23 | nodeClassMask: 0xff, 24 | resultMask: 0xff, 25 | referenceTypeId: "HasChild", 26 | }); 27 | if (browseResult.statusCode === StatusCodes.Good && browseResult.references?.length) { 28 | const parentNodeId = browseResult.references[0].nodeId; 29 | return { sep: ".", parentNodeId }; 30 | } 31 | browseResult = await session.browse({ 32 | browseDirection: BrowseDirection.Inverse, 33 | includeSubtypes: true, 34 | nodeId, 35 | nodeClassMask: 0xff, 36 | resultMask: 0xff, 37 | referenceTypeId: "Organizes", 38 | }); 39 | if (browseResult.statusCode === StatusCodes.Good && browseResult.references?.length) { 40 | const parentNodeId = browseResult.references[0].nodeId; 41 | return { sep: "/", parentNodeId }; 42 | } 43 | return null; 44 | } 45 | export async function extractBrowsePath(session: IBasicSessionAsync, nodeId: NodeId): Promise { 46 | try { 47 | const browseName = await readBrowseName(session, nodeId); 48 | const pathElements = []; 49 | pathElements.push(`${browseName.namespaceIndex}:${browseName.name}`); 50 | 51 | let parent = await getParent(session, nodeId); 52 | while (parent) { 53 | if (sameNodeId(parent.parentNodeId, resolveNodeId("RootFolder"))) { 54 | break; 55 | } 56 | 57 | const browseName = await readBrowseName(session, parent.parentNodeId); 58 | pathElements.unshift(`${browseName.namespaceIndex}:${browseName.name}${parent.sep}`); 59 | parent = await getParent(session, parent.parentNodeId); 60 | } 61 | const browsePath = "/" + pathElements.join(""); 62 | 63 | // verification 64 | const a = await session.translateBrowsePath(makeBrowsePath("i=84", browsePath)); 65 | return browsePath + " (" + a.targets[0]?.targetId?.toString() + ")"; 66 | } catch (err) { 67 | return "err" + (err as Error).message; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { NodeId } from "node-opcua-client"; 2 | 3 | export function w(s: string, l: number, c: string): string { 4 | c = c || " "; 5 | const filling = Array(25).join(c[0]); 6 | return (s + filling).substr(0, l); 7 | } 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /lib/view/address_space_explorer.ts: -------------------------------------------------------------------------------- 1 | import opcua from "node-opcua-client"; 2 | 3 | 4 | -------------------------------------------------------------------------------- /lib/view/alarm_box.ts: -------------------------------------------------------------------------------- 1 | import { Widgets } from "blessed"; 2 | import { ClientAlarmList, EventStuff } from "node-opcua-client"; 3 | import wrap from "wordwrap"; 4 | const truncate = require("cli-truncate"); 5 | 6 | function ellipsys(a: any) { 7 | if (!a) { 8 | return ""; 9 | } 10 | return truncate(a, 20, { position: "middle" }); 11 | } 12 | function formatMultiline(s: string, width: number): string[] { 13 | return wrap(80)(s).split("\n"); 14 | 15 | let a = ""; 16 | let r = []; 17 | for (const word of s.split(" ")) { 18 | if (a.length + word.length > width) { 19 | r.push(a); 20 | a = word; 21 | } else { 22 | a = a + " " + word; 23 | } 24 | } 25 | return r; 26 | } 27 | function n(a: any) { 28 | if (a === null || a === undefined) { 29 | return ""; 30 | } 31 | return a.toString(); 32 | } 33 | function f(flag: boolean): string { 34 | return flag ? "X" : "_"; 35 | } 36 | export async function updateAlarmBox(clientAlarms: ClientAlarmList, alarmBox: Widgets.ListTableElement, headers: any) { 37 | const data = [headers]; 38 | 39 | for (const alarm of clientAlarms.alarms()) { 40 | const fields = alarm.fields as any; 41 | const isEnabled = fields.enabledState.id.value; 42 | 43 | const sourceName = fields.sourceName.value?.toString(); 44 | 45 | const m = formatMultiline(fields.message.value.text, 80); 46 | for (let i = 0; i < m.length; i++) { 47 | const aa = m[i]; 48 | if (i === 0) { 49 | data.push([ 50 | alarm.eventType.toString(), 51 | alarm.conditionId.toString(), 52 | sourceName, 53 | // fields.branchId.value.toString(), 54 | // ellipsys(alarm.eventId.toString("hex")), 55 | isEnabled ? aa : "-", 56 | isEnabled ? fields.severity.value + " (" + fields.lastSeverity.value + ")" : "-", 57 | f(isEnabled) + 58 | (isEnabled ? f(fields.activeState.id.value) : "-") + 59 | (isEnabled ? f(fields.ackedState.id.value) : "-") + 60 | (isEnabled ? f(fields.confirmedState.id.value) : "-"), 61 | // (isEnabled ? f(fields.retain.value) : "-"), 62 | isEnabled ? ellipsys(fields.comment.value.text) : "-", 63 | ]); 64 | } else { 65 | data.push([ 66 | "", 67 | "", 68 | "", 69 | // fields.branchId.value.toString(), 70 | // ellipsys(alarm.eventId.toString("hex")), 71 | isEnabled ? aa : "-", 72 | isEnabled ? "" : "-", 73 | "", 74 | // (isEnabled ? f(fields.retain.value) : "-"), 75 | "", 76 | ]); 77 | } 78 | } 79 | } 80 | alarmBox.setRows(data); 81 | alarmBox.screen.render(); 82 | } 83 | -------------------------------------------------------------------------------- /lib/view/main_menu.ts: -------------------------------------------------------------------------------- 1 | import blessed from "blessed"; 2 | import chalk from "chalk"; 3 | 4 | -------------------------------------------------------------------------------- /lib/view/view.ts: -------------------------------------------------------------------------------- 1 | import blessed from "blessed"; 2 | import { format, callbackify } from "util"; 3 | import chalk from "chalk"; 4 | 5 | import { TreeItem } from "../widget/tree_item"; 6 | import { ClientAlarmList, NodeId, resolveNodeId, sameNodeId, VariantArrayType } from "node-opcua-client"; 7 | 8 | import { Tree } from "../widget/widget_tree"; 9 | import { Model, NodeChild } from "../model/model"; 10 | import { updateAlarmBox } from "./alarm_box"; 11 | import { w } from "../utils/utils"; 12 | 13 | const w2 = "40%"; 14 | 15 | const scrollbar = { 16 | ch: " ", 17 | track: { 18 | bg: "cyan", 19 | }, 20 | style: { 21 | inverse: true, 22 | }, 23 | }; 24 | 25 | const style = { 26 | focus: { 27 | border: { 28 | fg: "yellow", 29 | }, 30 | bold: false, 31 | }, 32 | item: { 33 | hover: { 34 | bg: "blue", 35 | }, 36 | }, 37 | selected: { 38 | bg: "blue", 39 | bold: true, 40 | }, 41 | }; 42 | 43 | let old_console_log: any; 44 | 45 | export function makeItems(arr: any[], width: number): string[] { 46 | return arr.map((a) => { 47 | return w(a[0], 25, ".") + ": " + w(a[1], width, " "); 48 | }); 49 | } 50 | 51 | let refreshTimer: NodeJS.Timeout | null = null; 52 | 53 | export class View { 54 | private monitoredItemsList: any; 55 | private $headers: string[] = []; 56 | 57 | public screen: blessed.Widgets.Screen; 58 | public area1: blessed.Widgets.BoxElement; 59 | public area2: blessed.Widgets.BoxElement; 60 | public menuBar: blessed.Widgets.ListbarElement; 61 | public alarmBox?: blessed.Widgets.ListTableElement; 62 | public attributeList: blessed.Widgets.ListElement; 63 | public attributeListNodeId?: NodeId; 64 | public logWindow: blessed.Widgets.ListElement; 65 | public tree: Tree; 66 | public writeForm: blessed.Widgets.BoxElement; 67 | public valuesToWriteElement: blessed.Widgets.TextboxElement; 68 | 69 | public model: Model; 70 | 71 | constructor(model: Model) { 72 | this.model = model; 73 | 74 | // Create a screen object. 75 | this.screen = blessed.screen({ 76 | smartCSR: true, 77 | autoPadding: false, 78 | fullUnicode: true, 79 | title: "OPCUA CLI-Client", 80 | }); 81 | // create the main area 82 | this.area1 = blessed.box({ 83 | top: 0, 84 | left: 0, 85 | width: "100%", 86 | height: "90%-10", 87 | }); 88 | this.area2 = blessed.box({ 89 | top: "90%-9", 90 | left: 0, 91 | width: "100%", 92 | height: "shrink", 93 | }); 94 | 95 | this.screen.append(this.area1); 96 | 97 | this.screen.append(this.area2); 98 | 99 | this.attributeList = this.install_attributeList(); 100 | this.install_monitoredItemsWindow(); 101 | this.install_writeFormWindow(); 102 | this.logWindow = this.install_logWindow(); 103 | this.menuBar = this.install_mainMenu(); 104 | this.tree = this.install_address_space_explorer(); 105 | // Render the screen. 106 | this.screen.render(); 107 | } 108 | 109 | install_writeFormWindow() { 110 | this.writeForm = blessed.box({ 111 | parent: this.area1, 112 | tags: true, 113 | top: "50%", 114 | left: w2 + "+1", 115 | width: "60%-1", 116 | height: "50%", 117 | keys: true, 118 | mouse: true, 119 | label: " Write item ", 120 | border: "line", 121 | scrollbar: scrollbar, 122 | noCellBorders: true, 123 | style: { ...style }, 124 | align: "left", 125 | hidden: true, 126 | }); 127 | 128 | { 129 | const form = blessed.form({ 130 | parent: this.writeForm, 131 | width: "100%-2", 132 | height: "100%-2", 133 | top: 1, 134 | left: 1, 135 | keys: true, 136 | }); 137 | 138 | blessed.text({ 139 | parent: form, 140 | top: 0, 141 | left: 0, 142 | content: "VALUES (Comma separated for array):", 143 | }); 144 | 145 | this.valuesToWriteElement = blessed.textbox({ 146 | parent: form, 147 | name: "valuesToWrite", 148 | top: 1, 149 | left: 0, 150 | height: "100%-2", 151 | inputOnFocus: true, 152 | mouse: false, 153 | vi: false, 154 | keys: false, 155 | content: "", 156 | border: { 157 | type: "line", 158 | }, 159 | focus: { 160 | fg: "blue", 161 | }, 162 | }); 163 | 164 | const padding = { 165 | top: 0, 166 | right: 2, 167 | bottom: 0, 168 | left: 2, 169 | }; 170 | const buttonTop = "100%-1"; 171 | var submit = blessed.button({ 172 | parent: form, 173 | name: "submit", 174 | content: "Submit", 175 | top: buttonTop, 176 | left: 0, 177 | shrink: true, 178 | mouse: true, 179 | padding, 180 | style: { 181 | bold: true, 182 | fg: "white", 183 | bg: "green", 184 | focus: { 185 | inverse: true, 186 | }, 187 | }, 188 | }); 189 | submit.on("press", function () { 190 | form.submit(); 191 | }); 192 | 193 | var closeForm = blessed.button({ 194 | parent: form, 195 | name: "close", 196 | content: "close", 197 | top: buttonTop, 198 | right: 0, 199 | shrink: true, 200 | mouse: true, 201 | padding, 202 | style: { 203 | bold: true, 204 | fg: "white", 205 | bg: "red", 206 | focus: { 207 | inverse: true, 208 | }, 209 | }, 210 | }); 211 | closeForm.on("press", () => { 212 | this.writeForm.hide(); 213 | this.screen.render(); 214 | }); 215 | 216 | const writeResultMsg = blessed.text({ 217 | parent: form, 218 | top: submit.top, 219 | left: "center", 220 | content: "", 221 | }); 222 | 223 | form.on("submit", async (data: any) => { 224 | const treeItem = this.tree.getSelectedItem(); 225 | if (treeItem.node) { 226 | // check if it is an array 227 | const dataValues = await this.model.readNode(treeItem.node); 228 | let valuesToWrite = data.valuesToWrite; 229 | 230 | if (dataValues && dataValues.value) { 231 | if (dataValues.value.arrayType == VariantArrayType.Array) { 232 | // since it is an array I will split by comma 233 | valuesToWrite = valuesToWrite.split(","); 234 | } 235 | } 236 | 237 | // send data to opc 238 | const res = await this.model.writeNode(treeItem.node, valuesToWrite); 239 | if (res.valueOf() == 0) { 240 | writeResultMsg.setContent("Write successful"); 241 | } else { 242 | writeResultMsg.setContent("Write error"); 243 | } 244 | this.screen.render(); 245 | } 246 | }); 247 | } 248 | 249 | this.area1.append(this.writeForm); 250 | } 251 | 252 | install_monitoredItemsWindow() { 253 | this.monitoredItemsList = blessed.listtable({ 254 | parent: this.area1, 255 | tags: true, 256 | top: "50%", 257 | left: w2 + "+1", 258 | width: "60%-1", 259 | height: "50%", 260 | keys: true, 261 | label: " Monitored Items ", 262 | border: "line", 263 | scrollbar: scrollbar, 264 | noCellBorders: true, 265 | style: { ...style }, 266 | align: "left", 267 | }); 268 | this.area1.append(this.monitoredItemsList); 269 | 270 | // binding ..... 271 | 272 | this.model.on("monitoredItemListUpdated", (monitoredItemsListData: any) => { 273 | if (monitoredItemsListData.length > 0) { 274 | this.monitoredItemsList.setRows(monitoredItemsListData); 275 | } else { 276 | // when using setRows with empty array, the view does not update. 277 | // setting an empty row. 278 | const empty = [[" "]]; 279 | this.monitoredItemsList.setRows(empty); 280 | } 281 | this.monitoredItemsList.render(); 282 | }); 283 | 284 | this.model.on("monitoredItemChanged", this._onMonitoredItemChanged.bind(this)); 285 | 286 | this.model.on("nodeChanged", this._onNodeChanged.bind(this)); 287 | } 288 | private _onMonitoredItemChanged(monitoredItemsListData: any /*node: any, dataValue: DataValue*/) { 289 | this.monitoredItemsList.setRows(monitoredItemsListData); 290 | this.monitoredItemsList.render(); 291 | } 292 | 293 | private install_logWindow() { 294 | const logWindow = blessed.list({ 295 | parent: this.area2, 296 | tags: true, 297 | label: " {bold}{cyan-fg}Info{/cyan-fg}{/bold} ", 298 | top: "top", 299 | left: "left", 300 | width: "100%", 301 | height: "100%-2", 302 | keys: true, 303 | border: "line", 304 | scrollable: true, 305 | scrollbar: { 306 | ch: " ", 307 | track: { 308 | bg: "cyan", 309 | }, 310 | style: { 311 | inverse: true, 312 | }, 313 | }, 314 | style: { ...style }, 315 | }); 316 | 317 | old_console_log = console.log; 318 | 319 | console.log = function (...args: [any]) { 320 | const str = format.apply(null, args); 321 | const lines = str.split("\n"); 322 | lines.forEach((str: string) => { 323 | logWindow.addItem(str); 324 | }); 325 | logWindow.select((logWindow as any).items.length - 1); 326 | }; 327 | this.area2.append(logWindow); 328 | return logWindow; 329 | } 330 | 331 | public install_mainMenu(): blessed.Widgets.ListbarElement { 332 | const menuBarOptions: blessed.Widgets.ListbarOptions = { 333 | parent: this.area2, 334 | top: "100%-2", 335 | left: "left", 336 | width: "100%", 337 | height: 2, 338 | keys: true, 339 | style: { 340 | ...style, 341 | prefix: { 342 | fg: "cyan", 343 | }, 344 | } as any, 345 | //xx label: " {bold}{cyan-fg}Info{/cyan-fg}{/bold}", 346 | //xx border: "line", 347 | bg: "cyan", 348 | commands: [], 349 | items: [], 350 | autoCommandKeys: true, 351 | }; 352 | const menuBar = blessed.listbar(menuBarOptions); 353 | this.area2.append(menuBar); 354 | 355 | (menuBar as any).setItems({ 356 | Monitor: { 357 | //xx prefix: "M", 358 | keys: ["m"], 359 | callback: () => this._onMonitoredSelectedItem(), 360 | }, 361 | Write: { 362 | keys: ["w"], 363 | callback: () => this._onWriteSelectedItem(), 364 | }, 365 | Exit: { 366 | keys: ["q", "x"], //["C-c", "escape"], 367 | callback: () => this._onExit(), 368 | }, 369 | Tree: { 370 | keys: ["t"], 371 | callback: () => this.tree.focus(), 372 | }, 373 | Attributes: { 374 | keys: ["l"], 375 | callback: () => this.attributeList.focus(), 376 | }, 377 | Info: { 378 | keys: ["i"], 379 | callback: () => this.logWindow.focus(), 380 | }, 381 | Clear: { 382 | keys: ["c"], 383 | callback: () => { 384 | this.logWindow.clearItems(); 385 | this.logWindow.screen.render(); 386 | }, 387 | }, 388 | Unmonitor: { 389 | keys: ["u"], 390 | callback: () => this._onUnmonitoredSelectedItem(), 391 | }, 392 | Stat: { 393 | keys: ["s"], 394 | callback: () => this._onDumpStatistics(), 395 | }, 396 | Alarm: { 397 | keys: ["a"], 398 | callback: this._onToggleAlarmWindows.bind(this), 399 | }, 400 | // "Menu": { keys: ["A-a", "x"], callback: () => this.menuBar.focus() } 401 | }); 402 | return menuBar; 403 | } 404 | 405 | private install_address_space_explorer(): Tree { 406 | this.tree = new Tree({ 407 | parent: this.area1, 408 | tags: true, 409 | fg: "green", 410 | //Xx keys: true, 411 | label: " {bold}{cyan-fg}Address Space{/cyan-fg}{/bold} ", 412 | top: "top", 413 | left: "left", 414 | width: "40%", 415 | height: "100%", 416 | keys: true, 417 | vi: true, 418 | mouse: true, 419 | border: "line", 420 | style: { ...style }, 421 | }); 422 | 423 | //allow control the table with the keyboard 424 | this.tree.on("select", (treeItem: any) => { 425 | if (treeItem) { 426 | this.fill_attributesRegion(treeItem.node.nodeId); 427 | } 428 | }); 429 | this.tree.on("keypress", (ch: any, key: any) => { 430 | if (key.name === "up" || key.name === "down") { 431 | if (refreshTimer) { 432 | return; 433 | } 434 | refreshTimer = setTimeout(() => { 435 | const treeItem = this.tree.getSelectedItem(); 436 | if (treeItem && treeItem.node) { 437 | this.fill_attributesRegion(treeItem.node.nodeId); 438 | } 439 | refreshTimer = null; 440 | }, 100); 441 | } 442 | }); 443 | 444 | this.area1.append(this.tree); 445 | 446 | this.populateTree(); 447 | this.tree.focus(); 448 | return this.tree; 449 | } 450 | 451 | private populateTree() { 452 | this.tree.setData({ 453 | name: "RootFolder", 454 | nodeId: resolveNodeId("RootFolder"), 455 | children: this.expand_opcua_node.bind(this), 456 | }); 457 | } 458 | 459 | private expand_opcua_node(node: any, callback: () => void) { 460 | async function f(this: any, node: any) { 461 | try { 462 | let children = await this.model.expand_opcua_node(node); 463 | 464 | // we sort the childrens by displayName alphabetically 465 | children = children.sort((a: NodeChild, b: NodeChild) => { 466 | return a.displayName < b.displayName ? -1 : a.displayName > b.displayName ? 1 : 0; 467 | }); 468 | 469 | const results = children.map((c: any) => new TreeItem({ ...c, children: this.expand_opcua_node.bind(this) })); 470 | return results; 471 | } catch (err) { 472 | throw new Error("cannot expand"); 473 | } 474 | } 475 | callbackify(f).call(this, node, callback); 476 | } 477 | 478 | private _onNodeChanged(nodeId: NodeId) { 479 | if (sameNodeId(this.attributeListNodeId, nodeId)) { 480 | // we need to refresh the attribute list 481 | this.fill_attributesRegion(nodeId); 482 | } 483 | } 484 | 485 | private async fill_attributesRegion(nodeId: NodeId) { 486 | type ATT = [string, string]; 487 | const attr: ATT[] = []; 488 | 489 | function append_text(prefix: string, s: string, attr: ATT[]) { 490 | const a = s.split("\n"); 491 | if (a.length === 1) { 492 | attr.push([prefix, s]); 493 | } else { 494 | attr.push([prefix, a[0]]); 495 | for (let j = 1; j < a.length; j++) { 496 | attr.push([" | ", a[j]]); 497 | } 498 | } 499 | } 500 | 501 | const attributes = await this.model.readNodeAttributes(nodeId); 502 | if (attributes.length === 0) { 503 | return; 504 | } 505 | for (const r of attributes) { 506 | append_text(r.attribute, r.text, attr); 507 | } 508 | const width = (this.attributeList as any).width - 28; 509 | this.attributeList.setItems(makeItems(attr, width) as any); 510 | this.attributeList.screen.render(); 511 | this.attributeListNodeId = nodeId; 512 | } 513 | 514 | private install_attributeList(): blessed.Widgets.ListElement { 515 | this.attributeList = blessed.list({ 516 | parent: this.area1, 517 | label: " {bold}{cyan-fg}Attribute List{/cyan-fg}{/bold} ", 518 | top: 0, 519 | tags: true, 520 | left: w2 + "+1", 521 | width: "60%-1", 522 | height: "50%", 523 | border: "line", 524 | // noCellBorders: true, 525 | scrollbar: scrollbar, 526 | style: { ...style }, 527 | align: "left", 528 | keys: true, 529 | }); 530 | this.area1.append(this.attributeList); 531 | 532 | const width = (this.attributeList as any).width - 28; 533 | this.attributeList.setItems(makeItems([], width) as any); 534 | return this.attributeList; 535 | } 536 | 537 | private install_alarm_windows() { 538 | if (this.alarmBox) { 539 | this.alarmBox.show(); 540 | this.alarmBox.focus(); 541 | return; 542 | } 543 | 544 | this.alarmBox = blessed.listtable({ 545 | parent: this.area1, 546 | tags: true, 547 | fg: "green", 548 | // label: "{bold}{cyan-fg}Alarms - Conditions {/cyan-fg}{/bold} ", 549 | label: "Alarms - Conditions", 550 | top: "top+6", 551 | left: "left+2", 552 | width: "100%-10", 553 | height: "100%-10", 554 | keys: true, 555 | border: "line", 556 | scrollbar: scrollbar, 557 | noCellBorders: false, 558 | style: { ...style }!, 559 | align : "left" 560 | }); 561 | 562 | this.$headers = [ 563 | "EventType", 564 | "ConditionId", 565 | "SourceName", 566 | // "BranchId", 567 | // "EventId", 568 | "Message", 569 | "Severity", 570 | //"Enabled?", "Active?", "Acked?", "Confirmed?", "Retain", 571 | "E!AC", 572 | "Comment", 573 | ]; 574 | 575 | const data = [this.$headers]; 576 | 577 | this.alarmBox.setData(data); 578 | 579 | this.model.installAlarmMonitoring(); 580 | this.model.on("alarmChanged", (list: ClientAlarmList) => updateAlarmBox(list, this.alarmBox, this.$headers)); 581 | this.alarmBox.focus(); 582 | } 583 | 584 | private hide_alarm_windows() { 585 | this.alarmBox!.hide(); 586 | } 587 | 588 | private async _onExit() { 589 | console.log(chalk.red(" disconnecting .... ")); 590 | await this.model.disconnect(); 591 | console.log(chalk.green(" disconnected .... ")); 592 | await new Promise((resolve) => setTimeout(resolve, 1000)); 593 | 594 | process.exit(0); 595 | } 596 | 597 | private async _onToggleAlarmWindows() { 598 | if (this.alarmBox && this.alarmBox.visible) { 599 | this.hide_alarm_windows(); 600 | } else { 601 | this.install_alarm_windows(); 602 | this.alarmBox!.show(); 603 | } 604 | this.screen.render(); 605 | } 606 | 607 | private _onMonitoredSelectedItem() { 608 | const treeItem = this.tree.getSelectedItem(); 609 | if (treeItem.node.monitoredItem) { 610 | console.log(" Already monitoring ", treeItem.node.nodeId.toString()); 611 | return; 612 | } 613 | this.model.monitor_item(treeItem); 614 | } 615 | private async _onWriteSelectedItem() { 616 | this.writeForm.show(); 617 | const treeItem = this.tree.getSelectedItem(); 618 | if (treeItem.node) { 619 | const treeItemToUse = this.model.request_write_item(treeItem); 620 | if (treeItemToUse) { 621 | const value = await this.model.readNodeValue(treeItem.node); 622 | if (value) { 623 | this.valuesToWriteElement.setValue(value); 624 | } else { 625 | this.valuesToWriteElement.setValue(""); 626 | } 627 | this.screen.render(); 628 | this.valuesToWriteElement.focus(); 629 | this.screen.render(); 630 | } 631 | return; 632 | } 633 | } 634 | 635 | private _onUnmonitoredSelectedItem() { 636 | const treeItem = this.tree.getSelectedItem(); 637 | if (!treeItem.node.monitoredItem) { 638 | console.log(treeItem.node.nodeId.toString(), " was not being monitored"); 639 | return; 640 | } 641 | this.model.unmonitor_item(treeItem); 642 | } 643 | 644 | private async _onDumpStatistics() { 645 | console.log("-----------------------------------------------------------------------------------------"); 646 | console.log(chalk.green(" transaction count : ", chalk.yellow(this.model.data.transactionCount))); 647 | console.log(chalk.green(" sent bytes : ", chalk.yellow(this.model.data.sentBytes))); 648 | console.log(chalk.green(" received bytes : ", chalk.yellow(this.model.data.receivedBytes))); 649 | console.log(chalk.green(" token renewal count : ", chalk.yellow(this.model.data.tokenRenewalCount))); 650 | console.log(chalk.green(" reconnection count : ", chalk.yellow(this.model.data.reconnectionCount))); 651 | console.log("-----------------------------------------------------------------------------------------"); 652 | const treeItem = this.tree.getSelectedItem(); 653 | const browsePath = await this.model.extractBrowsePath(treeItem.node.nodeId); 654 | console.log(chalk.cyan("selected node browse path :", chalk.magenta(browsePath))); 655 | } 656 | } 657 | -------------------------------------------------------------------------------- /lib/widget/tree_item.ts: -------------------------------------------------------------------------------- 1 | import { NodeClass } from "node-opcua-client"; 2 | 3 | /** 4 | * @param options.class 5 | * @param options.nodeId 6 | * @param options.arrow 7 | * @constructor 8 | */ 9 | export class TreeItem { 10 | 11 | public node: any | undefined = undefined; 12 | private arrow: string = ""; 13 | private displayName: string = ""; 14 | private class: NodeClass = 1; 15 | private valueAsString: string = ""; 16 | 17 | constructor(options: any) { 18 | const self = this as any; 19 | Object.keys(options).forEach(function (k) { 20 | self[k] = options[k]; 21 | }); 22 | } 23 | 24 | 25 | get name(): string { 26 | let str = this.arrow + " " + this.displayName; 27 | if (this.class === NodeClass.Variable) { 28 | str += " = " + this.valueAsString; 29 | } 30 | return str; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/widget/widget_tree.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { assert } from "node-opcua-client" 3 | import { Widgets } from "blessed"; 4 | import { TreeItem } from "./tree_item"; 5 | const blessed = require("blessed"); 6 | 7 | // some unicode icon characters ►▼◊◌○●□▪▫֎☺◘♦ 8 | 9 | function isFunction(variableToCheck: any) { 10 | return variableToCheck instanceof Function; 11 | } 12 | 13 | function toContent(node: any, isLastChild: boolean, parent: any): any { 14 | 15 | if (parent) { 16 | const sep = (parent.isLastChild) ? " " : "│"; 17 | node.prefix = parent.prefix + sep; 18 | } else { 19 | node.prefix = " "; 20 | } 21 | 22 | const s = (isLastChild) ? "└" : "├"; 23 | 24 | const level = node.depth; 25 | assert(level >= 0 && level < 100); 26 | 27 | const hasChildren = node.children && node.children.length > 0; 28 | // [+] 29 | const c = node.expanded ? (hasChildren ? chalk.green("▼") : "─") : "►"; 30 | const str = node.prefix + s + c + node.name; 31 | 32 | return str; 33 | } 34 | function dummy(node: any, callback: (err: Error | null, child: any) => void) { 35 | callback(null, node.children); 36 | } 37 | export interface Tree extends Widgets.ListElement { 38 | 39 | } 40 | export class Tree extends blessed.List { 41 | private items: TreeItem[] = []; 42 | private __data: any; 43 | private _index_selectedNode: number; 44 | private _old_selectedNode: any; 45 | 46 | constructor(options: any) { 47 | 48 | const scrollbar = { 49 | ch: " ", 50 | track: { 51 | bg: "cyan" 52 | }, 53 | style: { 54 | inverse: true 55 | } 56 | }; 57 | 58 | const style = { 59 | item: { 60 | hover: { 61 | bg: "blue" 62 | } 63 | }, 64 | selected: { 65 | bg: "blue", 66 | bold: true 67 | } 68 | }; 69 | 70 | options.border = options.border || "line"; 71 | options.scrollbar = options.scrollbar || scrollbar; 72 | options.style = options.style || style; 73 | options.keys = true; 74 | 75 | super(options); 76 | 77 | this.key(["+", "right"], this.expandSelected.bind(this)); 78 | this.key(["-", "left"], this.collapseSelected.bind(this)); 79 | 80 | this._index_selectedNode = 0; 81 | } 82 | 83 | 84 | _add(node: any, isLastChild: boolean, parent: any) { 85 | node.isLastChild = isLastChild; 86 | const item = this.add(toContent(node, isLastChild, parent)) as any; 87 | item.node = node; 88 | if (this._old_selectedNode === node) { 89 | this._index_selectedNode = this.itemCount - 1; 90 | } 91 | } 92 | 93 | get itemCount() { return (this as any).items.length; } 94 | 95 | walk(node: any, depth: number) { 96 | 97 | if (this.itemCount) { 98 | this._old_selectedNode = this.getSelectedItem().node; 99 | assert(this._old_selectedNode); 100 | } 101 | this._index_selectedNode = -1; 102 | this.setItems([]); 103 | 104 | if (node.name && depth === 0) { 105 | // root node 106 | node.depth = 0; 107 | this._add(node, true, null); 108 | } 109 | 110 | function dumpChildren(this: Tree, node: any, depth: number): void { 111 | 112 | if (isFunction(node.children)) { 113 | return; 114 | } 115 | node.children = node.children || []; 116 | let isLastChild; 117 | 118 | for (let i = 0; i < node.children.length; i++) { 119 | 120 | const child = node.children[i]; 121 | if (child) { 122 | child.depth = depth + 1; 123 | 124 | isLastChild = (i === node.children.length - 1); 125 | this._add(child, isLastChild, node); 126 | if (child.expanded && !isFunction(child.children)) { 127 | dumpChildren.call(this, child, depth + 1); 128 | } 129 | 130 | } 131 | } 132 | } 133 | 134 | if (node.expanded) { 135 | dumpChildren.call(this, node, depth); 136 | } 137 | this._index_selectedNode = this._index_selectedNode >= 0 ? this._index_selectedNode : 0; 138 | this.select(this._index_selectedNode); 139 | } 140 | 141 | 142 | expandSelected() { 143 | const node = this.getSelectedItem().node; 144 | if (node.expanded) { 145 | return; 146 | } 147 | 148 | const populate_children = isFunction(node.children) ? node.children : dummy; 149 | populate_children.call(this, node, (err: Error | null, children: any) => { 150 | if (err) { 151 | return; 152 | } 153 | assert(Array.isArray(children)); 154 | node.children = children; 155 | node.expanded = true; 156 | this.setData(this.__data); 157 | }); 158 | } 159 | 160 | collapseSelected() { 161 | const node = this.getSelectedItem().node; 162 | if (!node.expanded) { 163 | return; 164 | } 165 | node.expanded = false; 166 | this.setData(this.__data); 167 | } 168 | 169 | setData(data: any) { 170 | this.__data = data; 171 | this.walk(data, 0); 172 | this.screen.render(); 173 | } 174 | getSelectedItem(): TreeItem { 175 | return this.getTreeItemAtPos(this.getSelectedIndex()); 176 | } 177 | private getTreeItemAtPos(selectedIndex: number): TreeItem{ 178 | return this.items[selectedIndex]; 179 | } 180 | private getSelectedIndex(): number { 181 | return (this as any).selected; 182 | } 183 | } 184 | 185 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opcua-commander", 3 | "version": "0.40.0", 4 | "description": "OPCUA CLI client", 5 | "main": "bin/bundle.js", 6 | "keywords": [ 7 | "opcua", 8 | "iot", 9 | "iiot", 10 | "cli", 11 | "curses", 12 | "blessed" 13 | ], 14 | "scripts": { 15 | "build": "tsc -b && npm run bundle", 16 | "bundle": "npx -y esbuild --bundle --outfile=bin/bundle.js --external:blessed --external:chalk --external:wrap-ansi --minify-syntax --sourcemap --platform=node lib/index.ts", 17 | "release": "npm run build && npm run bundle && npx release-it", 18 | "test": "echo \"Error: no test specified\" && exit 1", 19 | "ncu": "npx npm-check-updates -u -x chalk,yargs,env-paths,update-notifier,camel-case,cli-truncate", 20 | "start": "node bin/opcua-commander", 21 | "demo:secure": "node bin/opcua-commander -e opc.tcp://opcuademo.sterfive.com:26543 -s=SignAndEncrypt -P=Aes128_Sha256_RsaOaep -u=user1 -p=password1", 22 | "demo:secure2": "node bin/opcua-commander -e opc.tcp://opcuademo.sterfive.com:26543 -s=SignAndEncrypt -P=Basic256Sha256 -u=user1 -p=password1", 23 | "demo": "node bin/opcua-commander -e opc.tcp://opcuademo.sterfive.com:26543 -s=None -u=user1 -p=password1", 24 | "snap": "bash ./buildsnap.sh" 25 | }, 26 | "author": "Etienne Rossignon", 27 | "license": "MIT", 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/node-opcua/opcua-commander.git" 31 | }, 32 | "bin": { 33 | "opcua-commander": "bin/opcua-commander" 34 | }, 35 | "engines": { 36 | "node": ">= 8.0" 37 | }, 38 | "bugs": { 39 | "url": "https://github.com/node-opcua/opcua-commander/issues" 40 | }, 41 | "dependencies": { 42 | "node-opcua-certificate-manager": "^2.134.0", 43 | "node-opcua-client": "^2.138.1", 44 | "node-opcua-pki": "^4.16.0", 45 | "blessed": "^0.1.81", 46 | "cli-truncate": "2.1.0", 47 | "check-node-version": "4.2.1", 48 | "chalk": "4.1.2", 49 | "wordwrap": "^1.0.0", 50 | "yargs": "17.5.1" 51 | }, 52 | "devDependencies": { 53 | "@types/blessed": "^0.1.25", 54 | "@types/wordwrap": "^1.0.3", 55 | "typescript": "^5.6.3", 56 | "es-abstract": "^1.23.5", 57 | "source-map-support": "^0.5.21" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | # CLI OPCUA Client with NodeOPCUA 3 | 4 | 5 | 6 | ![alt text]( 7 | https://raw.githubusercontent.com/node-opcua/opcua-commander/master/docs/demo.gif "...") 8 | 9 | [![Get it from the Snap Store](https://snapcraft.io/static/images/badges/en/snap-store-black.svg)](https://snapcraft.io/opcua-commander) 10 | 11 | 12 | ### install from npm 13 | 14 | $ npm install opcua-commander -g 15 | $ opcua-commander -e opc.tcp://localhost:26543 16 | 17 | 18 | ### install from source 19 | 20 | 21 | $ git clone https://github.com/node-opcua/opcua-commander.git 22 | $ cd opcua-commander 23 | $ npm install 24 | $ npm install -g typescript 25 | $ npm run build 26 | $ node dist/index.js -e opc.tcp://localhost:26543 27 | 28 | 29 | ### install on ubuntu 30 | 31 | if you have EACCES error on linux, 32 | 33 | $ npm install -g opcua-commander --unsafe-perm=true --allow-root 34 | $ sudo npm install -g opcua-commander --unsafe-perm=true --allow-root 35 | 36 | 37 | ### run with docker 38 | 39 | build your docker image 40 | 41 | $ docker build . -t commander 42 | 43 | Run the docker image 44 | 45 | $ docker run -it commander -e opc.tcp://localhost:26543 46 | 47 | -------------------------------------------------------------------------------- /snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: opcua-commander 2 | base: core22 3 | version: "0.39.0" 4 | summary: Curse based OPCUA Client for the command line 5 | description: | 6 | opcua-commander is a OPCUA Client application that runs in the terminal. 7 | It's based on ncurse and NodeOPCUA. 8 | author: Etienne Rossignon - Sterfive SAS 9 | 10 | grade: stable # must be 'stable' to release into candidate/stable channels 11 | confinement: strict # use 'strict' once you have the right plugs and slots 12 | parts: 13 | opcua-commander: 14 | # See 'snapcraft plugins' 15 | plugin: npm 16 | npm-include-node: true 17 | npm-node-version: "18.16.1" 18 | # plugin: nodejs 19 | # nodejs-package-manager: npm 20 | # nodejs-version: "18.9.1" 21 | source: . 22 | stage-packages: 23 | - openssl 24 | 25 | apps: 26 | opcua-commander: 27 | command: bin/opcua-commander 28 | plugs: 29 | - network 30 | environment: 31 | OPENSSL_CONF: /dev/null 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "skipLibCheck": true, 4 | "outDir": "dist", 5 | "target": "es6", 6 | "esModuleInterop": true, 7 | "noImplicitAny": true, 8 | "module": "commonjs", 9 | "lib": [ 10 | "es6" 11 | ], 12 | "moduleResolution": "node", 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "*": [ 17 | "node_modules/*", 18 | ] 19 | }, 20 | }, 21 | "files": ["lib/index.ts"] 22 | , "include": ["lib/**/*.ts"] 23 | } 24 | --------------------------------------------------------------------------------