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