Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions src/utils/modbus_data_tools.js
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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":
Expand Down Expand Up @@ -157,5 +214,8 @@ module.exports = {
getRegisterAddress,
getBufferAddress,
getValueFromRegistery,
CheckOffsetReadWriteProperties
CheckOffsetReadWriteProperties,
OutOfRangeError,
getRegisterRange,
validateRegisterRange
}
42 changes: 40 additions & 2 deletions test/e2e/modbus-mqtt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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 () {
Expand Down Expand Up @@ -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<void>((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<void>((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;
});
});
});
182 changes: 182 additions & 0 deletions test/unit/modbus_data_tools.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
});