UNPKG

modbus-connect

Version:

Modbus RTU over Web Serial and Node.js SerialPort

446 lines (386 loc) 17.1 kB
// transport/web-transports/web-serialport.js const logger = require("../../logger.js"); const { allocUint8Array } = require("../../utils/utils.js"); const { Mutex } = require('async-mutex'); const { ModbusError, ModbusTimeoutError, ModbusCRCError, ModbusResponseError, ModbusTooManyEmptyReadsError, ModbusExceptionError, ModbusFlushError } = require('../../errors.js') class WebSerialTransport { constructor(portFactory, options = {}) { // <-- Изменение 1: Проверка входных данных --> if(typeof portFactory !== 'function'){ throw new Error('A port factory function must be provided to WebSerialTransport') } this.portFactory = portFactory this.port = null; this.options = { baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', readTimeout: 1000, writeTimeout: 1000, reconnectInterval: 3000, maxReconnectAttempts: Infinity, maxEmptyReadsBeforeReconnect: 10, ...options }; this.reader = null; this.writer = null; this.readBuffer = new Uint8Array(0); this.isOpen = false; this._reconnectAttempts = 0; this._shouldReconnect = true; this._isFlushing = false; this._pendingFlushPromises = []; this._reconnectTimer = null; this._emptyReadCount = 0; this._readLoopActive = false; this._operationMutex = new Mutex(); } async connect() { // <-- Изменение 4: Очистка таймера при начале новой попытки подключения --> if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } try { this._shouldReconnect = true; this._emptyReadCount = 0; // <-- Изменение 5: Всегда получаем новый порт через фабрику --> // Это ключевой момент: мы больше не пытаемся переиспользовать "сломанный" объект logger.debug('Requesting new SerialPort instance from factory...'); this.port = await this.portFactory(); // <-- Получаем новый порт if (!this.port || typeof this.port.open !== 'function') { throw new Error('Port factory did not return a valid SerialPort object.'); } logger.debug('New SerialPort instance acquired.'); // <-- Изменение 3: Принудительная очистка перед открытием нового порта --> // На случай, если portFactory вернула существующий порт, который нужно "починить" if (this.port) { // Отменяем reader, если активен (на случай, если _cleanupResources не сработал полностью) if (this.reader) { try { await this.reader.cancel(); } catch (cancelErr) { logger.debug('Error cancelling reader during pre-connect (new port):', cancelErr.message); } try { this.reader.releaseLock(); } catch (releaseErr) { logger.debug('Error releasing reader lock during pre-connect (new port):', releaseErr.message); } this.reader = null; } // Освобождаем writer, если активен if (this.writer) { try { this.writer.releaseLock(); } catch (releaseErr) { logger.debug('Error releasing writer lock during pre-connect (new port):', releaseErr.message); } this.writer = null; } // Пытаемся закрыть порт, если он "открыт" // Это помогает сбросить состояние браузера, если порт "завис". // ВАЖНО: Сбрасываем isOpen до вызова close(), чтобы избежать рекурсии через _onError const wasOpen = this.isOpen; this.isOpen = false; this.readBuffer = allocUint8Array(0); if (wasOpen) { // Только если мы думали, что он открыт try { logger.debug('Attempting to close the port (from factory) before reopening...'); await this.port.close(); logger.debug('Port (from factory) closed successfully before reconnecting.'); } catch (closeErr) { // Ошибка закрытия может возникнуть, если порт уже закрыт или в некорректном состоянии. logger.debug('Error closing port (from factory) before reconnect (might be already closed or broken):', closeErr.message); // Игнорируем ошибку закрытия, цель - сброс состояния. } } // --- Конец изменения 3 --- } // <-- Конец изменения 5 --> // <-- Изменение 6: Убедиться, что предыдущие ресурсы освобождены --> // Хотя порт новый, всё равно сделаем очистку на всякий случай this._cleanupResources(); await this.port.open({ baudRate: this.options.baudRate, dataBits: this.options.dataBits, stopBits: this.options.stopBits, parity: this.options.parity, flowControl: 'none', }); const readable = this.port.readable; const writable = this.port.writable; if (!readable || !writable) { const errorMsg = 'Serial port not readable/writable after open'; logger.error(errorMsg); throw new Error(errorMsg); } this.reader = readable.getReader(); this.writer = writable.getWriter(); this.isOpen = true; this._reconnectAttempts = 0; this._startReading(); logger.info('WebSerial port opened successfully with new instance'); } catch (err) { logger.error(`Failed to open WebSerial port: ${err.message}`); // <-- Изменение 7: Упрощенная обработка ошибок --> // Больше не пытаемся различать типы ошибок открытия для остановки реконнекта // Если фабрика даёт новый порт, ошибка "already open" должна исчезнуть if (this._shouldReconnect) { this._scheduleReconnect(err); } throw err; } } // <-- Изменение 8: Централизованная функция очистки ресурсов --> _cleanupResources() { if (this.reader) { try { this.reader.releaseLock(); } catch (e) { logger.debug('Error releasing reader:', e.message); } this.reader = null; } if (this.writer) { try { this.writer.releaseLock(); } catch (e) { logger.debug('Error releasing writer:', e.message); } this.writer = null; } // Не закрываем this.port здесь, это делается в disconnect или перед новым открытием this.readBuffer = allocUint8Array(0); this._readLoopActive = false; this._emptyReadCount = 0; } _startReading() { if (!this.isOpen || !this.reader || this._readLoopActive) { logger.warn('Cannot start reading: port not open, no reader, or loop already active'); return; } this._readLoopActive = true; logger.debug('Starting read loop'); const loop = async () => { try { while (this.isOpen && this.reader) { try { const { value, done } = await this.reader.read(); if (done) { logger.warn('WebSerial read stream closed (done=true)'); this._readLoopActive = false; this._onClose(); break; } if (value && value.length > 0) { this._emptyReadCount = 0; const newBuffer = new Uint8Array(this.readBuffer.length + value.length); newBuffer.set(this.readBuffer, 0); newBuffer.set(value, this.readBuffer.length); this.readBuffer = newBuffer; } else { this._emptyReadCount++; if (this._emptyReadCount >= this.options.maxEmptyReadsBeforeReconnect) { logger.warn(`Too many empty reads (${this._emptyReadCount}), triggering reconnect`); this._emptyReadCount = 0; this._readLoopActive = false; this._onError(new ModbusTooManyEmptyReadsError()); break; } } } catch (readErr) { logger.warn(`Read operation error: ${readErr.message}`); this._readLoopActive = false; this._onError(readErr); break; } } } catch (loopErr) { logger.error(`Unexpected error in read loop: ${loopErr.message}`); this._readLoopActive = false; this._onError(loopErr); } finally { this._readLoopActive = false; logger.debug('Read loop finished'); } }; loop(); } async write(buffer) { const release = await this._operationMutex.acquire(); try { if (this._isFlushing) { logger.debug('Write operation aborted due to ongoing flush'); throw new ModbusFlushError(); } if(!this.isOpen || !this.writer){ logger.warn(`Write attempted on closed/unready port`); throw new Error('Port is closed or not ready for writing'); } const timeout = this.options.writeTimeout; const abort = new AbortController(); const timer = setTimeout(() => abort.abort(), timeout); try { await this.writer.write(buffer, { signal: abort.signal }); logger.debug(`Wrote ${buffer.length} bytes to WebSerial port`); } catch (err) { if (abort.signal.aborted) { logger.warn(`Write timeout on WebSerial port`); this._onError(new ModbusTimeoutError('Write timeout')); throw new ModbusTimeoutError('Write timeout'); } else { logger.error(`Write error on WebSerial port: ${err.message}`); this._onError(err); throw err; } } finally { clearTimeout(timer); } } finally { release(); } } async read(length, timeout = this.options.readTimeout) { const release = await this._operationMutex.acquire(); try { const start = Date.now(); return new Promise((resolve, reject) => { const check = () => { if (this._isFlushing) { logger.debug('Read operation interrupted by flush'); return reject(new ModbusFlushError()); } if (this.readBuffer.length >= length) { const data = this.readBuffer.slice(0, length); this.readBuffer = this.readBuffer.slice(length); logger.debug(`Read ${length} bytes from WebSerial port`); return resolve(data); } if (Date.now() - start > timeout) { logger.warn(`Read timeout on WebSerial port`); return reject(new ModbusTimeoutError('Read timeout')); } setTimeout(check, 10); }; check(); }); } finally { release(); } } async disconnect() { logger.info('Disconnecting WebSerial transport...'); this._shouldReconnect = false; if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } this._readLoopActive = false; try { // <-- Изменение 9: Используем централизованную очистку --> this._cleanupResources(); // <-- Изменение 10: Закрываем порт и сбрасываем его --> if (this.port) { try { logger.debug('Closing port...'); await this.port.close(); logger.debug('Port closed successfully.'); } catch (err) { // Ошибка закрытия может произойти, если порт уже закрыт или в некорректном состоянии logger.warn(`Error closing port (might be already closed): ${err.message}`); } this.port = null; // <-- ВАЖНО: Сбрасываем ссылку на порт } this.isOpen = false; logger.info('WebSerial transport disconnected successfully'); } catch (err) { logger.error(`Error during WebSerial transport shutdown: ${err.message}`); // Даже если ошибка, всё равно сбрасываем состояние this.isOpen = false; this.port = null; // <-- ВАЖНО: Сбрасываем ссылку на порт this.reader = null; this.writer = null; } } async flush(){ logger.debug('Flushing WebSerial transport buffer'); if(this._isFlushing){ logger.warn('Flush already in progress'); return Promise.all(this._pendingFlushPromises).catch(() => {}); } this._isFlushing = true; const flushPromise = new Promise((resolve) => { this._pendingFlushPromises.push(resolve); }); try { this.readBuffer = allocUint8Array(0); this._emptyReadCount = 0; logger.debug('WebSerial read buffer flushed'); } finally { this._isFlushing = false; this._pendingFlushPromises.forEach((resolve) => resolve()); this._pendingFlushPromises = []; logger.debug('WebSerial transport flush completed'); } return flushPromise; } // ? =========================================== // ? ========== МЕТОДЫ ДЛЯ РЕКОННЕКТА ========== // ? =========================================== // <-- Изменение 48: Универсальный метод для обработки ошибок и закрытия --> _handleConnectionLoss(reason) { if (!this.isOpen) return; logger.warn(`Connection loss detected: ${reason}`); this.isOpen = false; this._readLoopActive = false; // <-- Изменение 11: Используем централизованную очистку --> this._cleanupResources(); // <-- Изменение 12: Не пытаемся закрыть this.port здесь --> // Порт будет закрыт и сброшен в connect() или disconnect() if (this._shouldReconnect) { this._scheduleReconnect(new Error(reason)); } } _onError(err) { logger.error(`WebSerial port error: ${err.message}`); this._handleConnectionLoss(`Error: ${err.message}`); } _onClose() { logger.warn(`WebSerial port closed`); this._handleConnectionLoss('Port closed'); } _scheduleReconnect(err) { if (!this._shouldReconnect) { logger.info('Reconnect disabled, not scheduling'); return; } if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); } if (this._reconnectAttempts >= (this.options.maxReconnectAttempts || Infinity)) { logger.error(`Max reconnect attempts (${this.options.maxReconnectAttempts}) reached for WebSerial port`); return; } this._reconnectAttempts++; logger.info(`Scheduling reconnect to WebSerial port in ${this.options.reconnectInterval} ms (attempt ${this._reconnectAttempts}) due to: ${err.message}`); this._reconnectTimer = setTimeout(async () => { this._reconnectTimer = null; try { await this.connect(); // <-- connect() теперь получит НОВЫЙ порт logger.info(`Reconnect attempt ${this._reconnectAttempts} successful`); this._reconnectAttempts = 0; this._emptyReadCount = 0; } catch (reconnectErr) { logger.warn(`Reconnect attempt ${this._reconnectAttempts} failed: ${reconnectErr.message}`); // <-- Изменение 13: Упрощённая логика планирования --> // Просто планируем следующую попытку, если разрешено if (this._shouldReconnect) { this._scheduleReconnect(reconnectErr); } } }, this.options.reconnectInterval); } } module.exports = { WebSerialTransport }