UNPKG

modbus-connect

Version:

Modbus RTU over Web Serial and Node.js SerialPort

610 lines (500 loc) 24.2 kB
// 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(' '); } _getExpectedResponseLength(pdu) { if (!pdu || pdu.length === 0) return null; const funcCode = pdu[0]; switch(funcCode) { // Стандартные Modbus функции case 0x01: // Read Coils case 0x02: // Read Discrete Inputs if (pdu.length < 5) return null; const bitCount = (pdu[3] << 8) | pdu[4]; return 5 + Math.ceil(bitCount / 8); // slave(1) + func(1) + byteCount(1) + data(N) + CRC(2) case 0x03: // Read Holding Registers case 0x04: // Read Input Registers if (pdu.length < 5) return null; const regCount = (pdu[3] << 8) | pdu[4]; return 5 + regCount * 2; // slave(1) + func(1) + byteCount(1) + data(N*2) + CRC(2) case 0x05: // Write Single Coil case 0x06: // Write Single Register return 8; // slave(1) + func(1) + address(2) + value(2) + CRC(2) case 0x0F: // Write Multiple Coils case 0x10: // Write Multiple Registers return 8; // slave(1) + func(1) + address(2) + quantity(2) + CRC(2) case 0x08: // Diagnostics return 8; // slave(1) + func(1) + subFunc(2) + data(2) + CRC(2) // Специальные функции устройства case 0x14: // Read Device Comment if (pdu.length < 2) return null; return 5 + (pdu[1] & 0xFF); // slave(1) + func(1) + channel(1) + length(1) + data(N) + CRC(2) case 0x15: // Write Device Comment return 5; // slave(1) + func(1) + channel(1) + length(1) + CRC(2) case 0x2B: // Read Device Identification if (pdu.length < 4) return null; // Базовый заголовок ответа let baseLength = 9; // slave(1) + func(1) + interface(1) + category(1) + // respCategory(1) + contFlag(1) + nextStr(1) + strCount(1) + CRC(2) // Для ошибки if (pdu[2] === 0x00) return 6; // slave(1) + func(1) + interface(1) + error(1) + CRC(2) // Для индивидуального запроса (категория 0x04) if (pdu[2] === 0x04) { return null; // Длина строки неизвестна заранее } // Для основных/вспомогательных категорий return null; // Количество строк и их длины неизвестны заранее case 0x52: // Read File Length return 8; // slave(1) + func(1) + length(4) + CRC(2) case 0x55: // Open File return 8; // slave(1) + func(1) + length(4) + CRC(2) case 0x57: // Close File return 5; // slave(1) + func(1) + status(1) + CRC(2) case 0x5A: // Read File Chunk if (pdu.length < 3) return null; return 5 + (pdu[2] & 0xFF); // slave(1) + func(1) + chunkSize(1) + data(N) + CRC(2) case 0x5C: // Restart Controller return 0; // Ответа не ожидается case 0x6E: // Get Controller Time return 10; // slave(1) + func(1) + time(6) + CRC(2) case 0x6F: // Set Controller Time return 8; // slave(1) + func(1) + status(2) + CRC(2) // Обработка ошибок default: if (funcCode & 0x80) { // Error response return 5; // slave(1) + func(1) + errorCode(1) + CRC(2) } return null; } } async _readPacket(timeout, requestPdu = null) { const start = Date.now(); let buffer = new Uint8Array(0); let expectedLength = requestPdu ? this._getExpectedResponseLength(requestPdu) : null; while (true) { const timeLeft = timeout - (Date.now() - start); if (timeLeft <= 0) throw new ModbusTimeoutError('Read timeout'); // Читаем либо оставшиеся байты, либо 1 байт если длина неизвестна const bytesToRead = expectedLength ? expectedLength - buffer.length : 1; const chunk = await this.transport.read(bytesToRead, timeLeft); if (!chunk || chunk.length === 0) continue; buffer = concatUint8Arrays([buffer, chunk]); logger.debug('Received chunk:', { bytes: chunk.length, total: buffer.length }); try { // const parsed = parsePacket(buffer, this.crcFunc); // Если parsePacket успешен - возвращаем пакет return buffer; } catch (err) { if (err.message === 'Invalid packet' || err.message.startsWith('CRC mismatch')) { // Если мы знаем ожидаемую длину и буфер уже достаточного размера - это ошибка if (expectedLength && buffer.length >= expectedLength) { throw new Error('Invalid packet format'); } continue; // Продолжаем чтение } 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, pdu); 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