diff --git a/src/utils/modbus_data_tools.js b/src/utils/modbus_data_tools.js index ec77a4d..4ed8fcd 100644 --- a/src/utils/modbus_data_tools.js +++ b/src/utils/modbus_data_tools.js @@ -1,6 +1,58 @@ // @ts-check 'use strict' +/** + * Custom error for values outside valid register range + */ +class OutOfRangeError extends Error { + constructor(value, registerType, minValue, maxValue) { + super(`Value ${value} is out of range for ${registerType} (valid: ${minValue} to ${maxValue})`); + this.name = 'OutOfRangeError'; + this.value = value; + this.registerType = registerType; + this.minValue = minValue; + this.maxValue = maxValue; + } +} + +/** + * Get the valid range for a register type + * @param {string} registerType - Buffer method name without 'write'/'read' prefix (e.g., 'Int16BE', 'UInt16BE') + * @returns {{min: number, max: number}} Valid range for the register type + */ +function getRegisterRange(registerType) { + const ranges = { + 'Int8': { min: -128, max: 127 }, + 'UInt8': { min: 0, max: 255 }, + 'Int16BE': { min: -32768, max: 32767 }, + 'Int16LE': { min: -32768, max: 32767 }, + 'UInt16BE': { min: 0, max: 65535 }, + 'UInt16LE': { min: 0, max: 65535 }, + 'Int32BE': { min: -2147483648, max: 2147483647 }, + 'Int32LE': { min: -2147483648, max: 2147483647 }, + 'UInt32BE': { min: 0, max: 4294967295 }, + 'UInt32LE': { min: 0, max: 4294967295 }, + }; + return ranges[registerType] || null; +} + +/** + * Validate that a value is within the valid range for a register type + * @param {number} value - The value to validate + * @param {string} registerType - The register type (e.g., 'Int16BE', 'UInt16BE') + * @throws {OutOfRangeError} If value is outside valid range + */ +function validateRegisterRange(value, registerType) { + const range = getRegisterRange(registerType); + if (!range) { + // If register type is unknown, skip validation (trust Buffer methods to handle it) + return; + } + + if (value < range.min || value > range.max) { + throw new OutOfRangeError(value, registerType, range.min, range.max); + } +} /** * Ecriture en registre (AI-AO) @@ -19,7 +71,12 @@ function writeToRegister(entry, value, register, address) { case "integer": case "string": setValue = parseInt(value, (entry.encodeInt) ? entry.encodeInt : 10); - register['write' + (entry.register || "UInt16BE")](setValue, address); //as default, "UInt16BE" is used in Modbus + const registerType = entry.register || "Int16BE"; // Default to Int16BE (was UInt16BE but should be signed) + + // Validate value is within range for this register type + validateRegisterRange(setValue, registerType); + + register['write' + registerType](setValue, address); break; case "float": case "enum": @@ -157,5 +214,8 @@ module.exports = { getRegisterAddress, getBufferAddress, getValueFromRegistery, - CheckOffsetReadWriteProperties + CheckOffsetReadWriteProperties, + OutOfRangeError, + getRegisterRange, + validateRegisterRange } \ No newline at end of file diff --git a/test/e2e/modbus-mqtt.test.ts b/test/e2e/modbus-mqtt.test.ts index 6db587e..26c05b5 100644 --- a/test/e2e/modbus-mqtt.test.ts +++ b/test/e2e/modbus-mqtt.test.ts @@ -368,12 +368,12 @@ describe('ModbusSimulator - E2E Tests', function () { it('should have slave read updated AO value from master', async function () { this.timeout(10000); await new Promise((resolve, reject) => { - mqttClient.publish('homie/E2E_MASTER/R1-AO/AO-01/set', '54321', {}, (err) => err ? reject(err) : resolve()); + mqttClient.publish('homie/E2E_MASTER/R1-AO/AO-01/set', '12345', {}, (err) => err ? reject(err) : resolve()); }); const slaveMsg = await retrieveMessageAsync(m => m.topic === 'homie/E2E_SLAVE/AO/AO-01'); expect(slaveMsg).to.exist; - expect(slaveMsg!.message).to.equal('54321'); + expect(slaveMsg!.message).to.equal('12345'); }); it('should have master write to slave coil', async function () { @@ -451,4 +451,42 @@ describe('ModbusSimulator - E2E Tests', function () { } }); }); + + describe('OutOfRange Value Validation', () => { + it('should handle out-of-range value gracefully without crashing', async function () { + this.timeout(15000); + + // Attempt to write value 65536 to a 16-bit register (valid range: 0-65535) + // This should trigger error handling in master config or be rejected + messageBuffer = []; + await new Promise((resolve, reject) => { + mqttClient.publish('homie/E2E_MASTER/R1-AO/AO-00/set', '65536', {}, (err) => err ? reject(err) : resolve()); + }); + + // Wait briefly to see if master crashes or logs error + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Master should still be connected (not crashed) + expect(mqttClient.connected).to.be.true; + + // If error handling is implemented, we should see an error message or state change + // Otherwise, just verify the process didn't crash + }); + + it('should handle negative value for unsigned register gracefully', async function () { + this.timeout(15000); + + // Attempt to write negative value to unsigned 16-bit register + messageBuffer = []; + await new Promise((resolve, reject) => { + mqttClient.publish('homie/E2E_MASTER/R1-AO/AO-01/set', '-1', {}, (err) => err ? reject(err) : resolve()); + }); + + // Wait briefly + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Master should still be connected + expect(mqttClient.connected).to.be.true; + }); + }); }); diff --git a/test/unit/modbus_data_tools.test.ts b/test/unit/modbus_data_tools.test.ts new file mode 100644 index 0000000..9f8cb1e --- /dev/null +++ b/test/unit/modbus_data_tools.test.ts @@ -0,0 +1,182 @@ +import { expect } from 'chai'; +import * as util from '../../src/utils/modbus_data_tools.js'; + +describe('Data Tools', () => { + describe('writeToRegister - Range Validation', () => { + /** + * Test that writeToRegister validates input values against register type bounds + * and throws OutOfRangeError for values outside valid ranges + */ + + describe('Int16BE (signed 16-bit big-endian)', () => { + const entry = { type: 'integer', register: 'Int16BE', offset: undefined }; + const validBuffer = Buffer.alloc(4); + + it('should write valid positive value (32767)', () => { + expect(() => { + util.writeToRegister(entry, 32767, validBuffer, 0); + }).to.not.throw(); + expect(validBuffer.readInt16BE(0)).to.equal(32767); + }); + + it('should write valid negative value (-32768)', () => { + expect(() => { + util.writeToRegister(entry, -32768, validBuffer, 0); + }).to.not.throw(); + expect(validBuffer.readInt16BE(0)).to.equal(-32768); + }); + + it('should write zero', () => { + expect(() => { + util.writeToRegister(entry, 0, validBuffer, 0); + }).to.not.throw(); + expect(validBuffer.readInt16BE(0)).to.equal(0); + }); + + it('should throw OutOfRangeError for value > 32767', () => { + expect(() => { + util.writeToRegister(entry, 32768, validBuffer, 0); + }).to.throw(/OutOfRangeError|out of range|Out of range/i); + }); + + it('should throw OutOfRangeError for value < -32768', () => { + expect(() => { + util.writeToRegister(entry, -32769, validBuffer, 0); + }).to.throw(/OutOfRangeError|out of range|Out of range/i); + }); + + it('should throw OutOfRangeError for 54321 (example from issue)', () => { + expect(() => { + util.writeToRegister(entry, 54321, validBuffer, 0); + }).to.throw(/OutOfRangeError|out of range|Out of range/i); + }); + }); + + describe('Int16LE (signed 16-bit little-endian)', () => { + const entry = { type: 'integer', register: 'Int16LE', offset: undefined }; + const validBuffer = Buffer.alloc(4); + + it('should write valid positive value (32767)', () => { + expect(() => { + util.writeToRegister(entry, 32767, validBuffer, 0); + }).to.not.throw(); + expect(validBuffer.readInt16LE(0)).to.equal(32767); + }); + + it('should throw OutOfRangeError for value > 32767', () => { + expect(() => { + util.writeToRegister(entry, 32768, validBuffer, 0); + }).to.throw(/OutOfRangeError|out of range|Out of range/i); + }); + }); + + describe('UInt16BE (unsigned 16-bit big-endian)', () => { + const entry = { type: 'integer', register: 'UInt16BE', offset: undefined }; + const validBuffer = Buffer.alloc(4); + + it('should write valid max unsigned value (65535)', () => { + expect(() => { + util.writeToRegister(entry, 65535, validBuffer, 0); + }).to.not.throw(); + expect(validBuffer.readUInt16BE(0)).to.equal(65535); + }); + + it('should write zero', () => { + expect(() => { + util.writeToRegister(entry, 0, validBuffer, 0); + }).to.not.throw(); + expect(validBuffer.readUInt16BE(0)).to.equal(0); + }); + + it('should throw OutOfRangeError for value > 65535', () => { + expect(() => { + util.writeToRegister(entry, 65536, validBuffer, 0); + }).to.throw(/OutOfRangeError|out of range|Out of range/i); + }); + + it('should throw OutOfRangeError for negative value', () => { + expect(() => { + util.writeToRegister(entry, -1, validBuffer, 0); + }).to.throw(/OutOfRangeError|out of range|Out of range/i); + }); + + it('should throw OutOfRangeError for 54321 (example from issue)', () => { + expect(() => { + util.writeToRegister(entry, 54321, validBuffer, 0); + }).to.not.throw(); // 54321 is actually valid for UInt16BE (< 65535) + expect(validBuffer.readUInt16BE(0)).to.equal(54321); + }); + }); + + describe('UInt16LE (unsigned 16-bit little-endian)', () => { + const entry = { type: 'integer', register: 'UInt16LE', offset: undefined }; + const validBuffer = Buffer.alloc(4); + + it('should write valid max unsigned value (65535)', () => { + expect(() => { + util.writeToRegister(entry, 65535, validBuffer, 0); + }).to.not.throw(); + expect(validBuffer.readUInt16LE(0)).to.equal(65535); + }); + + it('should throw OutOfRangeError for value > 65535', () => { + expect(() => { + util.writeToRegister(entry, 65536, validBuffer, 0); + }).to.throw(/OutOfRangeError|out of range|Out of range/i); + }); + + it('should throw OutOfRangeError for negative value', () => { + expect(() => { + util.writeToRegister(entry, -1, validBuffer, 0); + }).to.throw(/OutOfRangeError|out of range|Out of range/i); + }); + }); + + describe('Default (Int16BE when register not specified)', () => { + const entry = { type: 'integer', register: undefined, offset: undefined }; + const validBuffer = Buffer.alloc(4); + + it('should use Int16BE as default', () => { + expect(() => { + util.writeToRegister(entry, 12345, validBuffer, 0); + }).to.not.throw(); + expect(validBuffer.readInt16BE(0)).to.equal(12345); + }); + + it('should apply Int16BE range to default', () => { + expect(() => { + util.writeToRegister(entry, 32768, validBuffer, 0); + }).to.throw(/OutOfRangeError|out of range|Out of range/i); + }); + }); + + describe('Boolean type (should not apply numeric bounds)', () => { + const entry = { type: 'boolean', offset: 0 }; + const validBuffer = Buffer.alloc(4); + + it('should accept boolean true', () => { + expect(() => { + util.writeToRegister(entry, true, validBuffer, 0); + }).to.not.throw(); + }); + + it('should accept boolean false', () => { + expect(() => { + util.writeToRegister(entry, false, validBuffer, 0); + }).to.not.throw(); + }); + + it('should accept truthy values', () => { + expect(() => { + util.writeToRegister(entry, 1, validBuffer, 0); + }).to.not.throw(); + }); + + it('should accept falsy values', () => { + expect(() => { + util.writeToRegister(entry, 0, validBuffer, 0); + }).to.not.throw(); + }); + }); + }); +});