From 6a1fb680a5af64f2ec441f86c463efa2bf27a4a9 Mon Sep 17 00:00:00 2001 From: Barneey96 Date: Thu, 10 Sep 2026 14:18:13 +0200 Subject: [PATCH 1/7] fix: resynchronize the client receive buffer --- src/client-response-handler.ts | 40 +++++++++++- src/constants/limits.ts | 15 +++++ src/modbus-rtu-client.ts | 11 +++- src/modbus-tcp-client.ts | 12 +++- src/rtu-client-response-handler.ts | 13 +++- src/tcp-client-response-handler.ts | 15 +++-- test/rtu-client-response-handler.test.js | 79 +++++++++++++++++++++++ test/tcp-response-handler.test.js | 82 ++++++++++++++++++++++++ 8 files changed, 254 insertions(+), 13 deletions(-) diff --git a/src/client-response-handler.ts b/src/client-response-handler.ts index 1225cb4..0a6ebf1 100644 --- a/src/client-response-handler.ts +++ b/src/client-response-handler.ts @@ -1,4 +1,5 @@ import MBAbstractResponse from './abstract-response' +import { LIMITS } from './constants' /** Modbus Client Repsonse Handler * @abstract @@ -6,10 +7,23 @@ import MBAbstractResponse from './abstract-response' export default abstract class ModbusClientResponseHandler { protected _buffer: Buffer protected abstract _messages: ResType[] + protected readonly _maxBufferSize: number - /** Create new Modbus Client Response Handler */ - constructor () { + /** Create new Modbus Client Response Handler + * @param {number} [maxBufferSize=LIMITS.TCP_ADU_MAX] The number of bytes the receive buffer may + * hold without a single complete response being parsable from it. Defaults to the largest + * Modbus/TCP ADU. + */ + constructor (maxBufferSize: number = LIMITS.TCP_ADU_MAX) { this._buffer = Buffer.alloc(0) + this._maxBufferSize = maxBufferSize + } + + /** The number of bytes the receive buffer may hold without a single complete response + * being parsable from it. + */ + public get maxBufferSize () { + return this._maxBufferSize } /** Process new incoming data and enqueue new modbus responses. @@ -23,4 +37,26 @@ export default abstract class ModbusClientResponseHandler * @param {Socket} socket The TCP Socket. * @param {number} [unitId=1] Unit ID * @param {number} [timeout=5000] Timeout for requests in ms. + * @param {number} [maxBufferSize=LIMITS.TCP_ADU_MAX] The number of bytes the receive buffer may hold * @memberof ModbusTCPClient */ - constructor (socket: Socket, unitId: number = 1, timeout: number = 5000) { + constructor ( + socket: Socket, + unitId: number = 1, + timeout: number = 5000, + maxBufferSize: number = LIMITS.TCP_ADU_MAX + ) { super(socket) this._requestHandler = new MBTCPClientRequestHandler(socket, unitId, timeout) - this._responseHandler = new ModbusTCPClientResponseHandler() + this._responseHandler = new ModbusTCPClientResponseHandler(maxBufferSize) this._unitId = unitId this._timeout = timeout diff --git a/src/rtu-client-response-handler.ts b/src/rtu-client-response-handler.ts index 71fade0..121de25 100644 --- a/src/rtu-client-response-handler.ts +++ b/src/rtu-client-response-handler.ts @@ -1,5 +1,6 @@ import Debug = require('debug'); const debug = Debug('rtu-response-handler') import ModbusClientResponseHandler from './client-response-handler.js' +import { LIMITS } from './constants' import ModbusRTUResponse from './rtu-response.js' /** Modbus/RTU Client Response Handler @@ -9,8 +10,12 @@ import ModbusRTUResponse from './rtu-response.js' export default class ModbusRTUClientResponseHandler extends ModbusClientResponseHandler { protected _messages: ModbusRTUResponse[] - constructor () { - super() + /** Create new Modbus/RTU Client Response Handler + * @param {number} [maxBufferSize=LIMITS.RTU_ADU_MAX] The number of bytes the receive buffer may + * hold without a single complete response being parsable from it. + */ + constructor (maxBufferSize: number = LIMITS.RTU_ADU_MAX) { + super(maxBufferSize) this._messages = [] } @@ -25,6 +30,10 @@ export default class ModbusRTUClientResponseHandler extends ModbusClientResponse if (!response) { debug('not enough data available to parse') + /* the remaining bytes are at most one incomplete frame by now, so anything beyond + * the largest possible ADU cannot be waiting for more data to arrive + */ + this._discardOversizedBuffer() return } diff --git a/src/tcp-client-response-handler.ts b/src/tcp-client-response-handler.ts index a499870..904bbb8 100644 --- a/src/tcp-client-response-handler.ts +++ b/src/tcp-client-response-handler.ts @@ -1,5 +1,6 @@ import Debug = require('debug'); const debug = Debug('tcp-response-handler') import ModbusClientResponseHandler from './client-response-handler.js' +import { LIMITS } from './constants' import ModbusTCPResponse from './tcp-response.js' /** Modbus/TCP Client Response Handler. @@ -9,10 +10,12 @@ import ModbusTCPResponse from './tcp-response.js' export default class ModbusTCPClientResponseHandler extends ModbusClientResponseHandler { protected _messages: ModbusTCPResponse[] - /** Create new Modbus/TCP Client Response Handler */ - constructor () { - super() - this._buffer = Buffer.alloc(0) + /** Create new Modbus/TCP Client Response Handler + * @param {number} [maxBufferSize=LIMITS.TCP_ADU_MAX] The number of bytes the receive buffer may + * hold without a single complete response being parsable from it. + */ + constructor (maxBufferSize: number = LIMITS.TCP_ADU_MAX) { + super(maxBufferSize) this._messages = [] } @@ -27,6 +30,10 @@ export default class ModbusTCPClientResponseHandler extends ModbusClientResponse if (!response) { debug('not enough data available to parse') + /* the remaining bytes are at most one incomplete frame by now, so anything beyond + * the largest possible ADU cannot be waiting for more data to arrive + */ + this._discardOversizedBuffer() return } diff --git a/test/rtu-client-response-handler.test.js b/test/rtu-client-response-handler.test.js index 2586cd7..ba08925 100644 --- a/test/rtu-client-response-handler.test.js +++ b/test/rtu-client-response-handler.test.js @@ -81,4 +81,83 @@ describe('Modbus/RTU Client Response Tests', function () { assert.equal(1, response.body.fc) assert.deepEqual([1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0], response.body.valuesAsArray) }) + + /* Modbus/RTU has no framing in the payload at all, so line noise or a frame the device + * never completed stays in front of every following response. The buffer only ever advances + * when a complete response parses and every frame is read from offset 0. + */ + describe('resynchronizing an unparsable buffer', function () { + /* function code 0x00 does not exist, so this never parses into a response body */ + const noise = Buffer.from([0x00, 0x00, 0x00, 0x00]) + + const readCoilsResponse = function () { + return Buffer.from([ + 0x01, // address + 0x01, // function code + 0x02, // byte count + 0xdd, // coils + 0x00, + 0xCD, 0xAB // crc + ]) + } + + it('should default to the largest possible Modbus/RTU ADU', function () { + assert.equal(256, new ModbusRTUClientResponseHandler().maxBufferSize) + assert.equal(64, new ModbusRTUClientResponseHandler(64).maxBufferSize) + }) + + it('should discard the buffer when no response parses from more than maxBufferSize bytes', function () { + handler = new ModbusRTUClientResponseHandler(12) + + handler.handleData(noise) + assert.equal(undefined, handler.shift(), 'noise must not parse into a response') + + /* 11 bytes, below the limit, the noise keeps this response from parsing */ + handler.handleData(readCoilsResponse()) + assert.equal(undefined, handler.shift(), 'the leading noise must desync this response') + + /* 18 bytes, beyond the limit, the prefix provably is not the start of a frame */ + handler.handleData(readCoilsResponse()) + assert.equal(undefined, handler.shift()) + + /* the buffer was dropped, so this response is aligned again */ + handler.handleData(readCoilsResponse()) + + const response = handler.shift() + + assert.ok(response !== undefined, 'the handler did not resynchronize') + assert.equal(1, response.address) + assert.equal(1, response.body.fc) + assert.deepEqual([1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0], response.body.valuesAsArray) + }) + + it('should keep a response that is still being received', function () { + /* room for exactly one ADU, so the incomplete frame sits right at the limit */ + handler = new ModbusRTUClientResponseHandler(7) + const responseBuffer = readCoilsResponse() + + handler.handleData(responseBuffer.slice(0, 4)) + assert.equal(undefined, handler.shift()) + + handler.handleData(responseBuffer.slice(4)) + + const response = handler.shift() + + assert.ok(response !== undefined, 'a frame at the limit must not be discarded') + assert.equal(1, response.body.fc) + }) + + it('should resynchronize with the default limit as well', function () { + handler.handleData(noise) + + let response + for (let i = 0; i < 45 && response === undefined; i += 1) { + handler.handleData(readCoilsResponse()) + response = handler.shift() + } + + assert.ok(response !== undefined, 'the handler never resynchronized') + assert.equal(1, response.body.fc) + }) + }) }) diff --git a/test/tcp-response-handler.test.js b/test/tcp-response-handler.test.js index df832fb..36ee241 100644 --- a/test/tcp-response-handler.test.js +++ b/test/tcp-response-handler.test.js @@ -184,4 +184,86 @@ describe('Modbus/TCP Client Response Handler Tests', function () { assert.equal(1, response.body.fc) assert.deepEqual([1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0], response.body.valuesAsArray) }) + + /* A device that answers with fewer bytes than its MBAP header announces leaves a prefix in + * the receive buffer. The buffer only ever advances when a complete response parses and + * every frame is read from offset 0, so that prefix desyncs every following response until + * the socket is reconnected. + */ + describe('resynchronizing an unparsable buffer', function () { + /* the beginning of a response the device never completed */ + const truncatedFrame = Buffer.from([0x00, 0x2a, 0x00, 0x00]) + + const readCoilsResponse = function () { + return Buffer.from([ + 0x00, 0x01, // transaction id + 0x00, 0x00, // protocol + 0x00, 0x05, // length + 0x03, // unit id + 0x01, // function code + 0x02, // byte count + 0xdd, // coils + 0x00 + ]) + } + + it('should default to the largest possible Modbus/TCP ADU', function () { + assert.equal(260, new TCPResponseHandler().maxBufferSize) + assert.equal(64, new TCPResponseHandler(64).maxBufferSize) + }) + + it('should discard the buffer when no response parses from more than maxBufferSize bytes', function () { + handler = new TCPResponseHandler(20) + + handler.handleData(truncatedFrame) + assert.equal(undefined, handler.shift(), 'a truncated frame must not parse') + + /* 15 bytes, below the limit, the stale prefix keeps this response from parsing */ + handler.handleData(readCoilsResponse()) + assert.equal(undefined, handler.shift(), 'the stale prefix must desync this response') + + /* 26 bytes, beyond the limit, the prefix provably is not the start of a frame */ + handler.handleData(readCoilsResponse()) + assert.equal(undefined, handler.shift()) + + /* the buffer was dropped, so this response is aligned again */ + handler.handleData(readCoilsResponse()) + + const response = handler.shift() + + assert.ok(response !== undefined, 'the handler did not resynchronize') + assert.equal(1, response.id) + assert.equal(1, response.body.fc) + assert.deepEqual([1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0], response.body.valuesAsArray) + }) + + it('should keep a response that is still being received', function () { + /* room for exactly one ADU, so the incomplete frame sits right at the limit */ + handler = new TCPResponseHandler(11) + const responseBuffer = readCoilsResponse() + + handler.handleData(responseBuffer.slice(0, 6)) + assert.equal(undefined, handler.shift()) + + handler.handleData(responseBuffer.slice(6)) + + const response = handler.shift() + + assert.ok(response !== undefined, 'a frame at the limit must not be discarded') + assert.equal(1, response.body.fc) + }) + + it('should resynchronize with the default limit as well', function () { + handler.handleData(truncatedFrame) + + let response + for (let i = 0; i < 30 && response === undefined; i += 1) { + handler.handleData(readCoilsResponse()) + response = handler.shift() + } + + assert.ok(response !== undefined, 'the handler never resynchronized') + assert.equal(1, response.body.fc) + }) + }) }) From 375f2c44e2d5f185aac05972a9ac40784d54670d Mon Sep 17 00:00:00 2001 From: Barneey96 Date: Tue, 15 Sep 2026 10:25:29 +0200 Subject: [PATCH 2/7] fix: RTU CRC validation & fast resynchronization based on TCP MBAP header --- src/constants/limits.ts | 11 +++ src/rtu-client-response-handler.ts | 49 +++++++++++- src/rtu-response.ts | 40 ++++++++-- src/tcp-client-response-handler.ts | 48 +++++++++++- src/tcp-response.ts | 12 +++ test/rtu-client-response-handler.test.js | 93 +++++++++++++++++++---- test/tcp-response-handler.test.js | 94 ++++++++++++++++++------ 7 files changed, 300 insertions(+), 47 deletions(-) diff --git a/src/constants/limits.ts b/src/constants/limits.ts index 1f88ad3..e0565cb 100644 --- a/src/constants/limits.ts +++ b/src/constants/limits.ts @@ -20,10 +20,21 @@ const RTU_ADU_MAX = 256 /* MBAP header (7) + PDU (253) */ const TCP_ADU_MAX = 260 +/* Transaction id (2) + protocol id (2) + length (2). The length field that follows counts the + * unit identifier and the PDU, so a complete Modbus/TCP ADU is MBAP_PREFIX_LENGTH + length and + * the length field itself can never leave 2 .. PDU_MAX + 1. + */ +const MBAP_PREFIX_LENGTH = 6 + +/* The protocol identifier reserved for Modbus in the MBAP header */ +const MBAP_PROTOCOL_ID = 0x0000 + export const LIMITS = { COIL_MAX, COIL_MIN, ERROR_CODE_THRESHOLD, + MBAP_PREFIX_LENGTH, + MBAP_PROTOCOL_ID, PDU_MAX, REGISTER_MAX, REGISTER_MIN, diff --git a/src/rtu-client-response-handler.ts b/src/rtu-client-response-handler.ts index 121de25..2640dc0 100644 --- a/src/rtu-client-response-handler.ts +++ b/src/rtu-client-response-handler.ts @@ -1,5 +1,6 @@ -import Debug = require('debug'); const debug = Debug('rtu-response-handler') +import Debug from 'debug'; const debug = Debug('rtu-response-handler') import ModbusClientResponseHandler from './client-response-handler.js' +import { isFunctionCode } from './codes' import { LIMITS } from './constants' import ModbusRTUResponse from './rtu-response.js' @@ -29,6 +30,10 @@ export default class ModbusRTUClientResponseHandler extends ModbusClientResponse const response = ModbusRTUResponse.fromBuffer(this._buffer) if (!response) { + if (this._resynchronize()) { + continue + } + debug('not enough data available to parse') /* the remaining bytes are at most one incomplete frame by now, so anything beyond * the largest possible ADU cannot be waiting for more data to arrive @@ -37,6 +42,17 @@ export default class ModbusRTUClientResponseHandler extends ModbusClientResponse return } + if (response.corrupted) { + /* The CRC covers the whole frame, so a mismatch proves offset 0 is not where this + * response starts. Drop a single byte rather than the frame the parser believed it + * saw: its byte count was derived from bytes we just proved untrustworthy, so + * skipping it could swallow the real frame that begins in the middle of it. + */ + debug('crc mismatch, skipping one byte to resynchronize') + this._buffer = this._buffer.slice(1) + continue + } + debug('crc', response.crc) debug('reset buffer from', this._buffer.length, 'to', (this._buffer.length - response.byteCount)) @@ -51,4 +67,35 @@ export default class ModbusRTUClientResponseHandler extends ModbusClientResponse public shift () { return this._messages.shift() } + + /** Drop the leading byte when it provably cannot begin a response, and report whether it did. + * + * A frame that failed to parse is usually just still arriving, so the buffer has to be left + * alone. The one thing that can be decided from an incomplete frame is its function code: the + * parser accepts a body only for a known function code, or for an exception whose underlying + * code is known, so anything else can never parse no matter how many bytes follow it. Waiting + * for the buffer to outgrow an ADU before dropping it would stall every request in the + * meantime, and each stalled request costs a full timeout. + * + * Only ever one byte: the real frame may start anywhere inside what is being skipped. + */ + private _resynchronize () { + if (this._buffer.length < 2) { + /* the function code has not arrived yet */ + return false + } + + const fc = this._buffer.readUInt8(1) + const isException = fc > LIMITS.ERROR_CODE_THRESHOLD && + isFunctionCode(fc - LIMITS.ERROR_CODE_THRESHOLD) + + if (isFunctionCode(fc) || isException) { + return false + } + + debug('function code', fc, 'cannot start a response, skipping one byte to resynchronize') + this._buffer = this._buffer.slice(1) + + return true + } } diff --git a/src/rtu-response.ts b/src/rtu-response.ts index c903738..09340c2 100644 --- a/src/rtu-response.ts +++ b/src/rtu-response.ts @@ -17,6 +17,14 @@ export default class ModbusRTUResponse LIMITS.PDU_MAX + 1) { + debug('implausible mbap header, skipping one byte to resynchronize') + this._buffer = this._buffer.slice(1) + return true + } + + const frameLength = LIMITS.MBAP_PREFIX_LENGTH + length + + if (this._buffer.length < frameLength) { + /* the announced frame is still on its way */ + return false + } + + debug('skipping a complete but unparsable frame of', frameLength, 'bytes') + this._buffer = this._buffer.slice(frameLength) + + return true + } } diff --git a/src/tcp-response.ts b/src/tcp-response.ts index 8fbb8c1..07ff40d 100644 --- a/src/tcp-response.ts +++ b/src/tcp-response.ts @@ -1,5 +1,6 @@ import Debug = require('debug'); const debug = Debug('tcp-response') import ModbusAbstractResponse from './abstract-response.js' +import { LIMITS } from './constants' import { ModbusRequestBody } from './request' import ModbusResponseBody from './response/response-body.js' import ResponseFactory from './response/response-factory.js' @@ -75,6 +76,17 @@ export default class ModbusTCPResponse Date: Tue, 15 Sep 2026 11:20:17 +0200 Subject: [PATCH 3/7] chore: bump version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3beabd3..6d8f388 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jsmodbus", - "version": "5.0.0", + "version": "5.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jsmodbus", - "version": "5.0.0", + "version": "5.0.1", "license": "MIT", "dependencies": { "commander": "^11.1.0", diff --git a/package.json b/package.json index a992265..5e93f4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jsmodbus", - "version": "5.0.0", + "version": "5.0.1", "description": "Implementation for the Serial/TCP Modbus protocol.", "author": "Stefan Poeter ", "main": "./dist/modbus.js", From 7590c5a40d84b416c7543d927263557382fb865c Mon Sep 17 00:00:00 2001 From: Barneey96 Date: Tue, 15 Sep 2026 11:21:39 +0200 Subject: [PATCH 4/7] fix: the body must fill length - 1 exactly --- src/rtu-response.ts | 12 +++- src/tcp-response.ts | 10 ++++ test/rtu-client-response-handler.test.js | 51 ++++++++++++++++- test/tcp-response-handler.test.js | 72 ++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 5 deletions(-) diff --git a/src/rtu-response.ts b/src/rtu-response.ts index 09340c2..3176368 100644 --- a/src/rtu-response.ts +++ b/src/rtu-response.ts @@ -83,9 +83,15 @@ export default class ModbusRTUResponse 1500, `expected the handler to keep recovering, got ${delivered}`) + }) }) }) diff --git a/test/tcp-response-handler.test.js b/test/tcp-response-handler.test.js index c15eb39..aa27894 100644 --- a/test/tcp-response-handler.test.js +++ b/test/tcp-response-handler.test.js @@ -269,6 +269,33 @@ describe('Modbus/TCP Client Response Handler Tests', function () { assert.equal(undefined, handler.shift(), 'an incomplete frame must never reach the caller') }) + /* The frame has fully arrived, but the body stops short of the length the header announced, + * which is how noise that happens to look like an MBAP header presents itself. + */ + it('should not parse a frame whose body does not fill the announced length', function () { + handler.handleData(Buffer.concat([ + Buffer.from([ + 0x00, 0x01, // transaction id + 0x00, 0x00, // protocol + 0x00, 0x08, // length: claims a 7 byte pdu + 0x03, // unit id + 0x01, // function code + 0x02, // byte count + 0xdd, // coils + 0x00, + 0x00, 0x00, 0x00 // padding the body never accounts for + ]), + readCoilsResponse() + ])) + + const response = handler.shift() + + assert.ok(response !== undefined, 'the frame behind the bad one must still be found') + assert.equal(1, response.id, 'the short body must not be delivered') + assert.deepEqual([1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0], response.body.valuesAsArray) + assert.equal(undefined, handler.shift(), 'only one response may be delivered') + }) + it('should keep a response that is still being received', function () { /* room for exactly one ADU, so the incomplete frame sits right at the limit */ handler = new TCPResponseHandler(11) @@ -313,5 +340,50 @@ describe('Modbus/TCP Client Response Handler Tests', function () { assert.equal(1, response.id) assert.equal(1, response.body.fc) }) + + /* The cases above pin individual scenarios, but the property that actually matters is a + * global one: wherever the stream is cut and whatever lands between frames, a response that + * reaches the caller must be the one the device sent. The seed is fixed so a failure is + * reproducible. + */ + it('should never deliver a corrupted response under random noise', function () { + let seed = 0x2f6e2b1 + const rnd = function (n) { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed % n } + + handler = new TCPResponseHandler() + + let delivered = 0 + + for (let round = 0; round < 2000; round += 1) { + if (rnd(3) === 0) { + const noise = Buffer.alloc(1 + rnd(12)) + for (let i = 0; i < noise.length; i += 1) { noise[i] = rnd(256) } + handler.handleData(noise) + } + + const frame = readCoilsResponse() + + if (rnd(2) === 0) { + const at = 1 + rnd(frame.length - 1) + handler.handleData(frame.slice(0, at)) + handler.handleData(frame.slice(at)) + } else { + handler.handleData(frame) + } + + let response + while ((response = handler.shift()) !== undefined) { + delivered += 1 + assert.equal(1, response.body.fc, 'a response built from noise reached the caller') + assert.deepEqual( + [1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0], + response.body.valuesAsArray, + 'a response built from noise reached the caller' + ) + } + } + + assert.ok(delivered > 1500, `expected the handler to keep recovering, got ${delivered}`) + }) }) }) From 0db52fc828e31bed5b04aebc3b5403e3f22df209 Mon Sep 17 00:00:00 2001 From: Barneey96 Date: Tue, 29 Sep 2026 12:01:47 +0200 Subject: [PATCH 5/7] revert: drop receive buffer resynchronization changes Reverts 6a1fb68, 375f2c4, 686c522 and 7590c5a to bring the branch back to the state of v5.0-dev. The faulty-response handling will instead be made pluggable via custom per function code response handlers. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 4 +- package.json | 2 +- src/client-response-handler.ts | 40 +---- src/constants/limits.ts | 26 --- src/modbus-rtu-client.ts | 11 +- src/modbus-tcp-client.ts | 12 +- src/rtu-client-response-handler.ts | 62 +------ src/rtu-response.ts | 46 +----- src/tcp-client-response-handler.ts | 59 +------ src/tcp-response.ts | 22 --- test/rtu-client-response-handler.test.js | 195 +--------------------- test/tcp-response-handler.test.js | 202 ----------------------- 12 files changed, 26 insertions(+), 655 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6d8f388..3beabd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jsmodbus", - "version": "5.0.1", + "version": "5.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jsmodbus", - "version": "5.0.1", + "version": "5.0.0", "license": "MIT", "dependencies": { "commander": "^11.1.0", diff --git a/package.json b/package.json index 5e93f4f..a992265 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jsmodbus", - "version": "5.0.1", + "version": "5.0.0", "description": "Implementation for the Serial/TCP Modbus protocol.", "author": "Stefan Poeter ", "main": "./dist/modbus.js", diff --git a/src/client-response-handler.ts b/src/client-response-handler.ts index 0a6ebf1..1225cb4 100644 --- a/src/client-response-handler.ts +++ b/src/client-response-handler.ts @@ -1,5 +1,4 @@ import MBAbstractResponse from './abstract-response' -import { LIMITS } from './constants' /** Modbus Client Repsonse Handler * @abstract @@ -7,23 +6,10 @@ import { LIMITS } from './constants' export default abstract class ModbusClientResponseHandler { protected _buffer: Buffer protected abstract _messages: ResType[] - protected readonly _maxBufferSize: number - /** Create new Modbus Client Response Handler - * @param {number} [maxBufferSize=LIMITS.TCP_ADU_MAX] The number of bytes the receive buffer may - * hold without a single complete response being parsable from it. Defaults to the largest - * Modbus/TCP ADU. - */ - constructor (maxBufferSize: number = LIMITS.TCP_ADU_MAX) { + /** Create new Modbus Client Response Handler */ + constructor () { this._buffer = Buffer.alloc(0) - this._maxBufferSize = maxBufferSize - } - - /** The number of bytes the receive buffer may hold without a single complete response - * being parsable from it. - */ - public get maxBufferSize () { - return this._maxBufferSize } /** Process new incoming data and enqueue new modbus responses. @@ -37,26 +23,4 @@ export default abstract class ModbusClientResponseHandler * @param {Socket} socket The TCP Socket. * @param {number} [unitId=1] Unit ID * @param {number} [timeout=5000] Timeout for requests in ms. - * @param {number} [maxBufferSize=LIMITS.TCP_ADU_MAX] The number of bytes the receive buffer may hold * @memberof ModbusTCPClient */ - constructor ( - socket: Socket, - unitId: number = 1, - timeout: number = 5000, - maxBufferSize: number = LIMITS.TCP_ADU_MAX - ) { + constructor (socket: Socket, unitId: number = 1, timeout: number = 5000) { super(socket) this._requestHandler = new MBTCPClientRequestHandler(socket, unitId, timeout) - this._responseHandler = new ModbusTCPClientResponseHandler(maxBufferSize) + this._responseHandler = new ModbusTCPClientResponseHandler() this._unitId = unitId this._timeout = timeout diff --git a/src/rtu-client-response-handler.ts b/src/rtu-client-response-handler.ts index 2640dc0..71fade0 100644 --- a/src/rtu-client-response-handler.ts +++ b/src/rtu-client-response-handler.ts @@ -1,7 +1,5 @@ -import Debug from 'debug'; const debug = Debug('rtu-response-handler') +import Debug = require('debug'); const debug = Debug('rtu-response-handler') import ModbusClientResponseHandler from './client-response-handler.js' -import { isFunctionCode } from './codes' -import { LIMITS } from './constants' import ModbusRTUResponse from './rtu-response.js' /** Modbus/RTU Client Response Handler @@ -11,12 +9,8 @@ import ModbusRTUResponse from './rtu-response.js' export default class ModbusRTUClientResponseHandler extends ModbusClientResponseHandler { protected _messages: ModbusRTUResponse[] - /** Create new Modbus/RTU Client Response Handler - * @param {number} [maxBufferSize=LIMITS.RTU_ADU_MAX] The number of bytes the receive buffer may - * hold without a single complete response being parsable from it. - */ - constructor (maxBufferSize: number = LIMITS.RTU_ADU_MAX) { - super(maxBufferSize) + constructor () { + super() this._messages = [] } @@ -30,29 +24,10 @@ export default class ModbusRTUClientResponseHandler extends ModbusClientResponse const response = ModbusRTUResponse.fromBuffer(this._buffer) if (!response) { - if (this._resynchronize()) { - continue - } - debug('not enough data available to parse') - /* the remaining bytes are at most one incomplete frame by now, so anything beyond - * the largest possible ADU cannot be waiting for more data to arrive - */ - this._discardOversizedBuffer() return } - if (response.corrupted) { - /* The CRC covers the whole frame, so a mismatch proves offset 0 is not where this - * response starts. Drop a single byte rather than the frame the parser believed it - * saw: its byte count was derived from bytes we just proved untrustworthy, so - * skipping it could swallow the real frame that begins in the middle of it. - */ - debug('crc mismatch, skipping one byte to resynchronize') - this._buffer = this._buffer.slice(1) - continue - } - debug('crc', response.crc) debug('reset buffer from', this._buffer.length, 'to', (this._buffer.length - response.byteCount)) @@ -67,35 +42,4 @@ export default class ModbusRTUClientResponseHandler extends ModbusClientResponse public shift () { return this._messages.shift() } - - /** Drop the leading byte when it provably cannot begin a response, and report whether it did. - * - * A frame that failed to parse is usually just still arriving, so the buffer has to be left - * alone. The one thing that can be decided from an incomplete frame is its function code: the - * parser accepts a body only for a known function code, or for an exception whose underlying - * code is known, so anything else can never parse no matter how many bytes follow it. Waiting - * for the buffer to outgrow an ADU before dropping it would stall every request in the - * meantime, and each stalled request costs a full timeout. - * - * Only ever one byte: the real frame may start anywhere inside what is being skipped. - */ - private _resynchronize () { - if (this._buffer.length < 2) { - /* the function code has not arrived yet */ - return false - } - - const fc = this._buffer.readUInt8(1) - const isException = fc > LIMITS.ERROR_CODE_THRESHOLD && - isFunctionCode(fc - LIMITS.ERROR_CODE_THRESHOLD) - - if (isFunctionCode(fc) || isException) { - return false - } - - debug('function code', fc, 'cannot start a response, skipping one byte to resynchronize') - this._buffer = this._buffer.slice(1) - - return true - } } diff --git a/src/rtu-response.ts b/src/rtu-response.ts index 3176368..c903738 100644 --- a/src/rtu-response.ts +++ b/src/rtu-response.ts @@ -17,14 +17,6 @@ export default class ModbusRTUResponse { protected _messages: ModbusTCPResponse[] - /** Create new Modbus/TCP Client Response Handler - * @param {number} [maxBufferSize=LIMITS.TCP_ADU_MAX] The number of bytes the receive buffer may - * hold without a single complete response being parsable from it. - */ - constructor (maxBufferSize: number = LIMITS.TCP_ADU_MAX) { - super(maxBufferSize) + /** Create new Modbus/TCP Client Response Handler */ + constructor () { + super() + this._buffer = Buffer.alloc(0) this._messages = [] } @@ -29,15 +26,7 @@ export default class ModbusTCPClientResponseHandler extends ModbusClientResponse const response = ModbusTCPResponse.fromBuffer(this._buffer) if (!response) { - if (this._resynchronize()) { - continue - } - debug('not enough data available to parse') - /* nothing could be skipped, so the bytes left are one frame that is still arriving; - * anything beyond the largest possible ADU cannot be waiting for more data - */ - this._discardOversizedBuffer() return } @@ -56,44 +45,4 @@ export default class ModbusTCPClientResponseHandler extends ModbusClientResponse this._buffer = this._buffer.slice(response.byteCount) } while (1) } - - /** Move the buffer to the next plausible frame after a response failed to parse and report - * whether anything was skipped. - * - * Unlike a raw serial stream, Modbus/TCP prefixes every frame with an MBAP header stating the - * exact frame length, so a desynchronized buffer can normally be realigned in a single step - * rather than by waiting for it to outgrow an ADU. A frame that has fully arrived and still - * does not parse - an unimplemented function code, a corrupt payload - is skipped whole, - * because its header told us exactly where the next one begins. - * - * Bytes that cannot be an MBAP header at all cost a single byte each instead: the real frame - * may begin anywhere inside them, so skipping further could swallow it. - */ - private _resynchronize () { - if (this._buffer.length < LIMITS.MBAP_PREFIX_LENGTH) { - /* the header itself is still arriving */ - return false - } - - const protocol = this._buffer.readUInt16BE(2) - const length = this._buffer.readUInt16BE(4) - - if (protocol !== LIMITS.MBAP_PROTOCOL_ID || length < 2 || length > LIMITS.PDU_MAX + 1) { - debug('implausible mbap header, skipping one byte to resynchronize') - this._buffer = this._buffer.slice(1) - return true - } - - const frameLength = LIMITS.MBAP_PREFIX_LENGTH + length - - if (this._buffer.length < frameLength) { - /* the announced frame is still on its way */ - return false - } - - debug('skipping a complete but unparsable frame of', frameLength, 'bytes') - this._buffer = this._buffer.slice(frameLength) - - return true - } } diff --git a/src/tcp-response.ts b/src/tcp-response.ts index 106f556..8fbb8c1 100644 --- a/src/tcp-response.ts +++ b/src/tcp-response.ts @@ -1,6 +1,5 @@ import Debug = require('debug'); const debug = Debug('tcp-response') import ModbusAbstractResponse from './abstract-response.js' -import { LIMITS } from './constants' import { ModbusRequestBody } from './request' import ModbusResponseBody from './response/response-body.js' import ResponseFactory from './response/response-factory.js' @@ -76,17 +75,6 @@ export default class ModbusTCPResponse 1500, `expected the handler to keep recovering, got ${delivered}`) - }) - }) }) diff --git a/test/tcp-response-handler.test.js b/test/tcp-response-handler.test.js index aa27894..df832fb 100644 --- a/test/tcp-response-handler.test.js +++ b/test/tcp-response-handler.test.js @@ -184,206 +184,4 @@ describe('Modbus/TCP Client Response Handler Tests', function () { assert.equal(1, response.body.fc) assert.deepEqual([1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0], response.body.valuesAsArray) }) - - /* A device that answers with fewer bytes than its MBAP header announces, or with a frame this - * library has no parser for, leaves a prefix in the receive buffer. The buffer only ever - * advances when a complete response parses and every frame is read from offset 0, so that - * prefix desyncs every following response until the handler realigns itself. Unlike a serial - * stream, Modbus/TCP states the frame length in its header, so realigning rarely costs more - * than the damaged frame itself. - */ - describe('resynchronizing an unparsable buffer', function () { - /* the beginning of a response the device never completed */ - const truncatedFrame = Buffer.from([0x00, 0x2a, 0x00, 0x00]) - - const readCoilsResponse = function () { - return Buffer.from([ - 0x00, 0x01, // transaction id - 0x00, 0x00, // protocol - 0x00, 0x05, // length - 0x03, // unit id - 0x01, // function code - 0x02, // byte count - 0xdd, // coils - 0x00 - ]) - } - - /* A complete, well formed frame carrying function code 0x11 (Report Server ID), which this - * library has no response parser for. Its header still says exactly where it ends. - */ - const unsupportedFunctionResponse = function () { - return Buffer.from([ - 0x00, 0x07, // transaction id - 0x00, 0x00, // protocol - 0x00, 0x04, // length - 0x03, // unit id - 0x11, // function code - 0x02, // byte count - 0xaa - ]) - } - - it('should default to the largest possible Modbus/TCP ADU', function () { - assert.equal(260, new TCPResponseHandler().maxBufferSize) - assert.equal(64, new TCPResponseHandler(64).maxBufferSize) - }) - - it('should skip a complete but unparsable frame in a single step', function () { - handler.handleData(Buffer.concat([ - unsupportedFunctionResponse(), - readCoilsResponse() - ])) - - const response = handler.shift() - - assert.ok(response !== undefined, 'the frame behind the unparsable one must be found') - assert.equal(1, response.id) - assert.equal(1, response.body.fc) - assert.deepEqual([1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0], response.body.valuesAsArray) - }) - - it('should resynchronize as soon as the next response arrives', function () { - handler.handleData(truncatedFrame) - assert.equal(undefined, handler.shift(), 'a truncated frame must not parse') - - handler.handleData(readCoilsResponse()) - - const response = handler.shift() - - assert.ok(response !== undefined, 'the handler did not resynchronize') - assert.equal(1, response.id) - assert.equal(1, response.body.fc) - assert.deepEqual([1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0], response.body.valuesAsArray) - }) - - /* Buffer#slice clamps silently, so without the length guard this parses into a response - * built from bytes that never arrived. - */ - it('should not parse a frame that announced more bytes than it sent', function () { - const lying = readCoilsResponse() - lying.writeUInt16BE(0x20, 4) // claim 32 bytes follow, then send 5 - - handler.handleData(lying) - - assert.equal(undefined, handler.shift(), 'an incomplete frame must never reach the caller') - }) - - /* The frame has fully arrived, but the body stops short of the length the header announced, - * which is how noise that happens to look like an MBAP header presents itself. - */ - it('should not parse a frame whose body does not fill the announced length', function () { - handler.handleData(Buffer.concat([ - Buffer.from([ - 0x00, 0x01, // transaction id - 0x00, 0x00, // protocol - 0x00, 0x08, // length: claims a 7 byte pdu - 0x03, // unit id - 0x01, // function code - 0x02, // byte count - 0xdd, // coils - 0x00, - 0x00, 0x00, 0x00 // padding the body never accounts for - ]), - readCoilsResponse() - ])) - - const response = handler.shift() - - assert.ok(response !== undefined, 'the frame behind the bad one must still be found') - assert.equal(1, response.id, 'the short body must not be delivered') - assert.deepEqual([1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0], response.body.valuesAsArray) - assert.equal(undefined, handler.shift(), 'only one response may be delivered') - }) - - it('should keep a response that is still being received', function () { - /* room for exactly one ADU, so the incomplete frame sits right at the limit */ - handler = new TCPResponseHandler(11) - const responseBuffer = readCoilsResponse() - - handler.handleData(responseBuffer.slice(0, 6)) - assert.equal(undefined, handler.shift()) - - handler.handleData(responseBuffer.slice(6)) - - const response = handler.shift() - - assert.ok(response !== undefined, 'a frame at the limit must not be discarded') - assert.equal(1, response.body.fc) - }) - - /* A header that is plausible and announces a frame still on its way cannot be skipped - the - * rest may genuinely be in flight - so the buffer limit remains the backstop for a device - * that stops halfway through a frame it announced. - */ - it('should discard the buffer when no response parses from more than maxBufferSize bytes', function () { - handler = new TCPResponseHandler(20) - - /* announces 100 bytes and then stops */ - handler.handleData(Buffer.from([0x00, 0x2a, 0x00, 0x00, 0x00, 0x64, 0x03])) - assert.equal(undefined, handler.shift(), 'a stalled frame must not parse') - - /* 18 bytes, below the limit, the stalled frame keeps this response from parsing */ - handler.handleData(readCoilsResponse()) - assert.equal(undefined, handler.shift(), 'the stalled frame must desync this response') - - /* 29 bytes, beyond the limit, so the buffer is dropped */ - handler.handleData(readCoilsResponse()) - assert.equal(undefined, handler.shift()) - - /* the buffer was dropped, so this response is aligned again */ - handler.handleData(readCoilsResponse()) - - const response = handler.shift() - - assert.ok(response !== undefined, 'the handler did not resynchronize') - assert.equal(1, response.id) - assert.equal(1, response.body.fc) - }) - - /* The cases above pin individual scenarios, but the property that actually matters is a - * global one: wherever the stream is cut and whatever lands between frames, a response that - * reaches the caller must be the one the device sent. The seed is fixed so a failure is - * reproducible. - */ - it('should never deliver a corrupted response under random noise', function () { - let seed = 0x2f6e2b1 - const rnd = function (n) { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed % n } - - handler = new TCPResponseHandler() - - let delivered = 0 - - for (let round = 0; round < 2000; round += 1) { - if (rnd(3) === 0) { - const noise = Buffer.alloc(1 + rnd(12)) - for (let i = 0; i < noise.length; i += 1) { noise[i] = rnd(256) } - handler.handleData(noise) - } - - const frame = readCoilsResponse() - - if (rnd(2) === 0) { - const at = 1 + rnd(frame.length - 1) - handler.handleData(frame.slice(0, at)) - handler.handleData(frame.slice(at)) - } else { - handler.handleData(frame) - } - - let response - while ((response = handler.shift()) !== undefined) { - delivered += 1 - assert.equal(1, response.body.fc, 'a response built from noise reached the caller') - assert.deepEqual( - [1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0], - response.body.valuesAsArray, - 'a response built from noise reached the caller' - ) - } - } - - assert.ok(delivered > 1500, `expected the handler to keep recovering, got ${delivered}`) - }) - }) }) From de8d0b29b8e7e869fdf88f426c4fa79860ce0e6a Mon Sep 17 00:00:00 2001 From: Barneey96 Date: Tue, 29 Sep 2026 13:12:06 +0200 Subject: [PATCH 6/7] feat: introduce esponse-body-handler interface --- package-lock.json | 4 +- package.json | 2 +- src/client-response-handler.ts | 28 ++++ src/modbus-client.ts | 19 +++ src/modbus.ts | 1 + src/response/response-body-handler.ts | 12 ++ src/rtu-client-response-handler.ts | 2 +- src/rtu-response.ts | 7 +- src/tcp-client-response-handler.ts | 2 +- src/tcp-response.ts | 7 +- test/custom-response-handler.test.js | 186 ++++++++++++++++++++++++++ 11 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 src/response/response-body-handler.ts create mode 100644 test/custom-response-handler.test.js diff --git a/package-lock.json b/package-lock.json index 3beabd3..6d8f388 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jsmodbus", - "version": "5.0.0", + "version": "5.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jsmodbus", - "version": "5.0.0", + "version": "5.0.1", "license": "MIT", "dependencies": { "commander": "^11.1.0", diff --git a/package.json b/package.json index a992265..5e93f4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jsmodbus", - "version": "5.0.0", + "version": "5.0.1", "description": "Implementation for the Serial/TCP Modbus protocol.", "author": "Stefan Poeter ", "main": "./dist/modbus.js", diff --git a/src/client-response-handler.ts b/src/client-response-handler.ts index 1225cb4..3df84f4 100644 --- a/src/client-response-handler.ts +++ b/src/client-response-handler.ts @@ -1,4 +1,6 @@ import MBAbstractResponse from './abstract-response' +import IResponseBodyHandler from './response/response-body-handler.js' +import ResponseFactory from './response/response-factory.js' /** Modbus Client Repsonse Handler * @abstract @@ -6,6 +8,7 @@ import MBAbstractResponse from './abstract-response' export default abstract class ModbusClientResponseHandler { protected _buffer: Buffer protected abstract _messages: ResType[] + protected readonly _customHandlers = new Map() /** Create new Modbus Client Response Handler */ constructor () { @@ -23,4 +26,29 @@ export default abstract class ModbusClientResponseHandler { + const handler = this._customHandlers.get(buffer[0]) + + if (!handler) { + return ResponseFactory.fromBuffer(buffer) + } + + try { + return handler.fromBuffer(buffer) + } catch (e) { + return null + } + } } diff --git a/src/modbus-client.ts b/src/modbus-client.ts index 9208e78..01f393d 100644 --- a/src/modbus-client.ts +++ b/src/modbus-client.ts @@ -22,6 +22,7 @@ import MBClientResponseHandler from './client-response-handler.js' import { UserRequestError } from './errors' import { CastRequestBody } from './request-response-map' import { WriteMultipleCoilsResponseBody } from './response' +import IResponseBodyHandler from './response/response-body-handler.js' import { PromiseUserRequest } from './user-request.js' /** Common Modbus Client @@ -342,6 +343,24 @@ export default abstract class MBClient { + /** @param {Buffer} buffer Starts at the function code. With Modbus/RTU the returned body's + * byteCount decides how many bytes are consumed before the CRC. + * @returns {ModbusResponseBody | null} null if not enough data has arrived yet. + */ + fromBuffer (buffer: Buffer): Body | null +} diff --git a/src/rtu-client-response-handler.ts b/src/rtu-client-response-handler.ts index 71fade0..3c21c28 100644 --- a/src/rtu-client-response-handler.ts +++ b/src/rtu-client-response-handler.ts @@ -21,7 +21,7 @@ export default class ModbusRTUClientResponseHandler extends ModbusClientResponse debug('buffer', this._buffer) do { - const response = ModbusRTUResponse.fromBuffer(this._buffer) + const response = ModbusRTUResponse.fromBuffer(this._buffer, this._parseBody) if (!response) { debug('not enough data available to parse') diff --git a/src/rtu-response.ts b/src/rtu-response.ts index c903738..060f22e 100644 --- a/src/rtu-response.ts +++ b/src/rtu-response.ts @@ -49,7 +49,10 @@ export default class ModbusRTUResponse ModbusResponseBody | null = ResponseFactory.fromBuffer + ) { if (buffer.length < 1) { return null } @@ -58,7 +61,7 @@ export default class ModbusRTUResponse ModbusResponseBody | null = ResponseFactory.fromBuffer + ) { try { const id = buffer.readUInt16BE(0) const protocol = buffer.readUInt16BE(2) @@ -80,7 +83,7 @@ export default class ModbusTCPResponse { throw new Error('boom') } }) + + handler.handleData(Buffer.from([0x01, 0x03, 0x02, 0x12, 0x34, 0xcd, 0xab])) + + assert.strictEqual(undefined, handler.shift()) + }) + }) + + describe('Modbus/TCP Client Response Handler.', function () { + it('should pass exactly the PDU to the custom handler', function () { + const handler = new ModbusTCPClientResponseHandler() + let received + + handler.registerResponseHandler(0x03, { + fromBuffer (buffer) { + received = buffer + return singleRegisterHandler.fromBuffer(buffer) + } + }) + + handler.handleData(Buffer.from([ + 0x00, 0x01, // transaction id + 0x00, 0x00, // protocol + 0x00, 0x05, // length + 0x01, // unit id + 0x03, 0xff, 0x12, 0x34 // pdu with broken byte count + ])) + + assert.deepEqual(Buffer.from([0x03, 0xff, 0x12, 0x34]), received) + assert.deepEqual([0x1234], handler.shift().body.valuesAsArray) + }) + }) + + describe('Modbus Client.', function () { + let socket + + beforeEach(function () { + socket = new EventEmitter() + socket.write = function () {} + }) + + it('should resolve the request with the body of the custom handler', function (done) { + const client = new Modbus.client.TCP(socket) + client.registerResponseHandler(0x03, singleRegisterHandler) + + socket.emit('connect') + + client.readHoldingRegisters(0, 1) + .then(function (resp) { + assert.equal(1, singleRegisterHandler.calls) + assert.deepEqual([0x1234], resp.response.body.valuesAsArray) + done() + }).catch(done) + + socket.emit('data', Buffer.from([ + 0x00, 0x01, // transaction id + 0x00, 0x00, // protocol + 0x00, 0x05, // length + 0x01, // unit id + 0x03, 0xff, 0x12, 0x34 + ])) + }) + + it('should unregister a custom handler on the client', function () { + const client = new Modbus.client.RTU(new EventEmitter(), 1) + client.registerResponseHandler(0x03, singleRegisterHandler) + + assert.equal(true, client.unregisterResponseHandler(0x03)) + assert.equal(false, client.unregisterResponseHandler(0x03)) + }) + + it('should keep custom handlers per client', function (done) { + const otherClient = new Modbus.client.TCP(new EventEmitter()) + const client = new Modbus.client.TCP(socket) + otherClient.registerResponseHandler(0x03, singleRegisterHandler) + + socket.emit('connect') + + client.readHoldingRegisters(0, 1) + .then(function (resp) { + assert.equal(0, singleRegisterHandler.calls) + assert.deepEqual([0x1234], resp.response.body.valuesAsArray) + done() + }).catch(done) + + socket.emit('data', Buffer.from([ + 0x00, 0x01, 0x00, 0x00, 0x00, 0x05, 0x01, + 0x03, 0x02, 0x12, 0x34 + ])) + }) + }) +}) From fc0291c95ecc0446e926bb8e673d898f87d14072 Mon Sep 17 00:00:00 2001 From: Barneey96 Date: Tue, 29 Sep 2026 13:33:12 +0200 Subject: [PATCH 7/7] feat: add public discardBuffer method to client-response-handler --- src/client-response-handler.ts | 9 +++++++++ src/modbus-client.ts | 8 ++++++++ test/custom-response-handler.test.js | 22 ++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/src/client-response-handler.ts b/src/client-response-handler.ts index 3df84f4..5488aa3 100644 --- a/src/client-response-handler.ts +++ b/src/client-response-handler.ts @@ -27,6 +27,15 @@ export default abstract class ModbusClientResponseHandler