UNPKG

modbus-connect

Version:

Modbus RTU over Web Serial and Node.js SerialPort

541 lines (447 loc) 21.3 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(' '); } 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