UNPKG

modbus-connect

Version:

Modbus RTU over Web Serial and Node.js SerialPort

816 lines (733 loc) 27.3 kB
// polling-manager.js /** * PollingManager управляет набором задач и очередями для ресурсов. * Обеспечивает последовательное выполнение задач, связанных с одним ресурсом. */ class PollingManager { constructor() { /** @type {Map<string, TaskController>} */ this.tasks = new Map(); /** @type {Map<string, TaskQueue>} */ this.queues = new Map(); } /** * Добавляет новую задачу в менеджер. */ addTask(options) { const { id, resourceId } = options; if (!id) throw new Error('Polling task must have an "id".'); if (this.tasks.has(id)) { throw new Error(`Polling task with id "${id}" already exists.`); } const controller = new TaskController(options, this); this.tasks.set(id, controller); if (resourceId) { if (!this.queues.has(resourceId)) { this.queues.set(resourceId, new TaskQueue(resourceId, this)); } // Добавляем задачу в очередь при создании (но не запускаем) this.queues.get(resourceId).enqueue(controller); } if (options.immediate) { if (resourceId) { controller.scheduleRun(); } else { controller.start(); } } } /** * Обновляет задачу новыми опциями. */ updateTask(id, newOptions) { if (!this.tasks.has(id)) { throw new Error(`Polling task with id "${id}" does not exist.`); } this.removeTask(id); this.addTask({ id, ...newOptions }); } /** * Удаляет задачу из менеджера и очереди. */ removeTask(id) { const task = this.tasks.get(id); if (task) { task.stop(); const resourceId = task.resourceId; if (resourceId && this.queues.has(resourceId)) { this.queues.get(resourceId).removeTask(id); // Если очередь стала пустой, удаляем её if (this.queues.get(resourceId).isEmpty()) { this.queues.delete(resourceId); } } this.tasks.delete(id); } } /** * Перезапускает задачу. */ restartTask(id) { const task = this.tasks.get(id); if (task) { task.stop(); // Остановка/запуск через очередь, если она есть if (task.resourceId && this.queues.has(task.resourceId)) { // Очередь сама обработает готовность задачи после остановки task.scheduleRun(); // scheduleRun внутри TaskController будет учитывать очередь } else { task.start(); // Прямой запуск для задач без очереди } } } /** * Запускает задачу. */ startTask(id) { this.tasks.get(id)?.start(); } /** * Останавливает задачу. */ stopTask(id) { this.tasks.get(id)?.stop(); } /** * Ставит задачу на паузу. */ pauseTask(id) { this.tasks.get(id)?.pause(); } /** * Возобновляет задачу. */ resumeTask(id) { this.tasks.get(id)?.resume(); } /** * Обновляет интервал задачи. */ setTaskInterval(id, interval) { this.tasks.get(id)?.setInterval(interval); } /** * Проверяет, запущена ли задача. */ isTaskRunning(id) { return this.tasks.get(id)?.isRunning() ?? false; } /** * Проверяет, приостановлена ли задача. */ isTaskPaused(id) { return this.tasks.get(id)?.isPaused() ?? false; } /** * Получает состояние задачи. */ getTaskState(id) { return this.tasks.get(id)?.getState() ?? null; } /** * Получает статистику задачи. */ getTaskStats(id) { return this.tasks.get(id)?.getStats() ?? null; } /** * Проверяет, существует ли задача. */ hasTask(id) { return this.tasks.has(id); } /** * Возвращает массив ID всех задач. */ getTaskIds() { return Array.from(this.tasks.keys()); } /** * Очищает все задачи. */ clearAll() { for (const task of this.tasks.values()) { task.stop(); } this.tasks.clear(); // Останавливаем и очищаем все очереди for (const queue of this.queues.values()) { queue.clear(); } this.queues.clear(); } /** * Перезапускает все задачи. */ restartAllTasks() { for (const task of this.tasks.values()) { task.stop(); task.scheduleRun(); } } /** * Ставит на паузу все задачи. */ pauseAllTasks() { for (const task of this.tasks.values()) { task.pause(); } } /** * Возобновляет все задачи. */ resumeAllTasks() { for (const task of this.tasks.values()) { task.resume(); } } /** * Запускает все задачи. */ startAllTasks() { for (const task of this.tasks.values()) { task.scheduleRun(); } } /** * Останавливает все задачи. */ stopAllTasks() { for (const task of this.tasks.values()) { task.stop(); } } /** * Возвращает статистику всех задач. */ getAllTaskStats() { const stats = {}; for (const [id, task] of this.tasks.entries()) { stats[id] = task.getStats(); } return stats; } /** * Внутренний метод для запуска задачи менеджером очереди. * @param {TaskController} taskController - Контроллер задачи. * @returns {Promise<void>} */ async _executeQueuedTask(taskController) { return taskController.executeOnce(); } } // Set для отслеживания задач, которые в данный момент в очереди или обрабатываются // Формат ключа: `${resourceId}:${taskId}` const queuedOrProcessingTasks = new Set(); /** * TaskQueue управляет очередью задач для конкретного ресурса. * Гарантирует отсутствие дубликатов и последовательное выполнение. */ class TaskQueue { /** * @param {string} resourceId * @param {PollingManager} pollingManager */ constructor(resourceId, pollingManager) { this.resourceId = resourceId; this.pollingManager = pollingManager; // Используем очередь (массив) для хранения ID задач в порядке их поступления this.taskQueue = []; this.isProcessing = false; } /** * Добавляет задачу в очередь на обработку. * @param {TaskController} taskController */ enqueue(taskController) { // Этот метод вызывается только при инициализации задачи // Для задач в очереди используем markTaskReady if (!taskController.stopped && taskController.resourceId === this.resourceId) { const taskKey = `${this.resourceId}:${taskController.id}`; // Проверяем, не находится ли задача уже в очереди или обработке if (!queuedOrProcessingTasks.has(taskKey)) { queuedOrProcessingTasks.add(taskKey); this.taskQueue.push(taskController.id); if (!this.isProcessing) { this._processNext(); } } } } /** * Удаляет задачу из очереди. * @param {string} taskId */ removeTask(taskId) { const taskKey = `${this.resourceId}:${taskId}`; queuedOrProcessingTasks.delete(taskKey); // Удаляем все вхождения taskId из очереди this.taskQueue = this.taskQueue.filter(id => id !== taskId); } /** * Проверяет, пуста ли очередь. * @returns {boolean} */ isEmpty() { return this.taskQueue.length === 0; } /** * Очищает очередь. */ clear() { // Очищаем Set для всех задач этой очереди for (const taskId of this.taskQueue) { const taskKey = `${this.resourceId}:${taskId}`; queuedOrProcessingTasks.delete(taskKey); } this.taskQueue = []; this.isProcessing = false; } /** * Сообщает очереди, что задача готова к следующему запуску. * Это основной метод для добавления задач в очередь во время работы. * @param {TaskController} taskController */ markTaskReady(taskController) { // Атомарная проверка и добавление if (!taskController.stopped && taskController.resourceId === this.resourceId) { const taskKey = `${this.resourceId}:${taskController.id}`; // Проверяем, не находится ли задача уже в очереди или обработке if (!queuedOrProcessingTasks.has(taskKey)) { // Добавляем в Set и в очередь queuedOrProcessingTasks.add(taskKey); this.taskQueue.push(taskController.id); if (!this.isProcessing) { this._processNext(); } } } } /** * Обрабатывает очередь задач. * @private */ async _processNext() { if (this.taskQueue.length === 0) { this.isProcessing = false; return; } this.isProcessing = true; // Извлекаем ID первой задачи из очереди const taskId = this.taskQueue.shift(); const taskKey = `${this.resourceId}:${taskId}`; // Получаем контроллер задачи const taskController = this.pollingManager.tasks.get(taskId); // Проверяем, существует ли задача и активна ли она if (!taskController || taskController.stopped) { // Удаляем из Set, так как задача больше не будет обрабатываться queuedOrProcessingTasks.delete(taskKey); this.isProcessing = false; // Переходим к следующей задаче if (this.taskQueue.length > 0) { // Используем setTimeout вместо setImmediate для браузеров setTimeout(() => this._processNext(), 0); } return; } try { // console.log(`[Queue ${this.resourceId}] Executing task: ${taskId}`); // Передаем выполнение PollingManager'у await this.pollingManager._executeQueuedTask(taskController); } catch (error) { console.error(`[Queue ${this.resourceId}] Error executing task ${taskId}:`, error); // Ошибка выполнения задачи обрабатывается внутри TaskController } finally { // ВАЖНО: Удаляем задачу из Set только после выполнения // Это позволяет задаче снова быть добавленной через markTaskReady queuedOrProcessingTasks.delete(taskKey); this.isProcessing = false; // Переходим к следующей задаче if (this.taskQueue.length > 0) { // Используем setTimeout вместо setImmediate для браузеров setTimeout(() => this._processNext(), 0); } } } } /** * TaskController управляет логикой одной задачи. */ class TaskController { /** * @param {Object} options * @param {PollingManager} pollingManager */ constructor({ id, resourceId, priority = 0, interval, fn, onData, onError, onStart, onStop, onFinish, onBeforeEach, onRetry, shouldRun, onSuccess, onFailure, name = null, immediate = false, maxRetries = 0, backoffDelay = 0, taskTimeout = 2000 }, pollingManager) { this.id = id; this.resourceId = resourceId; this.priority = priority; this.name = name; this.fn = Array.isArray(fn) ? fn : [fn]; this.interval = interval; this.onData = onData; this.onError = onError; this.onStart = onStart; this.onStop = onStop; this.onFinish = onFinish; this.onBeforeEach = onBeforeEach; this.onRetry = onRetry; this.shouldRun = shouldRun; this.onSuccess = onSuccess; this.onFailure = onFailure; this.maxRetries = maxRetries; this.backoffDelay = backoffDelay; this.taskTimeout = taskTimeout; this.stopped = true; this.paused = false; this.loopRunning = false; this.executionInProgress = false; this.pollingManager = pollingManager; this.stats = { totalRuns: 0, totalErrors: 0, lastError: null, lastResult: null, lastRunTime: null, retries: 0, successes: 0, failures: 0 }; } /** * Запускает задачу. */ async start() { if (!this.stopped) return; this.stopped = false; this.loopRunning = true; this.onStart?.(); if (this.resourceId) { this.scheduleRun(); } else { this._runLoop(); } } /** * Останавливает задачу. */ stop() { if (this.stopped) return; this.stopped = true; this.loopRunning = false; this.onStop?.(); } /** * Ставит задачу на паузу. */ pause() { this.paused = true; } /** * Возобновляет задачу. */ resume() { if (!this.stopped && this.paused) { this.paused = false; this.scheduleRun(); } } /** * Планирует запуск задачи. */ scheduleRun() { // Не планируем остановленные задачи if (this.stopped) return; if (this.resourceId && this.pollingManager.queues.has(this.resourceId)) { const queue = this.pollingManager.queues.get(this.resourceId); queue.markTaskReady(this); } else if (!this.resourceId) { if (this.stopped) { this.start(); } else if (!this.loopRunning) { this.loopRunning = true; this._runLoop(); } } } /** * Выполняет задачу один раз. * @returns {Promise<void>} */ async executeOnce() { if (this.stopped || this.paused) { return; } if (this.shouldRun && this.shouldRun() === false) { this._scheduleNextRun(); return; } this.onBeforeEach?.(); this.executionInProgress = true; this.stats.totalRuns++; // Очистка буфера перед запуском задачи if (this.fn[0]?.transport?.flush) { try { await this.fn[0].transport.flush(); } catch (flushErr) { console.warn(`Flush failed: ${flushErr.message}`); } } let success = false; let results = []; for (let fnIndex = 0; fnIndex < this.fn.length; fnIndex++) { let retryCount = 0; let result = null; while (!this.stopped && retryCount <= this.maxRetries) { // Проверка на паузу внутри цикла ретраев if (this.paused) { this.executionInProgress = false; return; } try { // Лимит времени на всю задачу result = await this._withTimeout( this.fn[fnIndex](), this.taskTimeout ); this.stats.successes++; this.stats.lastError = null; success = true; break; } catch (err) { retryCount++; this.stats.totalErrors++; this.stats.retries++; this.stats.lastError = err; this.onRetry?.(err, fnIndex, retryCount); const isFlushedError = err.name === 'ModbusFlushError'; let backoffDelay = this.backoffDelay; if (isFlushedError) { console.debug(`Task ${this.id} fn ${fnIndex} failed due to flush. Resetting backoff for next attempt.`) backoffDelay = this.backoffDelay; } let delay = isFlushedError ? Math.min(50, backoffDelay) : backoffDelay * Math.pow(2, retryCount - 1); if (retryCount > this.maxRetries) { this.stats.failures++; this.onFailure?.(err); this.onError?.(err, fnIndex, retryCount); console.warn(`Max retries exhausted for task ${this.id}, fn ${fnIndex}. Relying on transport reconnect logic`) } else { // Ждем перед следующей попыткой await this._sleep(delay); // После ожидания проверяем, не остановлена ли задача if (this.stopped) { this.executionInProgress = false; return; } } } } results.push(result); } this.stats.lastResult = results; this.stats.lastRunTime = Date.now(); this.executionInProgress = false; if (results.every(r => r !== null && r !== undefined)) { this.onData?.(...results); } else { console.warn(`Skipping onData for task ${this.id}: invalid result(s) - ${JSON.stringify(results)}`); } this.onFinish?.(success, results); // Планируем следующий запуск задачи this._scheduleNextRun(); } /** * Планирует следующий запуск задачи. * @private */ _scheduleNextRun() { if (!this.stopped && this.resourceId) { // Планируем следующий запуск через интервал setTimeout(() => { if (!this.stopped) { this.scheduleRun(); } }, this.interval); } else if (!this.stopped && !this.resourceId) { // Для задач без resourceId запускаем оригинальный цикл setTimeout(() => { if (!this.stopped && this.loopRunning) { this._runLoop(); } }, this.interval); } } /** * Проверяет, запущена ли задача. */ isRunning() { return !this.stopped; } /** * Проверяет, приостановлена ли задача. */ isPaused() { return this.paused; } /** * Устанавливает интервал задачи. */ setInterval(ms) { this.interval = ms; } /** * Возвращает состояние задачи. */ getState() { return { stopped: this.stopped, paused: this.paused, running: this.loopRunning, inProgress: this.executionInProgress }; } /** * Возвращает статистику задачи. */ getStats() { return { ...this.stats }; } /** * Оригинальный цикл выполнения задачи (для задач без resourceId). * @private */ async _runLoop() { let consecutiveCrcErrors = 0; let backoffDelay = this.backoffDelay; while (this.loopRunning && !this.stopped) { if (this.paused) { await this._sleep(this.interval); continue; } if (this.shouldRun && this.shouldRun() === false) { await this._sleep(this.interval); continue; } this.onBeforeEach?.(); this.executionInProgress = true; this.stats.totalRuns++; if (this.fn[0]?.transport?.flush) { try { await this.fn[0].transport.flush(); } catch (flushErr) { console.warn(`Flush failed: ${flushErr.message}`); } } let success = false; let results = []; for (let fnIndex = 0; fnIndex < this.fn.length; fnIndex++) { let retryCount = 0; let result = null; while (this.loopRunning && !this.stopped && retryCount <= this.maxRetries) { try { result = await this._withTimeout( this.fn[fnIndex](), this.taskTimeout ); this.stats.successes++; this.stats.lastError = null; success = true; consecutiveCrcErrors = 0; backoffDelay = this.backoffDelay; break; } catch (err) { retryCount++; this.stats.totalErrors++; this.stats.retries++; this.stats.lastError = err; this.onRetry?.(err, fnIndex, retryCount); const isFlushedError = err.name === 'ModbusFlushError'; if (isFlushedError) { console.debug(`Task ${this.id} fn ${fnIndex} failed due to flush. Resetting backoff for next attempt.`) backoffDelay = this.backoffDelay; } let delay = isFlushedError ? Math.min(50, backoffDelay) : backoffDelay * Math.pow(2, retryCount - 1); if (retryCount > this.maxRetries) { this.stats.failures++; this.onFailure?.(err); this.onError?.(err, fnIndex, retryCount); console.warn(`Max retries exhausted for task ${this.id}, fn ${fnIndex}. Relying on transport reconnect logic`) } else { await this._sleep(delay); } } } results.push(result); } this.stats.lastResult = results; this.stats.lastRunTime = Date.now(); this.executionInProgress = false; if (results.every(r => r !== null && r !== undefined)) { this.onData?.(...results); } else { console.warn(`Skipping onData for task ${this.id}: invalid result(s) - ${JSON.stringify(results)}`); } this.onFinish?.(success, results); // Для задач без resourceId используем sleep await this._sleep(this.interval); } this.loopRunning = false; } /** * Sleeps for given amount of milliseconds. * @param {number} ms * @returns {Promise} * @private */ _sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Wraps a promise with a timeout. * @param {Promise} promise * @param {number} timeout * @returns {Promise} * @private */ _withTimeout(promise, timeout) { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('Task timed out')), timeout); promise .then(result => { clearTimeout(timer); resolve(result); }) .catch(err => { clearTimeout(timer); reject(err); }); }); } } module.exports = PollingManager;