UNPKG

modbus-connect

Version:

Modbus RTU over Web Serial and Node.js SerialPort

629 lines (568 loc) 22 kB
// polling-manager.js // ⣿⣿⣿⣿⣿⢿⠿⠿⠿⠛⠛⠛⠛⠻⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿ // ⣿⣿⠟⠋⣁⠄⠄⣀⣤⣤⣤⣀⣉⣁⡀⠒⠄⠉⠛⣿⣿⣿⣿⣿ // ⡏⢡⣴⠟⠁⠐⠉⠄⣤⣄⠉⣿⠟⢃⡄⠄⠄⢠⡀⠈⠻⣿⣿⣿ // ⠄⢸⣤⣤⣀⠑⠒⠚⠛⣁⣤⣿⣦⣄⡉⠛⠛⠛⠉⣠⣄⠙⣿⣿ // ⠄⣾⣿⣿⡟⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡀⠚⣿ // ⠄⢻⣿⣿⣷⣄⣉⠙⠛⠛⠛⠛⠛⠛⠋⣉⣉⣀⣤⠤⠄⣸⡀⢻ // ⣇⡈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⢠⣶⣿⣇⠘ // ⣿⣧⡈⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⢸⣿⣿⡿⠄ // ⣿⣿⣷⡀⠹⣿⣿⣿⣿⣿⣿⡋⠙⢻⣿⣿⣿⠟⢀⣾⣿⣿⠃⣸ // ⣿⣿⣿⣿⣦⠈⠻⣿⣿⣿⣿⣿⣷⣤⣀⣀⣠⣤⣿⣿⠟⢁⣼⣿ // ⣿⣿⣿⣿⣿⣿⣶⣤⣈⠙⠛⠛⠿⠿⠿⠿⠿⠛⠛⣡⣴⣿⣿⣿ // ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣷⣶⣶⣶⣶⣶⣾⣿⣿⣿⣿⣿⣿ /** * PollingManager is a class that manages a set of polling tasks. * * A polling task is an object that contains options for a task that will be * executed at a given interval. The task can be started, stopped, paused, or * resumed. The PollingManager provides methods to manage multiple tasks and * retrieve their state and statistics. * * @class * @param {Object} [options] - Options for the PollingManager. * @param {boolean} [options.immediate=false] - If true, tasks will be started immediately after being added. * @param {number} [options.backoffDelay=300] - The initial delay in milliseconds for the backoff algorithm used for retrying failed tasks. * @param {number} [options.maxRetries=3] - The maximum number of times a task will be retried before it is stopped. * @param {number} [options.taskTimeout=2000] - The timeout in milliseconds for each task function execution. */ class PollingManager { constructor() { this.tasks = new Map(); } /** * Adds a new polling task to the manager. * * @param {Object} options - The options for the task. * @param {string} options.id - The ID of the task. * @param {number} [options.interval] - The interval in milliseconds at which the task will be executed. * @param {number} [options.immediate=false] - If true, the task will be started immediately after being added. * @param {number} [options.backoffDelay=300] - The initial delay in milliseconds for the backoff algorithm used for retrying failed tasks. * @param {number} [options.maxRetries=3] - The maximum number of times a task will be retried before it is stopped. * @param {number} [options.taskTimeout=2000] - The timeout in milliseconds for each task function execution. * @param {Function|Function[]} options.fn - The function or functions to be executed at each interval. * @param {Function} [options.onData] - The callback to be called with the results of the task functions when they all succeed. * @param {Function} [options.onError] - The callback to be called with the error when any task function fails. * @param {Function} [options.onStart] - The callback to be called when the task starts. * @param {Function} [options.onStop] - The callback to be called when the task stops. */ addTask(options) { const { id } = 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.tasks.set(id, controller); if (options.immediate) { controller.start(); } } /** * Updates a polling task with new options. * * @param {string} id - The ID of the task to update. * @param {Object} newOptions - The new options for the task. The `id` property will be ignored. * @throws {Error} If the task with the given ID does not exist. */ 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 }); } /** * Removes a polling task from the manager. * * @param {string} id - The ID of the task to remove. */ removeTask(id) { const task = this.tasks.get(id); if (task) { task.stop(); this.tasks.delete(id); } } /** * Restarts a polling task. * * @param {string} id - The ID of the task to restart. */ restartTask(id) { const task = this.tasks.get(id); if (task) { task.stop(); task.start(); } } /** * Starts a polling task. * * @param {string} id - The ID of the task to start. */ startTask(id) { this.tasks.get(id)?.start(); } /** * Stops a polling task. * * @param {string} id - The ID of the task to stop. */ stopTask(id) { this.tasks.get(id)?.stop(); } /** * Pauses a polling task. * * @param {string} id - The ID of the task to pause. */ pauseTask(id) { this.tasks.get(id)?.pause(); } /** * Resumes a polling task. * * @param {string} id - The ID of the task to resume. */ resumeTask(id) { this.tasks.get(id)?.resume(); } /** * Dynamically updates the polling interval for a task. * * @param {string} id - The ID of the task for which to update the interval. * @param {number} interval - The new interval in milliseconds. */ setTaskInterval(id, interval) { this.tasks.get(id)?.setInterval(interval); } /** * Checks if a polling task is currently running. * * @param {string} id - The ID of the task to check. * @returns {boolean} - True if the task is running, false otherwise. */ isTaskRunning(id) { return this.tasks.get(id)?.isRunning() ?? false; } /** * Checks if a polling task is currently paused. * * @param {string} id - The ID of the task to check. * @returns {boolean} - True if the task is paused, false otherwise. */ isTaskPaused(id) { return this.tasks.get(id)?.isPaused() ?? false; } /** * Gets the current state of a polling task. * * @param {string} id - The ID of the task to check. * @returns {Object|null} - An object with the following properties if the task exists, null otherwise: * - `running`: A boolean indicating if the task is currently running. * - `paused`: A boolean indicating if the task is currently paused. * - `interval`: The current polling interval in milliseconds. */ getTaskState(id) { return this.tasks.get(id)?.getState() ?? null; } /** * Gets the statistics of a polling task. * * @param {string} id - The ID of the task to check. * @returns {Object|null} - An object with the following properties if the task exists, null otherwise: * - `totalRuns`: The total number of times the task has run. * - `totalErrors`: The total number of errors that have occurred. * - `lastError`: The last error that occurred. * - `lastResult`: The last result of the task. * - `lastRunTime`: The time when the task was last run. * - `retries`: The number of retries that have occurred. * - `successes`: The number of successful runs. * - `failures`: The number of failed runs. */ getTaskStats(id) { return this.tasks.get(id)?.getStats() ?? null; } /** * Checks if a polling task exists. * * @param {string} id - The ID of the task to check. * @returns {boolean} - True if the task exists, false otherwise. */ hasTask(id) { return this.tasks.has(id); } /** * Returns an array of the IDs of all tasks currently in the polling manager. * * @returns {string[]} - An array of task IDs. */ getTaskIds() { return Array.from(this.tasks.keys()); } /** * Clears all tasks from the polling manager. */ clearAll() { for (const task of this.tasks.values()) { task.stop(); } this.tasks.clear(); } /** * Restarts all tasks in the polling manager. */ restartAllTasks() { for (const task of this.tasks.values()) { task.stop(); task.start(); } } /** * Pauses all tasks in the polling manager. */ pauseAllTasks() { for (const task of this.tasks.values()) { task.pause(); } } /** * Resumes all tasks in the polling manager. */ resumeAllTasks() { for (const task of this.tasks.values()) { task.resume(); } } /** * Starts all tasks in the polling manager. */ startAllTasks() { for (const task of this.tasks.values()) { task.start(); } } /** * Stops all tasks in the polling manager. */ stopAllTasks() { for (const task of this.tasks.values()) { task.stop(); } } /** * Returns an object containing the statistics of all tasks in the polling manager. * * @returns {Object} - An object with the following properties: * - `stats`: An object containing the statistics of each task. */ getAllTaskStats() { const stats = {}; for (const [id, task] of this.tasks.entries()) { stats[id] = task.getStats(); } return stats; } } /** * TaskController manages the execution of tasks that can be scheduled, retried on failure, * and handle various lifecycle events such as start, stop, and data processing. * * @param {Object} options - Configuration options for the task. * @param {string} options.id - Unique identifier for the task. * @param {number} options.interval - Polling interval in milliseconds. * @param {Function|Function[]} options.fn - Function(s) to execute during the task. * @param {Function} [options.onData] - Callback for handling successful function results. * @param {Function} [options.onError] - Callback for handling function errors. * @param {Function} [options.onStart] - Callback for task start event. * @param {Function} [options.onStop] - Callback for task stop event. * @param {Function} [options.onFinish] - Callback for task finish event. * @param {Function} [options.onBeforeEach] - Callback before each function execution. * @param {Function} [options.onRetry] - Callback for each retry attempt. * @param {Function} [options.shouldRun] - Function to determine if the task should run. * @param {Function} [options.onSuccess] - Callback on successful execution. * @param {Function} [options.onFailure] - Callback on failure after max retries. * @param {string} [options.name=null] - Optional name for the task. * @param {boolean} [options.immediate=false] - Whether to run immediately on add. * @param {number} [options.maxRetries=0] - Maximum number of retry attempts. * @param {number} [options.backoffDelay=0] - Delay between retries in milliseconds. * @param {number} [options.taskTimeout=2000] - Timeout for each function execution. */ class TaskController { constructor({ id, interval, fn, onData, onError, onStart, onStop, onFinish, onBeforeEach, onRetry, shouldRun, onSuccess, onFailure, name = null, immediate = false, maxRetries = 0, backoffDelay = 0, taskTimeout = 2000 }) { this.id = id; 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.stats = { totalRuns: 0, totalErrors: 0, lastError: null, lastResult: null, lastRunTime: null, retries: 0, successes: 0, failures: 0 }; } /** * Starts the task. */ async start() { if (!this.stopped) return; this.stopped = false; this.loopRunning = true; this.onStart?.(); this._runLoop(); } /** * Stops the task. */ stop() { if (this.stopped) return; this.stopped = true; this.loopRunning = false; this.onStop?.(); } /** * Pauses the task without stopping it. */ pause() { this.paused = true; } /** * Resumes the task if it is paused. */ resume() { if (!this.stopped && this.paused) { this.paused = false; } } /** * Checks if the task is currently running. * * @returns {boolean} - True if the task is running, false otherwise. */ isRunning() { return !this.stopped; } /** * Checks if the task is currently paused. * * @returns {boolean} - True if the task is paused, false otherwise. */ isPaused() { return this.paused; } /** * Sets the interval for this task in milliseconds. * @param {number} ms - The interval in milliseconds. */ setInterval(ms) { this.interval = ms; } /** * Returns the current state of the task. * * @returns {Object} - An object containing the following properties: * - `stopped`: Whether the task is stopped. * - `paused`: Whether the task is paused. * - `running`: Whether the task is running. * - `inProgress`: Whether the task is currently in progress. */ getState() { return { stopped: this.stopped, paused: this.paused, running: this.loopRunning, inProgress: this.executionInProgress }; } /** * Returns the statistics of the task. * * @returns {Object} - An object containing the following properties: * - `totalRuns`: The total number of runs. * - `totalErrors`: The total number of errors. * - `lastError`: The last error that occurred. * - `lastResult`: The last result of the task. * - `lastRunTime`: The time of the last run. * - `retries`: The number of retries that have occurred. * - `successes`: The number of successful runs. * - `failures`: The number of failed runs. */ getStats() { return { ...this.stats }; } /** * Runs the task loop. */ async _runLoop() { let consecutiveCrcErrors = 0; // Счетчик CRC-ошибок подряд let backoffDelay = this.backoffDelay; // Базовое значение backoff while (!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.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; // Сброс счетчика CRC backoffDelay = this.backoffDelay; // Сброс backoff break; } catch (err) { retryCount++; this.stats.totalErrors++; this.stats.retries++; this.stats.lastError = err; this.onRetry?.(err, fnIndex, retryCount); // Лог CRC mismatch с hex-дампом const isCrc = err.message?.toLowerCase().includes('crc'); if (isCrc) { consecutiveCrcErrors++; // Авто-флуш при 3 CRC-ошибках подряд if (consecutiveCrcErrors >= 3) { if (this.fn[0]?.transport?.flush) { try { await this.fn[0].transport.flush(); console.warn(`Auto flushed after 3 CRC errors`); } catch (flushErr) { console.warn(`Auto flush failed: ${flushErr.message}`); } } } // Лог с hex-дампом console.error(`[CRC Mismatch] Task: ${this.id}, Retry: ${retryCount}, Error: ${err.message}`); } if (retryCount > this.maxRetries) { this.stats.failures++; this.onFailure?.(err); this.onError?.(err, fnIndex, retryCount); // Перезапуск транспорта при исчерпании retries if (this.fn[0]?.transport?.disconnect && this.fn[0]?.transport?.connect) { try { await this.fn[0].transport.disconnect(); await this.fn[0].transport.connect(); console.warn(`Transport restarted after max retries`); } catch (restartErr) { console.error(`Transport restart failed: ${restartErr.message}`); } } } else { // Экспоненциальный backoff const delay = backoffDelay * Math.pow(2, retryCount - 1); 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); await this._sleep(this.interval); } } /** * 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