UNPKG

modbus-connect

Version:

Modbus RTU over Web Serial and Node.js SerialPort

1,103 lines (953 loc) 36.8 kB
// src/transport/transport-controller.ts import Logger from '../logger.js'; import PollingManager from '../polling-manager.js'; import type { Transport, WebSerialPort, NodeSerialTransportOptions, WebSerialTransportOptions, DeviceStateHandler, PortStateHandler, RSMode, PollingTaskOptions, PollingTaskStats, PollingManagerConfig, PollingQueueInfo, } from '../types/modbus-types.js'; import { ConnectionErrorType } from '../types/modbus-types.js'; import { DeviceConnectionTracker } from './trackers/DeviceConnectionTracker.js'; import { PortConnectionTracker } from './trackers/PortConnectionTracker.js'; import { RSModeConstraintError } from '../errors.js'; import { allocUint8Array } from '../utils/utils.js'; interface TransportInfo { id: string; type: 'node' | 'web'; transport: Transport; pollingManager: PollingManager; status: 'disconnected' | 'connecting' | 'connected' | 'error'; slaveIds: number[]; rsMode: RSMode; fallbacks: string[]; createdAt: Date; lastError?: Error; reconnectAttempts: number; maxReconnectAttempts: number; reconnectInterval: number; } interface TransportStatus { id: string; connected: boolean; lastError?: Error; connectedSlaveIds: number[]; uptime: number; reconnectAttempts: number; pollingStats?: { queueLength: number; tasksRunning: number; }; } type LoadBalancerStrategy = 'round-robin' | 'sticky' | 'first-available'; /** * Контроллер транспорта для управления подключениями к устройствам. * Также управляет задачами опроса (PollingManager) для каждого транспорта. */ class TransportController { private transports: Map<string, TransportInfo> = new Map(); private slaveTransportMap: Map<number, string[]> = new Map(); private loadBalancerStrategy: LoadBalancerStrategy = 'first-available'; private loggerInstance = new Logger(); private logger = this.loggerInstance.createLogger('TransportController'); private _roundRobinIndex: number = 0; private readonly _stickyMap = new Map<number, string>(); private transportToDeviceTrackerMap: Map<string, DeviceConnectionTracker> = new Map(); private transportToPortTrackerMap: Map<string, PortConnectionTracker> = new Map(); private transportToDeviceHandlerMap: Map<string, DeviceStateHandler> = new Map(); private transportToPortHandlerMap: Map<string, PortStateHandler> = new Map(); private _externalDeviceStateHandler: DeviceStateHandler | null = null; private _externalPortStateHandler: PortStateHandler | null = null; constructor() { this.logger.setLevel('info'); } /** * Устанавливает обработчик состояния устройства для внешнего мира. * @param handler - Обработчик состояния */ public setDeviceStateHandler(handler: DeviceStateHandler): void { this._externalDeviceStateHandler = handler; } /** * Устанавливает обработчик состояния порта для внешнего мира. * @param handler - Обработчик состояния */ public setPortStateHandler(handler: PortStateHandler): void { this._externalPortStateHandler = handler; } /** * Добавляет транспорт и инициализирует его PollingManager. * @param id - ID транспорта * @param type - Тип транспорта ('node' или 'web') * @param options - Опции транспорта * @param reconnectOptions - Параметры переподключения * @param pollingConfig - Конфигурация для PollingManager */ async addTransport( id: string, type: 'node' | 'web', options: NodeSerialTransportOptions | (WebSerialTransportOptions & { port: WebSerialPort }), reconnectOptions?: { maxReconnectAttempts?: number; reconnectInterval?: number; }, pollingConfig?: PollingManagerConfig ): Promise<void> { if (this.transports.has(id)) { throw new Error(`Transport with id "${id}" already exists`); } const rsMode = options.RSMode ?? 'RS485'; const slaveIds = (options as any).slaveIds || []; if (rsMode === 'RS232' && slaveIds.length > 1) { throw new RSModeConstraintError( `Transport "${id}" with RSMode 'RS232' cannot be assigned more than one device. Provided ${slaveIds.length} devices.` ); } let transport: Transport; try { switch (type) { case 'node': { const path = (options as any).port || (options as any).path; if (!path) { throw new Error('Missing "port" (or "path") option for node transport'); } const NodeSerialTransport = (await import('./node-transports/node-serialport.js')) .default; const nodeOptions: NodeSerialTransportOptions = {}; const allowedNodeKeys = [ 'baudRate', 'dataBits', 'stopBits', 'parity', 'readTimeout', 'writeTimeout', 'maxBufferSize', 'reconnectInterval', 'maxReconnectAttempts', 'RSMode', ]; for (const key of allowedNodeKeys) { if (key in options) { (nodeOptions as any)[key] = (options as any)[key]; } } transport = new NodeSerialTransport(path, nodeOptions); break; } case 'web': { const webOptionsIn = options as WebSerialTransportOptions & { port: WebSerialPort }; const port = webOptionsIn.port; if (!port) { throw new Error('Missing "port" option for web transport'); } const WebSerialTransport = (await import('./web-transports/web-serialport.js')).default; const portFactory = async (): Promise<any> => { this.logger.debug('WebSerialTransport portFactory: Returning provided port instance'); try { if (port.readable || port.writable) { this.logger.debug( 'WebSerialTransport portFactory: Port seems to be in use, trying to close...' ); try { await port.close(); this.logger.debug('WebSerialTransport portFactory: Existing port closed'); } catch (closeErr: any) { this.logger.warn( 'WebSerialTransport portFactory: Error closing existing port (might be already closed or broken):', closeErr.message ); } } } catch (err: any) { this.logger.error( 'WebSerialTransport portFactory: Failed to prepare existing port for reuse:', err ); } return port; }; const webOptions: WebSerialTransportOptions = {}; const allowedWebKeys = [ 'baudRate', 'dataBits', 'stopBits', 'parity', 'readTimeout', 'writeTimeout', 'reconnectInterval', 'maxReconnectAttempts', 'maxEmptyReadsBeforeReconnect', 'RSMode', ]; for (const key of allowedWebKeys) { if (key in webOptionsIn) { (webOptions as any)[key] = (webOptionsIn as any)[key]; } } this.logger.debug('Creating WebSerialTransport with provided port'); transport = new WebSerialTransport(portFactory, webOptions); break; } default: throw new Error(`Unknown transport type: ${type}`); } } catch (err: any) { this.logger.error(`Failed to create transport of type "${type}": ${err.message}`); throw err; } const seenSlaveIds = new Set<number>(); for (const slaveId of slaveIds) { if (seenSlaveIds.has(slaveId)) { throw new Error( `Duplicate slave ID ${slaveId} provided for transport "${id}". Each slave ID must be unique per transport.` ); } seenSlaveIds.add(slaveId); } const fallbacks = (options as any).fallbacks || []; const pollingManager = new PollingManager(pollingConfig, this.loggerInstance); pollingManager.logger = this.loggerInstance.createLogger(`PM:${id}`); pollingManager.setLogLevelForAll('error'); const info: TransportInfo = { id, type, transport, pollingManager, status: 'disconnected', slaveIds, rsMode: transport.getRSMode(), fallbacks, createdAt: new Date(), reconnectAttempts: 0, maxReconnectAttempts: reconnectOptions?.maxReconnectAttempts ?? 5, reconnectInterval: reconnectOptions?.reconnectInterval ?? 2000, }; this.transports.set(id, info); const deviceTracker = new DeviceConnectionTracker(); const portTracker = new PortConnectionTracker(); this.transportToDeviceTrackerMap.set(id, deviceTracker); this.transportToPortTrackerMap.set(id, portTracker); transport.setDeviceStateHandler((slaveId, connected, error) => { this._onDeviceStateChange(id, slaveId, connected, error); }); transport.setPortStateHandler((connected, slaveIds, error) => { this._onPortStateChange(id, connected, slaveIds, error); }); this._updateSlaveTransportMap(id, slaveIds); this.logger.info(`Transport "${id}" added with PollingManager`, { type, slaveIds }); } /** * Удаляет транспорт по указанному ID. * @param id - ID транспорта */ async removeTransport(id: string): Promise<void> { const info = this.transports.get(id); if (!info) return; info.pollingManager.clearAll(); await this.disconnectTransport(id); this.transports.delete(id); this.transportToDeviceTrackerMap.delete(id); this.transportToPortTrackerMap.delete(id); this.transportToDeviceHandlerMap.delete(id); this.transportToPortHandlerMap.delete(id); for (const [slaveId, list] of this.slaveTransportMap.entries()) { const updated = list.filter(tid => tid !== id); if (updated.length === 0) { this.slaveTransportMap.delete(slaveId); } else { this.slaveTransportMap.set(slaveId, updated); } } this.logger.info(`Transport "${id}" removed`); } // ========================================================= // Методы управления PollingManager (Прокси) // ========================================================= private _getTransportInfo(transportId: string): TransportInfo { const info = this.transports.get(transportId); if (!info) throw new Error(`Transport "${transportId}" not found`); return info; } /** * Добавляет задачу опроса в указанный транспорт. * @param transportId - ID транспорта * @param options - Опции задачи */ public addPollingTask(transportId: string, options: PollingTaskOptions): void { const info = this._getTransportInfo(transportId); info.pollingManager.addTask(options); } /** * Удаляет задачу опроса из указанного транспорта. * @param transportId - ID транспорта * @param taskId - ID задачи */ public removePollingTask(transportId: string, taskId: string): void { const info = this._getTransportInfo(transportId); info.pollingManager.removeTask(taskId); } /** * Обновляет задачу опроса. * @param transportId - ID транспорта * @param taskId - ID задачи * @param newOptions - Новые опции */ public updatePollingTask( transportId: string, taskId: string, newOptions: Partial<PollingTaskOptions> ): void { const info = this._getTransportInfo(transportId); info.pollingManager.updateTask(taskId, newOptions); } /** * Управление состоянием конкретной задачи. * @param transportId - ID транспорта * @param taskId - ID задачи * @param action - Действие (start, stop, pause, resume) */ public controlTask( transportId: string, taskId: string, action: 'start' | 'stop' | 'pause' | 'resume' ): void { const info = this._getTransportInfo(transportId); switch (action) { case 'start': info.pollingManager.startTask(taskId); break; case 'stop': info.pollingManager.stopTask(taskId); break; case 'pause': info.pollingManager.pauseTask(taskId); break; case 'resume': info.pollingManager.resumeTask(taskId); break; } } /** * Управление всем опросом на транспорте. * @param transportId - ID транспорта * @param action - Действие (startAll, stopAll, pauseAll, resumeAll) */ public controlPolling( transportId: string, action: 'startAll' | 'stopAll' | 'pauseAll' | 'resumeAll' ): void { const info = this._getTransportInfo(transportId); switch (action) { case 'startAll': info.pollingManager.startAllTasks(); break; case 'stopAll': info.pollingManager.stopAllTasks(); break; case 'pauseAll': info.pollingManager.pauseAllTasks(); break; case 'resumeAll': info.pollingManager.resumeAllTasks(); break; } } /** * Получает статистику задач для транспорта. * @param transportId - ID транспорта * @returns Статистика задач */ public getPollingStats(transportId: string): Record<string, PollingTaskStats> { const info = this._getTransportInfo(transportId); return info.pollingManager.getAllTaskStats(); } /** * Получает информацию об очереди. * @param transportId - ID транспорта * @returns Информация об очереди */ public getPollingQueueInfo(transportId: string): PollingQueueInfo { const info = this._getTransportInfo(transportId); return info.pollingManager.getQueueInfo(); } /** * Позволяет выполнить функцию (например, запись) с использованием мьютекса PollingManager'а транспорта. * Это предотвращает конфликты между опросом и ручными командами. * @param transportId - ID транспорта * @param fn - Функция для выполнения * @returns Результат выполнения функции */ public async executeImmediate<T>(transportId: string, fn: () => Promise<T>): Promise<T> { const info = this._getTransportInfo(transportId); return info.pollingManager.executeImmediate(fn); } // ========================================================= /** * Получает транспорт по указанному ID. * @param id - ID транспорта * @returns Транспорт или null, если транспорт не найден */ getTransport(id: string): Transport | null { const info = this.transports.get(id); return info ? info.transport : null; } /** * Получает список всех транспортов. * @returns Массив объектов TransportInfo */ listTransports(): TransportInfo[] { return Array.from(this.transports.values()); } /** * Подключает все транспорты. */ async connectAll(): Promise<void> { const promises = Array.from(this.transports.values()).map(info => this.connectTransport(info.id) ); await Promise.all(promises); } /** * Отключает все транспорты. */ async disconnectAll(): Promise<void> { const promises = Array.from(this.transports.values()).map(info => this.disconnectTransport(info.id) ); await Promise.all(promises); } /** * Подключает транспорт по указанному ID. * @param id - ID транспорта */ async connectTransport(id: string): Promise<void> { const info = this.transports.get(id); if (!info) throw new Error(`Transport with id "${id}" not found`); if (info.status === 'connecting' || info.status === 'connected') return; info.status = 'connecting'; try { await info.transport.connect(); info.status = 'connected'; info.reconnectAttempts = 0; info.pollingManager.resumeAllTasks(); this.logger.info(`Transport "${id}" connected`); } catch (err) { info.status = 'error'; info.lastError = err instanceof Error ? err : new Error(String(err)); this.logger.error(`Failed to connect transport "${id}":`, info.lastError.message); if (info.reconnectAttempts < info.maxReconnectAttempts) { info.reconnectAttempts++; setTimeout( () => this.connectTransport(id), info.reconnectInterval * info.reconnectAttempts ); } else { this.logger.error(`Max reconnection attempts reached for "${id}"`); } throw err; } } /** * Отключает транспорт по указанному ID. * @param id - ID транспорта */ async disconnectTransport(id: string): Promise<void> { const info = this.transports.get(id); if (!info) return; try { info.pollingManager.pauseAllTasks(); await info.transport.disconnect(); info.status = 'disconnected'; this.logger.info(`Transport "${id}" disconnected`); } catch (err) { this.logger.error(`Error disconnecting transport "${id}":`, (err as Error).message); } } /** * Назначить slaveId транспорту. Если транспорт уже обслуживает этот slaveId — игнорирует. * @param transportId - ID транспорта * @param slaveId - ID устройства */ assignSlaveIdToTransport(transportId: string, slaveId: number): void { const info = this.transports.get(transportId); if (!info) { throw new Error(`Transport with id "${transportId}" not found`); } if (info.rsMode === 'RS232' && info.slaveIds.length >= 1) { const existingSlaveId = info.slaveIds[0]; throw new RSModeConstraintError( `Cannot assign slaveId ${slaveId} to transport "${transportId}". It is in 'RS232' mode and already manages device ${existingSlaveId}.` ); } if (info.slaveIds.includes(slaveId)) { throw new Error( `Cannot assign slave ID ${slaveId}". The transport is already managing this ID.` ); } info.slaveIds.push(slaveId); this._updateSlaveTransportMap(transportId, [slaveId]); this.logger.info(`Assigned slaveId ${slaveId} to transport "${transportId}"`); } /** * Удаляет slaveId из транспорта. * Позволяет отвязать устройство, чтобы потом подключить его заново. * @param transportId - ID транспорта * @param slaveId - ID устройства */ removeSlaveIdFromTransport(transportId: string, slaveId: number): void { const info = this.transports.get(transportId); if (!info) { this.logger.warn( `Attempted to remove slaveId ${slaveId} from non-existent transport "${transportId}"` ); return; } const index = info.slaveIds.indexOf(slaveId); if (index !== -1) { info.slaveIds.splice(index, 1); } else { this.logger.warn(`SlaveId ${slaveId} was not found in transport "${transportId}"`); return; } const transportList = this.slaveTransportMap.get(slaveId); if (transportList) { const updatedList = transportList.filter(tid => tid !== transportId); if (updatedList.length === 0) { this.slaveTransportMap.delete(slaveId); } else { this.slaveTransportMap.set(slaveId, updatedList); } } const stickyTransport = this._stickyMap.get(slaveId); if (stickyTransport === transportId) { this._stickyMap.delete(slaveId); } const tracker = this.transportToDeviceTrackerMap.get(transportId); if (tracker) { try { tracker.removeState(slaveId); } catch (err: any) { this.logger.warn( `Failed to remove state for slaveId ${slaveId} from tracker: ${err.message}` ); } } const transportAny = info.transport as any; if (typeof transportAny.removeConnectedDevice === 'function') { transportAny.removeConnectedDevice(slaveId); } this.logger.info(`Removed slaveId ${slaveId} from transport "${transportId}"`); } /** * Перезагружает транспорт с новыми опциями. * @param id - ID транспорта * @param options - Новые опции */ async reloadTransport( id: string, options: NodeSerialTransportOptions | (WebSerialTransportOptions & { port: WebSerialPort }) ): Promise<void> { const info = this.transports.get(id); if (!info) throw new Error(`Transport with id "${id}" not found`); const wasConnected = info.status === 'connected'; info.pollingManager.clearAll(); await this.disconnectTransport(id); let newTransport: Transport; try { switch (info.type) { case 'node': { const nodeOptionsIn = options as NodeSerialTransportOptions; const path = (nodeOptionsIn as any).port || (nodeOptionsIn as any).path; if (!path) { throw new Error('Missing "port" (or "path") option for node transport'); } const NodeSerialTransport = (await import('./node-transports/node-serialport.js')) .default; newTransport = new NodeSerialTransport(path, nodeOptionsIn); break; } case 'web': { const webOptionsIn = options as WebSerialTransportOptions & { port: WebSerialPort }; const port = webOptionsIn.port; if (!port) { throw new Error('Missing "port" option for web transport'); } const WebSerialTransport = (await import('./web-transports/web-serialport.js')).default; const portFactory = async (): Promise<any> => { return port; }; newTransport = new WebSerialTransport(portFactory, webOptionsIn); break; } default: throw new Error(`Unknown transport type: ${info.type}`); } } catch (err: any) { this.logger.error(`Failed to create new transport of type "${info.type}": ${err.message}`); throw err; } const deviceTracker = new DeviceConnectionTracker(); const portTracker = new PortConnectionTracker(); this.transportToDeviceTrackerMap.set(id, deviceTracker); this.transportToPortTrackerMap.set(id, portTracker); newTransport.setDeviceStateHandler((slaveId, connected, error) => { this._onDeviceStateChange(id, slaveId, connected, error); }); newTransport.setPortStateHandler((connected, slaveIds, error) => { this._onPortStateChange(id, connected, slaveIds, error); }); const deviceHandler = this.transportToDeviceHandlerMap.get(id); if (deviceHandler) { await deviceTracker.setHandler(deviceHandler); } const portHandler = this.transportToPortHandlerMap.get(id); if (portHandler) { await portTracker.setHandler(portHandler); } info.transport = newTransport; info.rsMode = newTransport.getRSMode(); if (wasConnected) { await this.connectTransport(id); } this.logger.info(`Transport "${id}" reloaded with new options`); } /** * Выполняет операцию записи (или любую другую команду, требующую эксклюзивного доступа) * на указанном транспорте, используя мьютекс PollingManager. * @param transportId - ID транспорта, на котором нужно выполнить запись. * @param data - Данные для записи (Uint8Array). * @param readLength - Ожидаемая длина ответа (если есть). * @param timeout - Таймаут на чтение ответа (в мс). * @returns Прочитанный ответ (Uint8Array) или пустой буфер, если readLength=0. */ public async writeToPort( transportId: string, data: Uint8Array, readLength: number = 0, timeout: number = 3000 ): Promise<Uint8Array> { const info = this._getTransportInfo(transportId); if (!info.transport.isOpen) { throw new Error( `Transport "${transportId}" is not open (connection status: ${info.status}).` ); } return info.pollingManager.executeImmediate(async () => { await (info.transport as any).write(data); if (readLength > 0) { return (info.transport as any).read(readLength, timeout); } await (info.transport as any).flush(); return allocUint8Array(0); }); } /** * Установить сопоставление slaveId -> [transportId, fallback1, ...] * Вызывается автоматически при addTransport. */ private _updateSlaveTransportMap(id: string, slaveIds: number[]): void { for (const slaveId of slaveIds) { const list = this.slaveTransportMap.get(slaveId) || []; if (!list.includes(id)) { list.push(id); this.slaveTransportMap.set(slaveId, list); } } } /** * Получить транспорт для конкретного slaveId. * Использует стратегию балансировки или fallback. */ getTransportForSlave(slaveId: number, requiredRSMode: RSMode): Transport | null { const transportIds = this.slaveTransportMap.get(slaveId); if (transportIds && transportIds.length > 0) { let transport: Transport | null = null; switch (this.loadBalancerStrategy) { case 'round-robin': transport = this._getTransportRoundRobin(transportIds); break; case 'sticky': transport = this._getTransportSticky(slaveId, transportIds); break; case 'first-available': default: transport = this._getTransportFirstAvailable(transportIds); break; } if (transport) { const info = Array.from(this.transports.values()).find(i => i.transport === transport); if (info && info.rsMode === requiredRSMode) { return transport; } } } for (const info of this.transports.values()) { if (info.status === 'connected' && info.rsMode === requiredRSMode) { if ( requiredRSMode === 'RS485' || (requiredRSMode === 'RS232' && info.slaveIds.length === 0) ) { return info.transport; } } } this.logger.warn( `No connected transport found for slave ${slaveId} with required RSMode ${requiredRSMode}` ); return null; } /** * Получить транспорт по стратегии round-robin */ private _getTransportRoundRobin(transportIds: string[]): Transport | null { const connectedTransports = transportIds .map(id => this.transports.get(id)) .filter((info): info is TransportInfo => !!info && info.status === 'connected'); if (connectedTransports.length === 0) { return this._getTransportFirstAvailable(transportIds); } this._roundRobinIndex = (this._roundRobinIndex + 1) % connectedTransports.length; const selectedInfo = connectedTransports[this._roundRobinIndex]; return selectedInfo?.transport ?? null; } /** * Получить транспорт по стратегии sticky */ private _getTransportSticky(slaveId: number, transportIds: string[]): Transport | null { const lastUsedId = this._stickyMap.get(slaveId); if (lastUsedId) { const info = this.transports.get(lastUsedId); if (info && info.status === 'connected' && transportIds.includes(lastUsedId)) { return info.transport; } } const transport = this._getTransportFirstAvailable(transportIds); if (transport) { const transportEntry = Array.from(this.transports.entries()).find( ([_id, info]) => info.transport === transport ); if (transportEntry) { const newTransportId = transportEntry[0]; this._stickyMap.set(slaveId, newTransportId); } } return transport; } /** * Получить транспорт по стратегии first-available */ private _getTransportFirstAvailable(transportIds: string[]): Transport | null { for (const id of transportIds) { const info = this.transports.get(id); if (info && info.status === 'connected') { return info.transport; } } for (const id of transportIds) { const info = this.transports.get(id); if (info && info.fallbacks) { for (const fallbackId of info.fallbacks) { const fallbackInfo = this.transports.get(fallbackId); if (fallbackInfo && fallbackInfo.status === 'connected') { return fallbackInfo.transport; } } } } return null; } /** * Получает статус транспорта по указанному ID. * @param id - ID транспорта * @returns Статус транспорта или пустой объект, если транспорт не найден */ getStatus(id?: string): TransportStatus | Record<string, TransportStatus> { if (id) { const info = this.transports.get(id); if (!info) return {} as TransportStatus; const queueInfo = info.pollingManager.getQueueInfo(); return { id: info.id, connected: info.status === 'connected', lastError: info.lastError, connectedSlaveIds: info.slaveIds, uptime: Date.now() - info.createdAt.getTime(), reconnectAttempts: info.reconnectAttempts, pollingStats: { queueLength: queueInfo.queueLength, tasksRunning: info.pollingManager.getSystemStats().tasks ? Object.keys(info.pollingManager.getSystemStats().tasks).length : 0, }, }; } const result: Record<string, TransportStatus> = {}; for (const [tid, info] of this.transports) { const queueInfo = info.pollingManager.getQueueInfo(); result[tid] = { id: info.id, connected: info.status === 'connected', lastError: info.lastError, connectedSlaveIds: info.slaveIds, uptime: Date.now() - info.createdAt.getTime(), reconnectAttempts: info.reconnectAttempts, pollingStats: { queueLength: queueInfo.queueLength, tasksRunning: info.pollingManager.getSystemStats().tasks ? Object.keys(info.pollingManager.getSystemStats().tasks).length : 0, }, }; } return result; } /** * Получает количество активных транспортов. * @returns Количество активных транспортов */ getActiveTransportCount(): number { let count = 0; for (const info of this.transports.values()) { if (info.status === 'connected') count++; } return count; } /** * Устанавливает стратегию балансировки. * @param strategy - Стратегия балансировки ('round-robin', 'sticky', 'first-available') */ setLoadBalancer(strategy: LoadBalancerStrategy): void { this.loadBalancerStrategy = strategy; } /** * Устанавливает обработчик состояния устройства для транспорта. * @param transportId - ID транспорта * @param handler - Обработчик состояния */ async setDeviceStateHandlerForTransport( transportId: string, handler: DeviceStateHandler ): Promise<void> { const tracker = this.transportToDeviceTrackerMap.get(transportId); if (!tracker) { throw new Error(`No device tracker found for transport "${transportId}"`); } await tracker.setHandler(handler); this.transportToDeviceHandlerMap.set(transportId, handler); } /** * Устанавливает обработчик состояния порта для транспорта. * @param transportId - ID транспорта * @param handler - Обработчик состояния */ async setPortStateHandlerForTransport( transportId: string, handler: PortStateHandler ): Promise<void> { const tracker = this.transportToPortTrackerMap.get(transportId); if (!tracker) { throw new Error(`No port tracker found for transport "${transportId}"`); } await tracker.setHandler(handler); this.transportToPortHandlerMap.set(transportId, handler); } /** * Внутренний метод: вызывается транспортом при изменении состояния устройства. */ private _onDeviceStateChange( transportId: string, slaveId: number, connected: boolean, error?: { type: string; message: string } ): void { const tracker = this.transportToDeviceTrackerMap.get(transportId); if (!tracker) { this.logger.warn(`No device tracker found for transport "${transportId}"`); return; } if (connected) { tracker.notifyConnected(slaveId); } else { const errorType = (error?.type as ConnectionErrorType) || ConnectionErrorType.UnknownError; const errorMessage = error?.message || 'Device disconnected'; tracker.notifyDisconnected(slaveId, errorType, errorMessage); } const handler = this.transportToDeviceHandlerMap.get(transportId); if (handler) { handler(slaveId, connected, error); } if (this._externalDeviceStateHandler) { this._externalDeviceStateHandler(slaveId, connected, error); } } /** * Внутренний метод: вызывается транспортом при изменении состояния порта. */ private _onPortStateChange( transportId: string, connected: boolean, slaveIds?: number[], error?: { type: string; message: string } ): void { const tracker = this.transportToPortTrackerMap.get(transportId); if (!tracker) { this.logger.warn(`No port tracker found for transport "${transportId}"`); return; } const info = this.transports.get(transportId); if (connected) { tracker.notifyConnected(); if (info) info.pollingManager.resumeAllTasks(); } else { const errorType = (error?.type as ConnectionErrorType) || ConnectionErrorType.UnknownError; const errorMessage = error?.message || 'Port disconnected'; tracker.notifyDisconnected(errorType, errorMessage, slaveIds); if (info) info.pollingManager.pauseAllTasks(); } if (info) { info.status = connected ? 'connected' : 'disconnected'; if (!connected) { info.lastError = new Error(error?.message); } } const handler = this.transportToPortHandlerMap.get(transportId); if (handler) { handler(connected, slaveIds, error as any); } if (this._externalPortStateHandler) { this._externalPortStateHandler(connected, slaveIds, error as any); } } /** * Уничтожает контроллер транспорта. */ async destroy(): Promise<void> { for (const info of this.transports.values()) { info.pollingManager.clearAll(); } await this.disconnectAll(); this.transports.clear(); this.slaveTransportMap.clear(); for (const tracker of this.transportToDeviceTrackerMap.values()) { await tracker.clear(); } for (const tracker of this.transportToPortTrackerMap.values()) { await tracker.clear(); } this.transportToDeviceTrackerMap.clear(); this.transportToPortTrackerMap.clear(); this.transportToDeviceHandlerMap.clear(); this.transportToPortHandlerMap.clear(); this._externalDeviceStateHandler = null; this._externalPortStateHandler = null; this.logger.info('TransportController destroyed'); } } export = TransportController;