UNPKG

modbus-connect

Version:

Modbus RTU over Web Serial and Node.js SerialPort

650 lines (590 loc) 23.7 kB
// src/transport/node-transports/node-serialport.ts import { SerialPort } from 'serialport'; import { Mutex } from 'async-mutex'; import { concatUint8Arrays, sliceUint8Array, allocUint8Array } from '../../utils/utils.js'; import Logger from '../../logger.js'; import { ModbusFlushError, NodeSerialTransportError, NodeSerialConnectionError, NodeSerialReadError, NodeSerialWriteError, ModbusTimeoutError, ModbusTooManyEmptyReadsError, ModbusCRCError, ModbusParityError, ModbusNoiseError, ModbusFramingError, ModbusOverrunError, ModbusCollisionError, ModbusConfigError, ModbusBaudRateError, ModbusSyncError, ModbusFrameBoundaryError, ModbusLRCError, ModbusChecksumError, ModbusDataConversionError, ModbusBufferOverflowError, ModbusBufferUnderrunError, ModbusMemoryError, ModbusStackOverflowError, ModbusResponseError, ModbusInvalidAddressError, ModbusInvalidFunctionCodeError, ModbusInvalidQuantityError, ModbusIllegalDataAddressError, ModbusIllegalDataValueError, ModbusSlaveBusyError, ModbusAcknowledgeError, ModbusSlaveDeviceFailureError, ModbusMalformedFrameError, ModbusInvalidFrameLengthError, ModbusInvalidTransactionIdError, ModbusUnexpectedFunctionCodeError, ModbusConnectionRefusedError, ModbusConnectionTimeoutError, ModbusNotConnectedError, ModbusAlreadyConnectedError, ModbusInsufficientDataError, ModbusGatewayPathUnavailableError, ModbusGatewayTargetDeviceError, ModbusInvalidStartingAddressError, ModbusMemoryParityError, ModbusBroadcastError, ModbusGatewayBusyError, ModbusDataOverrunError, ModbusInterFrameTimeoutError, ModbusSilentIntervalError, } from '../../errors.js'; import { Transport, NodeSerialTransportOptions, DeviceStateHandler, ConnectionErrorType, PortStateHandler, RSMode, } from '../../types/modbus-types.js'; // ========== CONSTANTS ========== const NODE_SERIAL_CONSTANTS = { MIN_BAUD_RATE: 300, MAX_BAUD_RATE: 115200, DEFAULT_MAX_BUFFER_SIZE: 4096, POLL_INTERVAL_MS: 10, } as const; // ========== LOGGER ========== const loggerInstance = new Logger(); loggerInstance.setLogFormat(['timestamp', 'level', 'logger']); loggerInstance.setCustomFormatter('logger', (value: unknown) => { return typeof value === 'string' ? `[${value}]` : ''; }); const logger = loggerInstance.createLogger('NodeSerialTransport'); logger.setLevel('info'); // ========== ERROR HANDLERS ========== const ERROR_HANDLERS: Record<string, () => void> = { [ModbusTimeoutError.name]: () => logger.error('Timeout error detected'), [ModbusCRCError.name]: () => logger.error('CRC error detected'), [ModbusParityError.name]: () => logger.error('Parity error detected'), [ModbusNoiseError.name]: () => logger.error('Noise error detected'), [ModbusFramingError.name]: () => logger.error('Framing error detected'), [ModbusOverrunError.name]: () => logger.error('Overrun error detected'), [ModbusCollisionError.name]: () => logger.error('Collision error detected'), [ModbusConfigError.name]: () => logger.error('Configuration error detected'), [ModbusBaudRateError.name]: () => logger.error('Baud rate error detected'), [ModbusSyncError.name]: () => logger.error('Sync error detected'), [ModbusFrameBoundaryError.name]: () => logger.error('Frame boundary error detected'), [ModbusLRCError.name]: () => logger.error('LRC error detected'), [ModbusChecksumError.name]: () => logger.error('Checksum error detected'), [ModbusDataConversionError.name]: () => logger.error('Data conversion error detected'), [ModbusBufferOverflowError.name]: () => logger.error('Buffer overflow error detected'), [ModbusBufferUnderrunError.name]: () => logger.error('Buffer underrun error detected'), [ModbusMemoryError.name]: () => logger.error('Memory error detected'), [ModbusStackOverflowError.name]: () => logger.error('Stack overflow error detected'), [ModbusResponseError.name]: () => logger.error('Response error detected'), [ModbusInvalidAddressError.name]: () => logger.error('Invalid address error detected'), [ModbusInvalidFunctionCodeError.name]: () => logger.error('Invalid function code error detected'), [ModbusInvalidQuantityError.name]: () => logger.error('Invalid quantity error detected'), [ModbusIllegalDataAddressError.name]: () => logger.error('Illegal data address error detected'), [ModbusIllegalDataValueError.name]: () => logger.error('Illegal data value error detected'), [ModbusSlaveBusyError.name]: () => logger.error('Slave busy error detected'), [ModbusAcknowledgeError.name]: () => logger.error('Acknowledge error detected'), [ModbusSlaveDeviceFailureError.name]: () => logger.error('Slave device failure error detected'), [ModbusMalformedFrameError.name]: () => logger.error('Malformed frame error detected'), [ModbusInvalidFrameLengthError.name]: () => logger.error('Invalid frame length error detected'), [ModbusInvalidTransactionIdError.name]: () => logger.error('Invalid transaction ID error detected'), [ModbusUnexpectedFunctionCodeError.name]: () => logger.error('Unexpected function code error detected'), [ModbusConnectionRefusedError.name]: () => logger.error('Connection refused error detected'), [ModbusConnectionTimeoutError.name]: () => logger.error('Connection timeout error detected'), [ModbusNotConnectedError.name]: () => logger.error('Not connected error detected'), [ModbusAlreadyConnectedError.name]: () => logger.error('Already connected error detected'), [ModbusInsufficientDataError.name]: () => logger.error('Insufficient data error detected'), [ModbusGatewayPathUnavailableError.name]: () => logger.error('Gateway path unavailable error detected'), [ModbusGatewayTargetDeviceError.name]: () => logger.error('Gateway target device error detected'), [ModbusInvalidStartingAddressError.name]: () => logger.error('Invalid starting address error detected'), [ModbusMemoryParityError.name]: () => logger.error('Memory parity error detected'), [ModbusBroadcastError.name]: () => logger.error('Broadcast error detected'), [ModbusGatewayBusyError.name]: () => logger.error('Gateway busy error detected'), [ModbusDataOverrunError.name]: () => logger.error('Data overrun error detected'), [ModbusInterFrameTimeoutError.name]: () => logger.error('Inter-frame timeout error detected'), [ModbusSilentIntervalError.name]: () => logger.error('Silent interval error detected'), [ModbusTooManyEmptyReadsError.name]: () => logger.error('Too many empty reads error detected'), [ModbusFlushError.name]: () => logger.error('Flush error detected'), [NodeSerialTransportError.name]: () => logger.error('NodeSerial transport error detected'), [NodeSerialConnectionError.name]: () => logger.error('NodeSerial connection error detected'), [NodeSerialReadError.name]: () => logger.error('NodeSerial read error detected'), [NodeSerialWriteError.name]: () => logger.error('NodeSerial write error detected'), }; const handleModbusError = (err: Error): void => { const handler = ERROR_HANDLERS[err.constructor.name]; if (handler) { handler(); } else { logger.error(`Unknown error: ${err.message}`); } }; /** * Транспорт для Node.js через serialport. */ class NodeSerialTransport implements Transport { public isOpen: boolean = false; private path: string; private options: Required<NodeSerialTransportOptions>; private port: SerialPort | null = null; private readBuffer: Uint8Array = allocUint8Array(0); private _reconnectAttempts: number = 0; private _shouldReconnect: boolean = true; private _reconnectTimeout: NodeJS.Timeout | null = null; private _isConnecting: boolean = false; private _isDisconnecting: boolean = false; private _isFlushing: boolean = false; private _pendingFlushPromises: Array<() => void> = []; private _operationMutex: Mutex = new Mutex(); private _connectionPromise: Promise<void> | null = null; private _resolveConnection: (() => void) | null = null; private _rejectConnection: ((reason?: Error | string | null) => void) | null = null; private _connectedSlaveIds: Set<number> = new Set(); private _deviceStateHandler: DeviceStateHandler | null = null; private _portStateHandler: PortStateHandler | null = null; private _wasEverConnected: boolean = false; constructor(port: string, options: NodeSerialTransportOptions = {}) { this.path = port; this.options = { baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', readTimeout: 1000, writeTimeout: 1000, maxBufferSize: NODE_SERIAL_CONSTANTS.DEFAULT_MAX_BUFFER_SIZE, reconnectInterval: 3000, maxReconnectAttempts: Infinity, RSMode: options.RSMode || 'RS485', ...options, }; } public getRSMode(): RSMode { return this.options.RSMode; } public setDeviceStateHandler(handler: DeviceStateHandler): void { this._deviceStateHandler = handler; } public setPortStateHandler(handler: PortStateHandler): void { this._portStateHandler = handler; } public async disableDeviceTracking(): Promise<void> { this._deviceStateHandler = null; logger.debug('Device tracking disabled'); } public async enableDeviceTracking(handler?: DeviceStateHandler): Promise<void> { if (handler) { this._deviceStateHandler = handler; } logger.debug('Device tracking enabled'); } public notifyDeviceConnected(slaveId: number): void { if (this._connectedSlaveIds.has(slaveId)) { return; } this._connectedSlaveIds.add(slaveId); if (this._deviceStateHandler) { this._deviceStateHandler(slaveId, true); } } public notifyDeviceDisconnected( slaveId: number, errorType: ConnectionErrorType, errorMessage: string ): void { if (!this._connectedSlaveIds.has(slaveId)) { return; } this._connectedSlaveIds.delete(slaveId); if (this._deviceStateHandler) { this._deviceStateHandler(slaveId, false, { type: errorType, message: errorMessage }); } } public removeConnectedDevice(slaveId: number): void { if (this._connectedSlaveIds.has(slaveId)) { this._connectedSlaveIds.delete(slaveId); logger.debug(`Manually removed device ${slaveId} from connected set`); } } private async _notifyPortConnected(): Promise<void> { this._wasEverConnected = true; if (this._portStateHandler) { this._portStateHandler(true, [], undefined); } } private async _notifyPortDisconnected( errorType: ConnectionErrorType = ConnectionErrorType.UnknownError, errorMessage: string = 'Port disconnected' ): Promise<void> { if (!this._wasEverConnected) { logger.debug('Skipping DISCONNECTED — port was never connected'); return; } if (this._portStateHandler) { this._portStateHandler(false, [], { type: errorType, message: errorMessage }); } } private async _releaseAllResources(): Promise<void> { logger.debug('Releasing NodeSerial resources'); this._removeAllListeners(); if (this.port && this.port.isOpen) { await new Promise<void>((resolve, reject) => { this.port!.close((_err: Error | null) => { if (_err) reject(_err); else { logger.debug('Port closed successfully'); resolve(); } }); }); } this.port = null; this.isOpen = false; this.readBuffer = allocUint8Array(0); this._connectedSlaveIds.clear(); } private _removeAllListeners(): void { if (this.port) { this.port.removeAllListeners('data'); this.port.removeAllListeners('error'); this.port.removeAllListeners('close'); } } async connect(): Promise<void> { if (this._reconnectAttempts >= this.options.maxReconnectAttempts && !this.isOpen) { const error = new NodeSerialConnectionError( `Max reconnect attempts (${this.options.maxReconnectAttempts}) reached` ); logger.error(`Connection failed: ${error.message}`); throw error; } if (this._isConnecting) { logger.warn(`Connection attempt already in progress`); return this._connectionPromise ?? Promise.resolve(); } this._isConnecting = true; this._connectionPromise = new Promise<void>((resolve, reject) => { this._resolveConnection = resolve; this._rejectConnection = reject; }); try { if (this._reconnectTimeout) { clearTimeout(this._reconnectTimeout); this._reconnectTimeout = null; } if (this.port) { await this._releaseAllResources(); } if ( this.options.baudRate < NODE_SERIAL_CONSTANTS.MIN_BAUD_RATE || this.options.baudRate > NODE_SERIAL_CONSTANTS.MAX_BAUD_RATE ) { throw new ModbusConfigError(`Invalid baud rate: ${this.options.baudRate}`); } await this._createAndOpenPort(); logger.info(`Serial port ${this.path} opened`); await this._notifyPortConnected(); if (this._resolveConnection) { this._resolveConnection(); this._resolveConnection = null; this._rejectConnection = null; } } catch (err: unknown) { const error = err instanceof Error ? err : new NodeSerialTransportError(String(err)); logger.error(`Failed to open serial port ${this.path}: ${error.message}`); this.isOpen = false; if (this._wasEverConnected) { await this._notifyPortDisconnected(ConnectionErrorType.ConnectionLost, error.message); } if (this._reconnectAttempts >= this.options.maxReconnectAttempts) { const maxError = new NodeSerialConnectionError( `Max reconnect attempts (${this.options.maxReconnectAttempts}) reached` ); if (this._rejectConnection) { this._rejectConnection(maxError); this._resolveConnection = null; this._rejectConnection = null; } throw maxError; } if (this._shouldReconnect) { this._scheduleReconnect(error); } else { if (this._rejectConnection) { this._rejectConnection(error); this._resolveConnection = null; this._rejectConnection = null; } throw error; } } finally { this._isConnecting = false; } } private async _createAndOpenPort(): Promise<void> { return new Promise<void>((resolve, reject) => { const serialOptions = { path: this.path, baudRate: this.options.baudRate, dataBits: this.options.dataBits, stopBits: this.options.stopBits, parity: this.options.parity, autoOpen: false, }; this.port = new SerialPort(serialOptions); this.port.open((_err: Error | null) => { if (_err) { this.isOpen = false; if (_err.message.includes('permission')) { reject(new NodeSerialConnectionError('Permission denied')); } else if (_err.message.includes('busy')) { reject(new NodeSerialConnectionError('Serial port is busy')); } else if (_err.message.includes('no such file')) { reject(new NodeSerialConnectionError('Serial port does not exist')); } else { reject(new NodeSerialConnectionError(_err.message)); } return; } this.isOpen = true; this._reconnectAttempts = 0; this._removeAllListeners(); this.port?.on('data', this._onData.bind(this)); this.port?.on('error', this._onError.bind(this)); this.port?.on('close', this._onClose.bind(this)); resolve(); }); }); } private _onData(data: Buffer): void { if (!this.isOpen) return; try { const chunk = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); if (this.readBuffer.length + chunk.length > this.options.maxBufferSize) { this._handleError( new ModbusBufferOverflowError( this.readBuffer.length + chunk.length, this.options.maxBufferSize ) ); return; } this.readBuffer = concatUint8Arrays([this.readBuffer, chunk]); if (this.readBuffer.length > this.options.maxBufferSize) { this.readBuffer = sliceUint8Array(this.readBuffer, -this.options.maxBufferSize); } } catch (err: unknown) { this._handleError(err instanceof Error ? err : new NodeSerialTransportError(String(err))); } } private _onError(err: Error): void { logger.error(`Serial port ${this.path} error: ${err.message}`); if (err.message.includes('parity')) this._handleError(new ModbusParityError(err.message)); else if (err.message.includes('frame')) this._handleError(new ModbusFramingError(err.message)); else if (err.message.includes('overrun')) this._handleError(new ModbusOverrunError(err.message)); else if (err.message.includes('collision')) this._handleError(new ModbusCollisionError(err.message)); else if (err.message.includes('noise')) this._handleError(new ModbusNoiseError(err.message)); else this._handleError(new NodeSerialTransportError(err.message)); } private _onClose(): void { logger.info(`Serial port ${this.path} closed`); this.isOpen = false; this._notifyPortDisconnected(ConnectionErrorType.PortClosed, 'Port was closed').catch(() => {}); } private _scheduleReconnect(_err: Error): void { if (!this._shouldReconnect || this._isDisconnecting) return; if (this._reconnectTimeout) clearTimeout(this._reconnectTimeout); if (this._reconnectAttempts >= this.options.maxReconnectAttempts) { const maxError = new NodeSerialConnectionError(`Max reconnect attempts reached`); if (this._rejectConnection) this._rejectConnection(maxError); this._shouldReconnect = false; return; } this._reconnectAttempts++; this._reconnectTimeout = setTimeout(() => { this._reconnectTimeout = null; this._attemptReconnect(); }, this.options.reconnectInterval); } private async _attemptReconnect(): Promise<void> { try { if (this.port && this.port.isOpen) await this._releaseAllResources(); await this._createAndOpenPort(); this._reconnectAttempts = 0; await this._notifyPortConnected(); if (this._resolveConnection) this._resolveConnection(); } catch (error: unknown) { const err = error instanceof Error ? error : new NodeSerialTransportError(String(error)); this._reconnectAttempts++; if ( this._shouldReconnect && !this._isDisconnecting && this._reconnectAttempts <= this.options.maxReconnectAttempts ) { this._scheduleReconnect(err); } else { const maxError = new NodeSerialConnectionError(`Max reconnect attempts reached`); if (this._rejectConnection) this._rejectConnection(maxError); this._shouldReconnect = false; await this._notifyPortDisconnected(ConnectionErrorType.MaxReconnect, maxError.message); } } } async flush(): Promise<void> { if (this._isFlushing) { await Promise.all(this._pendingFlushPromises.map(p => p())).catch(() => {}); return; } this._isFlushing = true; const p = new Promise<void>(resolve => this._pendingFlushPromises.push(resolve)); try { this.readBuffer = allocUint8Array(0); } finally { this._isFlushing = false; this._pendingFlushPromises.forEach(r => r()); this._pendingFlushPromises = []; } return p; } async write(buffer: Uint8Array): Promise<void> { if (!this.isOpen || !this.port?.isOpen) throw new NodeSerialWriteError('Port closed'); if (buffer.length === 0) throw new ModbusBufferUnderrunError(0, 1); const release = await this._operationMutex.acquire(); try { return new Promise<void>((resolve, reject) => { this.port!.write(buffer, 'binary', (_err: Error | null | undefined) => { if (_err) { const e = _err.message.includes('parity') ? new ModbusParityError(_err.message) : _err.message.includes('collision') ? new ModbusCollisionError(_err.message) : new NodeSerialWriteError(_err.message); this._handleError(e); return reject(e); } this.port!.drain((_drainErr: Error | null | undefined) => { if (_drainErr) { const e = new NodeSerialWriteError(_drainErr.message); this._handleError(e); return reject(e); } resolve(); }); }); }); } finally { release(); } } async read(length: number, timeout: number = this.options.readTimeout): Promise<Uint8Array> { if (length <= 0) throw new ModbusDataConversionError(length, 'positive'); const release = await this._operationMutex.acquire(); const start = Date.now(); try { return new Promise((resolve, reject) => { const check = () => { if (!this.isOpen || !this.port?.isOpen) { return reject(new NodeSerialReadError('Port is closed')); } if (this._isFlushing) { return reject(new ModbusFlushError()); } if (this.readBuffer.length >= length) { const data = sliceUint8Array(this.readBuffer, 0, length); this.readBuffer = sliceUint8Array(this.readBuffer, length); if (data.length !== length) { return reject(new ModbusInsufficientDataError(data.length, length)); } return resolve(data); } if (Date.now() - start > timeout) { return reject( new ModbusTimeoutError(`Read timeout: No data received within ${timeout}ms`) ); } setTimeout(check, NODE_SERIAL_CONSTANTS.POLL_INTERVAL_MS); }; check(); }); } finally { release(); } } async disconnect(): Promise<void> { this._shouldReconnect = false; this._isDisconnecting = true; if (this._reconnectTimeout) clearTimeout(this._reconnectTimeout); if (this._rejectConnection) this._rejectConnection(new NodeSerialConnectionError('Disconnected')); if (!this.isOpen || !this.port) { this._isDisconnecting = false; if (this._wasEverConnected) { await this._notifyPortDisconnected( ConnectionErrorType.ManualDisconnect, 'Port closed by user' ); } return; } await this._releaseAllResources(); if (this._wasEverConnected) { await this._notifyPortDisconnected( ConnectionErrorType.ManualDisconnect, 'Port closed by user' ); } this._isDisconnecting = false; } destroy(): void { this._shouldReconnect = false; if (this._reconnectTimeout) clearTimeout(this._reconnectTimeout); if (this._rejectConnection) this._rejectConnection(new NodeSerialTransportError('Destroyed')); this._releaseAllResources().catch(() => {}); if (this._wasEverConnected) { this._notifyPortDisconnected(ConnectionErrorType.Destroyed, 'Transport destroyed').catch( () => {} ); } } private _handleError(err: Error): void { handleModbusError(err); this._handleConnectionLoss(`Error: ${err.message}`); } private _handleConnectionLoss(reason: string): void { if (!this.isOpen && !this._isConnecting) return; logger.warn(`Connection loss detected: ${reason}`); this.isOpen = false; if (this._wasEverConnected) { this._notifyPortDisconnected(ConnectionErrorType.ConnectionLost, reason).catch(() => {}); } } } export = NodeSerialTransport;