modbus-connect
Version:
Modbus RTU over Web Serial and Node.js SerialPort
629 lines (568 loc) • 22 kB
JavaScript
// 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