diff --git a/.eslintrc.json b/.eslintrc.json index eccfe20..f6ffd75 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,7 +4,7 @@ "es6": true }, "parserOptions": { - "ecmaVersion": 2017 + "ecmaVersion": 2020 }, "extends": "eslint:recommended", "rules": { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6a2d41..cd5b72e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: ci -on: +on: push: branches: - master @@ -12,7 +12,7 @@ jobs: strategy: matrix: - node-version: [14.x, 18.x, 20.x] + node-version: [18.x, 20.x] steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index fe3da05..192ac86 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ lib-cov # Coverage directory used by tools like istanbul coverage +.nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt @@ -33,6 +34,3 @@ modbus-serial docs package-lock\.json - -# Visual Studio Code -.vscode/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..e927274 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + // Use IntelliSense to learn about possible Node.js debug attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Current File", + "type": "node", + "request": "launch", + "program": "${file}" + }, + { + "type": "node", + "request": "launch", + "name": "Mocha single test", + "program": "${workspaceFolder}/node_modules/mocha/bin/mocha.js", + // current file + "args": ["${file}"], + "console": "internalConsole", + "cwd": "${workspaceRoot}", + "runtimeVersion": "16", + } + ] + } + \ No newline at end of file diff --git a/ModbusRTU.d.ts b/ModbusRTU.d.ts index b14a087..d2c7616 100644 --- a/ModbusRTU.d.ts +++ b/ModbusRTU.d.ts @@ -1,4 +1,4 @@ -import { Socket } from 'net'; +import { Socket, SocketConstructorOpts, TcpSocketConnectOpts } from 'net'; import { TestPort } from "./TestPort"; import { PortInfo } from '@serialport/bindings-cpp'; @@ -133,12 +133,14 @@ export interface SerialPortUnixPlatformOptions { vtime?: number; } -export interface TcpPortOptions { +export interface TcpPortOptions extends TcpSocketConnectOpts { port?: number; localAddress?: string; family?: number; ip?: string; timeout?: number; + socket: Socket; + socketOpts: SocketConstructorOpts } export interface UdpPortOptions { diff --git a/examples/tcp_client_abort_signal.js b/examples/tcp_client_abort_signal.js new file mode 100644 index 0000000..9979522 --- /dev/null +++ b/examples/tcp_client_abort_signal.js @@ -0,0 +1,58 @@ +"use strict"; + +// create an empty modbus client +// let ModbusRTU = require("modbus-serial"); +const ModbusRTU = require("../index"); +const client = new ModbusRTU(); + +const abortController = new AbortController(); +const { signal } = abortController; +signal.addEventListener("abort", () => { + console.log("Abort signal received by the abort controller"); +}); + +async function connect() { + await client.connectTCP("127.0.0.1", { + port: 8502, + socketOpts: { + signal: signal + } + }); + client.setID(1); + client.setTimeout(2000); +} + +async function readRegisters() { + const data = await client.readHoldingRegisters(5, 4); + console.log("Received:", data.data); +} + +async function runner() { + await connect(); + + setTimeout(() => { + abortController.abort("Aborting request"); + }, 1000); + + await readRegisters(); +} + +runner() + .then(() => { + if (signal.aborted) { + if (signal.reason) { + console.log(`Request aborted with reason: ${signal.reason}`); + } else { + console.log("Request aborted but no reason was given."); + } + } else { + console.log("Request not aborted"); + } + }) + .catch((error) => { + console.error(error); + }) + .finally(async() => { + console.log("Close client"); + await client.close(); + }); diff --git a/package.json b/package.json index 6cc1cec..665ebec 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "A pure JavaScript implemetation of MODBUS-RTU (Serial and TCP) for NodeJS.", "main": "index.js", "scripts": { - "test": "mocha --recursive" + "test": "nyc --reporter=lcov --reporter=text mocha --recursive" }, "repository": { "type": "git", @@ -34,6 +34,7 @@ "mocha": "^10.2.0", "mocha-eslint": "^7.0.0", "mockery": "^2.1.0", + "nyc": "^15.1.0", "pump": "^3.0.0", "sinon": "^15.2.0", "web-bluetooth-mock": "^1.2.0", diff --git a/ports/tcpport.js b/ports/tcpport.js index 90a565f..5e4133b 100644 --- a/ports/tcpport.js +++ b/ports/tcpport.js @@ -17,8 +17,21 @@ class TcpPort extends EventEmitter { /** * Simulate a modbus-RTU port using modbus-TCP connection. * - * @param ip - * @param options + * @param {string} ip - IP address of Modbus slave. + * @param {{ + * port?: number, + * localAddress?: string, + * family?: 0|4|6, + * timeout?: number, + * socket?: net.Socket + * socketOpts?: { + * fd: number, + * allowHalfOpen?: boolean, + * readable?: boolean, + * writable?: boolean, + * signal?: AbortSignal + * }, + * } & net.TcpSocketConnectOpts} options - Options object. * options.port: Nonstandard Modbus port (default is 502). * options.localAddress: Local IP address to bind to, default is any. * options.family: 4 = IPv4-only, 6 = IPv6-only, 0 = either (default). @@ -26,23 +39,37 @@ class TcpPort extends EventEmitter { */ constructor(ip, options) { super(); - const modbus = this; + const self = this; + /** @type {boolean} Flag to indicate if port is open */ this.openFlag = false; + /** @type {(err?: Error) => void} */ this.callback = null; this._transactionIdWrite = 1; + /** @type {net.Socket?} - Optional custom socket */ this._externalSocket = null; if(typeof ip === "object") { options = ip; + ip = undefined; } - if (typeof(options) === "undefined") options = {}; + if (typeof options === "undefined") options = {}; - this.connectOptions = { - host: ip || options.ip, - port: options.port || MODBUS_PORT, - localAddress: options.localAddress, - family: options.family + this.socketOpts = undefined; + if (options.socketOpts) { + this.socketOpts = options.socketOpts; + delete options.socketOpts; + } + + /** @type {net.TcpSocketConnectOpts} - Options for net.connect(). */ + this.connectOptions = { + // Default options + ...{ + host: ip || options.ip, + port: MODBUS_PORT + }, + // User options + ...options }; if(options.socket) { @@ -55,18 +82,20 @@ class TcpPort extends EventEmitter { } // handle callback - call a callback function only once, for the first event - // it will triger + // it will trigger const handleCallback = function(had_error) { - if (modbus.callback) { - modbus.callback(had_error); - modbus.callback = null; + if (self.callback) { + self.callback(had_error); + self.callback = null; } }; // init a socket - this._client = this._externalSocket || new net.Socket(); + this._client = this._externalSocket || new net.Socket(this.socketOpts); if (options.timeout) this._client.setTimeout(options.timeout); + + // register events handlers this._client.on("data", function(data) { let buffer; let crc; @@ -89,11 +118,11 @@ class TcpPort extends EventEmitter { buffer.writeUInt16LE(crc, buffer.length - CRC_LENGTH); // update transaction id and emit data - modbus._transactionIdRead = data.readUInt16BE(0); - modbus.emit("data", buffer); + self._transactionIdRead = data.readUInt16BE(0); + self.emit("data", buffer); // debug - modbusSerialDebug({ action: "parsed tcp port", buffer: buffer, transactionId: modbus._transactionIdRead }); + modbusSerialDebug({ action: "parsed tcp port", buffer: buffer, transactionId: self._transactionIdRead }); // reset data data = data.slice(length + MIN_MBAP_LENGTH); @@ -101,22 +130,22 @@ class TcpPort extends EventEmitter { }); this._client.on("connect", function() { - modbus.openFlag = true; + self.openFlag = true; modbusSerialDebug("TCP port: signal connect"); handleCallback(); }); this._client.on("close", function(had_error) { - modbus.openFlag = false; + self.openFlag = false; modbusSerialDebug("TCP port: signal close: " + had_error); handleCallback(had_error); - modbus.emit("close"); - modbus.removeAllListeners(); + self.emit("close"); + self.removeAllListeners(); }); this._client.on("error", function(had_error) { - modbus.openFlag = false; + self.openFlag = false; modbusSerialDebug("TCP port: signal error: " + had_error); handleCallback(had_error); }); @@ -142,7 +171,7 @@ class TcpPort extends EventEmitter { /** * Simulate successful port open. * - * @param callback + * @param {(err?: Error) => void} callback */ open(callback) { if(this._externalSocket === null) { @@ -159,7 +188,7 @@ class TcpPort extends EventEmitter { /** * Simulate successful close port. * - * @param callback + * @param {(err?: Error) => void} callback */ close(callback) { this.callback = callback; @@ -170,7 +199,7 @@ class TcpPort extends EventEmitter { /** * Simulate successful destroy port. * - * @param callback + * @param {(err?: Error) => void} callback */ destroy(callback) { this.callback = callback; @@ -182,7 +211,7 @@ class TcpPort extends EventEmitter { /** * Send data to a modbus-tcp slave. * - * @param data + * @param {Buffer} data */ write(data) { if(data.length < MIN_DATA_LENGTH) { diff --git a/test/ports/tcpport.test.js b/test/ports/tcpport.test.js index 1a8224a..7449bb7 100644 --- a/test/ports/tcpport.test.js +++ b/test/ports/tcpport.test.js @@ -4,7 +4,7 @@ const expect = require("chai").expect; const mockery = require("mockery"); -describe("Modbus TCP port", function() { +describe("Modbus TCP port methods", function() { let port; before(function() { @@ -24,6 +24,62 @@ describe("Modbus TCP port", function() { port.close(); }); + describe("Modbus TCP port constructor", function() { + const TcpPort = require("./../../ports/tcpport"); + it("with ip as string", function() { + const port = new TcpPort("localhost"); + + expect(port).to.be.an.instanceOf(TcpPort); + expect(port.connectOptions.host).to.equal("localhost"); + expect(port.connectOptions.port).to.equal(502); + }); + + it("with ip as object", function() { + const port = new TcpPort({ ip: "localhost" }); + + expect(port).to.be.an.instanceOf(TcpPort); + expect(port.connectOptions.host).to.equal("localhost"); + expect(port.connectOptions.port).to.equal(502); + }); + + it("with ip as object and port as number", function() { + const port = new TcpPort({ ip: "localhost", port: 9999 }); + + expect(port).to.be.an.instanceOf(TcpPort); + expect(port.connectOptions.host).to.equal("localhost"); + expect(port.connectOptions.port).to.equal(9999); + }); + + it("with ip as string and options as object", function() { + const port = new TcpPort("localhost", { port: 9999 }); + + expect(port).to.be.an.instanceOf(TcpPort); + expect(port.connectOptions.host).to.equal("localhost"); + expect(port.connectOptions.port).to.equal(9999); + }); + + it("with socket creation options", function() { + const controller = new AbortController(); + const port = new TcpPort("localhost", { port: 9999, + socketOpts: { + allowHalfOpen: true, + readable: true, + writable: true, + signal: controller.signal + } }); + + expect(port).to.be.an.instanceOf(TcpPort); + expect(port.connectOptions.host).to.equal("localhost"); + expect(port.connectOptions.port).to.equal(9999); + expect(port.socketOpts).to.deep.equal({ + allowHalfOpen: true, + readable: true, + writable: true, + signal: controller.signal + }); + }); + }); + describe("#isOpen", function() { it("should not be open before #open", function() { expect(port.isOpen).to.be.false;