modbus-connect
Version:
Modbus RTU over Web Serial and Node.js SerialPort
541 lines (447 loc) • 21.3 kB
JavaScript
// client.js
// Registers
const { buildReadHoldingRegistersRequest, parseReadHoldingRegistersResponse } = require('./function-codes/read-holding-registers.js');
const { buildReadInputRegistersRequest, parseReadInputRegistersResponse } = require('./function-codes/read-input-registers.js');
const { buildWriteSingleRegisterRequest, parseWriteSingleRegisterResponse } = require('./function-codes/write-single-register.js');
const { buildWriteMultipleRegistersRequest, parseWriteMultipleRegistersResponse } = require('./function-codes/write-multiple-registers.js');
// Bit operations
const { buildReadCoilsRequest, parseReadCoilsResponse } = require('./function-codes/read-coils.js');
const { buildReadDiscreteInputsRequest, parseReadDiscreteInputsResponse } = require('./function-codes/read-discrete-inputs.js');
const { buildWriteSingleCoilRequest, parseWriteSingleCoilResponse } = require('./function-codes/write-single-coil.js');
const { buildWriteMultipleCoilsRequest, parseWriteMultipleCoilsResponse } = require('./function-codes/write-multiple-coils.js');
// Special functions
const { buildReportSlaveIdRequest, parseReportSlaveIdResponse } = require('./function-codes/report-slave-id.js');
const { buildReadDeviceIdentificationRequest, parseReadDeviceIdentificationResponse } = require('./function-codes/read-device-identification.js');
// Special functions for SGM130
const { buildReadDeviceCommentRequest, parseReadDeviceCommentResponse } = require('./function-codes/SGM130/read-device-comment.js');
const { buildWriteDeviceCommentRequest, parseWriteDeviceCommentResponse } = require('./function-codes/SGM130/write-device-comment.js');
const { buildReadFileLengthRequest, parseReadFileLengthResponse } = require('./function-codes/SGM130/read-file-length.js');
const { buildOpenFileRequest, parseOpenFileResponse } = require('./function-codes/SGM130/openFile.js');
const { buildCloseFileRequest, parseCloseFileResponse } = require('./function-codes/SGM130/closeFile.js');
const { buildRestartControllerRequest, parseRestartControllerResponse } = require('./function-codes/SGM130/restart-controller.js');
const { buildGetControllerTimeRequest, parseGetControllerTimeResponse } = require('./function-codes/SGM130/get-controller-time.js');
const { buildSetControllerTimeRequest, parseSetControllerTimeResponse } = require('./function-codes/SGM130/set-controller-time.js');
const {
ModbusTimeoutError,
ModbusCRCError,
ModbusResponseError,
ModbusTooManyEmptyReadsError,
ModbusExceptionError
} = require('./errors.js');
const { buildPacket, parsePacket } = require('./packet-builder.js');
const { concatUint8Arrays, allocUint8Array, toHex } = require('./utils/utils.js');
const logger = require('./logger.js');
const { Diagnostics } = require('./utils/diagnostics.js');
const crcFns = require('./utils/crc.js');
const crcAlgorithmMap = {
crc16Modbus: crcFns.crc16Modbus,
crc16CcittFalse: crcFns.crc16CcittFalse,
crc32: crcFns.crc32,
crc8: crcFns.crc8,
crc1: crcFns.crc1,
crc8_1wire: crcFns.crc8_1wire,
crc8_dvbs2: crcFns.crc8_dvbs2,
crc16_kermit: crcFns.crc16_kermit,
crc16_xmodem: crcFns.crc16_xmodem,
crc24: crcFns.crc24,
crc32mpeg: crcFns.crc32mpeg,
crcjam: crcFns.crcjam
};
class ModbusClient {
constructor(transport, slaveId = 1, options = {}) {
this.transport = transport;
this.slaveId = slaveId;
this.defaultTimeout = options.timeout || 2000;
this.retryCount = options.retryCount || 0;
this.retryDelay = options.retryDelay || 100;
this.echoEnabled = options.echoEnabled || false;
this.diagnostics = new Diagnostics();
this.crcFunc = crcAlgorithmMap[options.crcAlgorithm || 'crc16Modbus'];
if (!this.crcFunc) throw new Error(`Unknown CRC algorithm: ${options.crcAlgorithm}`);
}
async connect() {
await this.transport.connect();
logger.info('Transport connected', { transport: this.transport.constructor.name });
}
async disconnect() {
await this.transport.disconnect();
logger.info('Transport disconnected', { transport: this.transport.constructor.name });
}
_toHex(buffer) {
return Array.from(buffer)
.map(b => b.toString(16).padStart(2, '0'))
.join(' ');
}
async _readPacket(timeout) {
const start = Date.now();
let buffer = new Uint8Array(0);
while (true) {
const timeLeft = timeout - (Date.now() - start);
if (timeLeft <= 0) {
logger.error('Read timeout exceeded');
throw new ModbusTimeoutError('Read timeout');
}
const chunk = await this.transport.read(1, timeLeft);
if (!chunk || chunk.length === 0) {
logger.warn('Empty chunk received during read');
continue;
}
buffer = concatUint8Arrays([buffer, chunk]);
logger.debug('Received chunk:', { bytes: chunk.length, total: buffer.length, hex: this._toHex(chunk) });
try {
// Попытка распарсить первый полный пакет из буфера
const parsed = parsePacket(buffer, this.crcFunc);
// parsed.packetLength — длина пакета, который parsePacket выделил (надо добавить)
// Предполагаю, что parsePacket вернет и длину, либо добавь такую логику
// Если parsePacket не возвращает длину, нужно добавить в неё
const packetLength = parsed.packetLength || buffer.length;
// Отрезаем пакет от буфера
const packet = buffer.slice(0, packetLength);
// Оставляем остаток буфера для следующего вызова (_readPacket)
// Можно сохранить в поле объекта клиента, чтобы потом использовать
buffer = buffer.slice(packetLength);
logger.debug('Parsed response packet:', { slave: parsed.slaveAddress, funcCode: parsed.pdu[0], hex: this._toHex(packet) });
return packet;
} catch (err) {
if (err.message === 'Invalid packet' || err.message.startsWith('CRC mismatch')) {
logger.debug('Packet not complete or CRC error, continuing read...', { error: err.message });
continue;
} else {
logger.error('Error parsing packet', { error: err.message, buffer: this._toHex(buffer) });
throw err;
}
}
}
}
async _sendRequest(pdu, timeout = this.defaultTimeout, ignoreNoResponse = false) {
this.diagnostics.recordRequest();
let lastError;
const startTime = Date.now();
for (let attempt = 0; attempt <= this.retryCount; attempt++) {
try {
const attemptStart = Date.now();
const timeLeft = timeout - (attemptStart - startTime);
if (timeLeft <= 0) throw new ModbusTimeoutError('Timeout before request');
logger.debug(`Attempt #${attempt + 1} — sending request`, {
slaveId: this.slaveId,
funcCode: pdu[0],
hex: this._toHex(pdu)
});
const packet = buildPacket(this.slaveId, pdu, this.crcFunc);
logger.debug('Full request packet:', { hex: this._toHex(packet) });
// Записываем пакет в транспорт
await this.transport.write(packet);
logger.debug('Packet written to transport', { bytes: packet.length });
// --- ЭХО ---
if (this.echoEnabled) {
logger.debug('Echo enabled, reading echo back...');
const echoResponse = await this.transport.read(packet.length, timeLeft);
if (!echoResponse || echoResponse.length !== packet.length) {
throw new Error(`Echo length mismatch (expected ${packet.length}, got ${echoResponse ? echoResponse.length : 0})`);
}
for (let i = 0; i < packet.length; i++) {
if (packet[i] !== echoResponse[i]) {
throw new Error('Echo mismatch detected');
}
}
logger.debug('Echo verified successfully');
}
// --- /ЭХО ---
if (ignoreNoResponse) {
// Не ждём ответа, считаем, что запрос успешно отправлен
this.diagnostics.recordSuccess(Date.now() - startTime);
logger.info('Request sent, no response expected', {
slaveId: this.slaveId,
funcCode: pdu[0],
hex: this._toHex(pdu)
});
return;
}
// Обычное чтение ответа
const response = await this._readPacket(timeLeft);
const elapsed = Date.now() - startTime;
this.diagnostics.recordSuccess(elapsed);
const { slaveAddress, pdu: responsePdu } = parsePacket(response, this.crcFunc);
const funcCode = responsePdu[0];
if ((funcCode & 0x80) !== 0) {
const errorCode = responsePdu[1];
throw new ModbusExceptionError(funcCode & 0x7F, errorCode);
}
logger.info('Response received', {
slaveAddress,
funcCode,
responseTime: elapsed,
hex: this._toHex(responsePdu)
});
return responsePdu;
} catch (err) {
const elapsed = Date.now() - startTime;
this.diagnostics.recordError(err, { responseTimeMs: elapsed });
logger.warn(`Attempt #${attempt + 1} failed: ${err.message}`, {
responseTime: elapsed,
error: err,
requestHex: this._toHex(pdu)
});
lastError = err;
if (ignoreNoResponse && err.message.toLowerCase().includes('timeout')) {
logger.info('Timeout ignored due to ignoreNoResponse=true');
return;
}
if (attempt < this.retryCount) {
logger.debug(`Retrying after delay ${this.retryDelay}ms`);
await new Promise(resolve => setTimeout(resolve, this.retryDelay));
} else {
logger.error(`All ${this.retryCount + 1} attempts exhausted`, { error: lastError });
throw lastError;
}
}
}
}
_convertRegisters(registers, type = 'uint16') {
const buffer = new ArrayBuffer(registers.length * 2);
const view = new DataView(buffer);
// Big endian запись (Modbus по умолчанию)
registers.forEach((reg, i) => {
view.setUint16(i * 2, reg, false);
});
const read32 = (method, littleEndian = false) => {
const result = [];
for (let i = 0; i < registers.length - 1; i += 2) {
result.push(view[method](i * 2, littleEndian));
}
return result;
};
const read64 = (method, littleEndian = false) => {
const result = [];
for (let i = 0; i < registers.length - 3; i += 4) {
const tempBuf = new ArrayBuffer(8);
const tempView = new DataView(tempBuf);
for (let j = 0; j < 8; j++) {
tempView.setUint8(j, view.getUint8(i * 2 + j));
}
switch (method) {
case 'uint64': {
const high = BigInt(tempView.getUint32(0, littleEndian));
const low = BigInt(tempView.getUint32(4, littleEndian));
result.push((high << 32n) | low);
break;
}
case 'int64': {
const high = BigInt(tempView.getUint32(0, littleEndian));
const low = BigInt(tempView.getUint32(4, littleEndian));
let value = (high << 32n) | low;
if (value & (1n << 63n)) value -= 1n << 64n;
result.push(value);
break;
}
case 'double': {
result.push(tempView.getFloat64(0, littleEndian));
break;
}
}
}
return result;
};
const getSwapped32 = (i, mode) => {
const a = view.getUint8(i * 2);
const b = view.getUint8(i * 2 + 1);
const c = view.getUint8(i * 2 + 2);
const d = view.getUint8(i * 2 + 3);
let bytes;
switch (mode) {
case 'sw': bytes = [c, d, a, b]; break;
case 'sb': bytes = [b, a, d, c]; break;
case 'sbw': bytes = [d, c, b, a]; break;
case 'le': bytes = [d, c, b, a]; break; // LE = full byte reverse
case 'le_sw': bytes = [b, a, d, c]; break;
case 'le_sb': bytes = [a, b, c, d]; break;
case 'le_sbw': bytes = [c, d, a, b]; break;
default: bytes = [a, b, c, d]; break;
}
const tempBuf = new ArrayBuffer(4);
const tempView = new DataView(tempBuf);
bytes.forEach((byte, idx) => tempView.setUint8(idx, byte));
return tempView;
};
const read32Swapped = (method, mode) => {
const result = [];
for (let i = 0; i < registers.length - 1; i += 2) {
const tempView = getSwapped32(i, mode);
result.push(tempView[method](0, false));
}
return result;
};
switch (type.toLowerCase()) {
// 16-бит
case 'uint16': return registers;
case 'int16': return registers.map((_, i) => view.getInt16(i * 2, false));
// 32-бит
case 'uint32': return read32('getUint32');
case 'int32': return read32('getInt32');
case 'float': return read32('getFloat32');
// 32-бит LE
case 'uint32_le': return read32('getUint32', true);
case 'int32_le': return read32('getInt32', true);
case 'float_le': return read32('getFloat32', true);
// 32-бит со свопами
case 'uint32_sw': return read32Swapped('getUint32', 'sw');
case 'int32_sw': return read32Swapped('getInt32', 'sw');
case 'float_sw': return read32Swapped('getFloat32', 'sw');
case 'uint32_sb': return read32Swapped('getUint32', 'sb');
case 'int32_sb': return read32Swapped('getInt32', 'sb');
case 'float_sb': return read32Swapped('getFloat32', 'sb');
case 'uint32_sbw': return read32Swapped('getUint32', 'sbw');
case 'int32_sbw': return read32Swapped('getInt32', 'sbw');
case 'float_sbw': return read32Swapped('getFloat32', 'sbw');
// 32-бит little-endian через полные свопы
case 'uint32_le_sw': return read32Swapped('getUint32', 'le_sw');
case 'int32_le_sw': return read32Swapped('getInt32', 'le_sw');
case 'float_le_sw': return read32Swapped('getFloat32', 'le_sw');
case 'uint32_le_sb': return read32Swapped('getUint32', 'le_sb');
case 'int32_le_sb': return read32Swapped('getInt32', 'le_sb');
case 'float_le_sb': return read32Swapped('getFloat32', 'le_sb');
case 'uint32_le_sbw': return read32Swapped('getUint32', 'le_sbw');
case 'int32_le_sbw': return read32Swapped('getInt32', 'le_sbw');
case 'float_le_sbw': return read32Swapped('getFloat32', 'le_sbw');
// 64-бит
case 'uint64': return read64('uint64');
case 'int64': return read64('int64');
case 'double': return read64('double');
// 64-бит LE
case 'uint64_le': return read64('uint64', true);
case 'int64_le': return read64('int64', true);
case 'double_le': return read64('double', true);
// Разное
case 'hex':
return registers.map(r => r.toString(16).toUpperCase().padStart(4, '0'));
case 'string': {
let str = '';
for (let i = 0; i < registers.length; i++) {
const high = (registers[i] >> 8) & 0xFF;
const low = registers[i] & 0xFF;
if (high !== 0) str += String.fromCharCode(high);
if (low !== 0) str += String.fromCharCode(low);
}
return str;
}
case 'bool':
return registers.map(r => r !== 0);
case 'binary':
return registers.map(r =>
r.toString(2).padStart(16, '0').split('').map(b => b === '1')
);
case 'bcd':
return registers.map(r => {
const high = ((r >> 8) & 0xFF);
const low = (r & 0xFF);
return (
((high >> 4) * 10 + (high & 0x0F)) * 100 +
(low >> 4) * 10 + (low & 0x0F)
);
});
default:
throw new Error(`Unsupported type: ${type}`);
}
}
// --- Публичные методы Modbus ---
async readHoldingRegisters(startAddress, quantity, options = {}) {
const pdu = buildReadHoldingRegistersRequest(startAddress, quantity)
const responsePdu = await this._sendRequest(pdu);
const registers = parseReadHoldingRegistersResponse(responsePdu);
const type = options.type || 'uint16'
return this._convertRegisters(registers, type)
}
async readInputRegisters(startAddress, quantity, options = {}) {
const responsePdu = await this._sendRequest(buildReadInputRegistersRequest(startAddress, quantity))
const registers = parseReadInputRegistersResponse(responsePdu)
const type = options.type || 'uint16'
return this._convertRegisters(registers, type)
}
async writeSingleRegister(address, value, timeout) {
const pdu = buildWriteSingleRegisterRequest(address, value);
const responsePdu = await this._sendRequest(pdu, timeout);
return parseWriteSingleRegisterResponse(responsePdu);
}
async writeMultipleRegisters(startAddress, values, timeout) {
const pdu = buildWriteMultipleRegistersRequest(startAddress, values);
const responsePdu = await this._sendRequest(pdu, timeout);
return parseWriteMultipleRegistersResponse(responsePdu);
}
async readCoils(startAddress, quantity, timeout) {
const pdu = buildReadCoilsRequest(startAddress, quantity);
const responsePdu = await this._sendRequest(pdu, timeout);
return parseReadCoilsResponse(responsePdu);
}
async readDiscreteInputs(startAddress, quantity, timeout) {
const pdu = buildReadDiscreteInputsRequest(startAddress, quantity);
const responsePdu = await this._sendRequest(pdu, timeout);
return parseReadDiscreteInputsResponse(responsePdu);
}
async writeSingleCoil(address, value, timeout) {
const pdu = buildWriteSingleCoilRequest(address, value);
const responsePdu = await this._sendRequest(pdu, timeout);
return parseWriteSingleCoilResponse(responsePdu);
}
async writeMultipleCoils(startAddress, values, timeout) {
const pdu = buildWriteMultipleCoilsRequest(startAddress, values);
const responsePdu = await this._sendRequest(pdu, timeout);
return parseWriteMultipleCoilsResponse(responsePdu);
}
async reportSlaveId(timeout) {
const pdu = buildReportSlaveIdRequest();
const responsePdu = await this._sendRequest(pdu, timeout);
return parseReportSlaveIdResponse(responsePdu);
}
async readDeviceIdentification(timeout) {
// Сохраняем текущий slaveId
const originalSlaveId = this.slaveId;
try {
const pdu = buildReadDeviceIdentificationRequest();
const responsePdu = await this._sendRequest(pdu, timeout);
return parseReadDeviceIdentificationResponse(responsePdu);
} finally {
this.slaveId = originalSlaveId;
}
}
async readDeviceComment(channel, timeout) {
const pdu = buildReadDeviceCommentRequest(channel);
const responsePdu = await this._sendRequest(pdu, timeout);
return parseReadDeviceCommentResponse(responsePdu);
}
async writeDeviceComment(comment, timeout) {
const pdu = buildWriteDeviceCommentRequest(comment);
const responsePdu = await this._sendRequest(pdu, timeout);
return parseWriteDeviceCommentResponse(responsePdu);
}
async readFileLength(timeout) {
const pdu = buildReadFileLengthRequest();
const responsePdu = await this._sendRequest(pdu, timeout);
return parseReadFileLengthResponse(responsePdu);
}
async openFile(filename, timeout) {
const pdu = buildOpenFileRequest(filename);
const responsePdu = await this._sendRequest(pdu, timeout);
return parseOpenFileResponse(responsePdu);
}
async closeFile(timeout) {
const pdu = buildCloseFileRequest();
const responsePdu = await this._sendRequest(pdu, timeout);
return parseCloseFileResponse(responsePdu);
}
async restartController(timeout) {
const pdu = buildRestartControllerRequest();
const responsePdu = await this._sendRequest(pdu, timeout);
return parseRestartControllerResponse(responsePdu);
}
async getControllerTime(timeout) {
const pdu = buildGetControllerTimeRequest();
const responsePdu = await this._sendRequest(pdu, timeout);
return parseGetControllerTimeResponse(responsePdu);
}
async setControllerTime(datetime, timeout) {
const pdu = buildSetControllerTimeRequest(datetime);
const responsePdu = await this._sendRequest(pdu, timeout);
return parseSetControllerTimeResponse(responsePdu);
}
}
module.exports = ModbusClient