modbus-connect
Version:
Modbus RTU over Web Serial and Node.js SerialPort
260 lines (233 loc) • 8.7 kB
text/typescript
// src/transport/trackers/DeviceConnectionTracker.ts
import { Mutex } from 'async-mutex';
import {
DeviceStateHandler,
DeviceConnectionStateObject,
DeviceConnectionTrackerOptions,
ConnectionErrorType,
} from '../../types/modbus-types.js';
/**
* Отслеживает состояние подключения Modbus-устройств (slave).
* Поддерживает:
* - Уведомления с debounce
* - Потокобезопасность через `async-mutex`
* - Валидацию `slaveId` (1–255)
* - Иммутабельные возвращаемые данные
* - Отключение обработчика
*/
export class DeviceConnectionTracker {
private _handler?: DeviceStateHandler;
private readonly _states = new Map<number, DeviceConnectionStateObject>();
private readonly _debounceMs: number;
private readonly _validateSlaveId: boolean;
private readonly _mutex = new Mutex();
private readonly _debounceTimeouts = new Map<number, NodeJS.Timeout>();
/**
* Создаёт экземпляр трекера состояний.
*
* @param options Настройки трекера
* @param options.debounceMs Интервал дебонса уведомлений об отключении (мс), по умолчанию: `500`
* @param options.validateSlaveId Валидировать `slaveId` (1–255), по умолчанию: `true`
*/
constructor(options: DeviceConnectionTrackerOptions = {}) {
this._debounceMs = options.debounceMs ?? 500;
this._validateSlaveId = options.validateSlaveId ?? true;
}
/**
* Устанавливает обработчик изменения состояния устройства.
* При установке — вызывает обработчик для всех текущих состояний.
*
* @param handler Функция: `(slaveId: number, connected: boolean, error?) => void`
*/
public async setHandler(handler: DeviceStateHandler): Promise<void> {
const release = await this._mutex.acquire();
try {
this._handler = handler;
for (const state of this._states.values()) {
handler(
state.slaveId,
state.hasConnectionDevice,
state.hasConnectionDevice
? undefined
: { type: state.errorType!, message: state.errorMessage! }
);
}
} finally {
release();
}
}
/**
* Удаляет обработчик изменения состояния.
* После вызова — уведомления прекращаются.
*/
public async removeHandler(): Promise<void> {
const release = await this._mutex.acquire();
try {
this._handler = undefined;
} finally {
release();
}
}
/**
* Уведомляет о подключении устройства.
* Игнорируется, если устройство уже подключено.
*
* @param slaveId Идентификатор устройства (1–255)
*/
public async notifyConnected(slaveId: number): Promise<void> {
if (this._validateSlaveId && (slaveId < 1 || slaveId > 255)) return;
const release = await this._mutex.acquire();
try {
const existingTimeout = this._debounceTimeouts.get(slaveId);
if (existingTimeout) {
clearTimeout(existingTimeout);
this._debounceTimeouts.delete(slaveId);
}
const existing = this._states.get(slaveId);
if (existing?.hasConnectionDevice) {
return;
}
const state: DeviceConnectionStateObject = {
slaveId,
hasConnectionDevice: true,
};
this._states.set(slaveId, state);
this._handler?.(slaveId, true);
} finally {
release();
}
}
/**
* Уведомляет об отключении устройства с **trailing debounce**.
* Последний вызов в серии будет выполнен через `debounceMs`.
*
* @param slaveId Идентификатор устройства (1–255)
* @param errorType Тип ошибки, по умолчанию: `UnknownError`
* @param errorMessage Подробное сообщение, по умолчанию: `'Device disconnected'`
*/
public notifyDisconnected(
slaveId: number,
errorType: ConnectionErrorType = ConnectionErrorType.UnknownError,
errorMessage: string = 'Device disconnected'
): void {
if (this._validateSlaveId && (slaveId < 1 || slaveId > 255)) return;
const existingTimeout = this._debounceTimeouts.get(slaveId);
if (existingTimeout) clearTimeout(existingTimeout);
const timeout = setTimeout(() => {
this._debounceTimeouts.delete(slaveId);
this._doNotifyDisconnected(slaveId, errorType, errorMessage);
}, this._debounceMs);
this._debounceTimeouts.set(slaveId, timeout);
}
/**
* Полностью удаляет состояние устройства из памяти трекера.
* СИНХРОННЫЙ метод. Используется при принудительном удалении устройства из конфигурации.
* Гарантирует, что при следующем notifyConnected событие будет отправлено.
*
* @param slaveId Идентификатор устройства (1–255)
*/
public removeState(slaveId: number): void {
const existingTimeout = this._debounceTimeouts.get(slaveId);
if (existingTimeout) {
clearTimeout(existingTimeout);
this._debounceTimeouts.delete(slaveId);
}
this._states.delete(slaveId);
}
/**
* Выполняет фактическое уведомление об отключении (внутренний метод).
*/
private async _doNotifyDisconnected(
slaveId: number,
errorType: ConnectionErrorType,
errorMessage: string
): Promise<void> {
const release = await this._mutex.acquire();
try {
const existing = this._states.get(slaveId);
if (existing && !existing.hasConnectionDevice && existing.errorType === errorType) {
return;
}
const newState: DeviceConnectionStateObject = {
slaveId,
hasConnectionDevice: false,
errorType,
errorMessage,
};
this._states.set(slaveId, newState);
this._handler?.(slaveId, false, { type: errorType, message: errorMessage });
} finally {
release();
}
}
/**
* Возвращает копию состояния конкретного устройства.
*/
public async getState(slaveId: number): Promise<DeviceConnectionStateObject | undefined> {
const release = await this._mutex.acquire();
try {
const state = this._states.get(slaveId);
return state ? { ...state } : undefined;
} finally {
release();
}
}
/**
* Возвращает копии всех текущих состояний устройств.
*/
public async getAllStates(): Promise<DeviceConnectionStateObject[]> {
const release = await this._mutex.acquire();
try {
return Array.from(
this._states.values(),
({ slaveId, hasConnectionDevice, errorType, errorMessage }) => ({
slaveId,
hasConnectionDevice,
errorType,
errorMessage,
})
);
} finally {
release();
}
}
/**
* Очищает все состояния и отменяет все таймеры дебонса.
*/
public async clear(): Promise<void> {
const release = await this._mutex.acquire();
try {
this._states.clear();
this._debounceTimeouts.forEach(clearTimeout);
this._debounceTimeouts.clear();
this._handler = undefined;
} finally {
release();
}
}
/**
* Проверяет, отслеживается ли устройство.
*/
public hasState(slaveId: number): boolean {
return this._states.has(slaveId);
}
/**
* Возвращает список `slaveId` всех подключённых устройств.
*/
public getConnectedSlaveIds(): number[] {
return Array.from(this._states.entries())
.filter(([, s]) => s.hasConnectionDevice)
.map(([id]) => id);
}
/**
* Сбрасывает таймер дебонса для указанного устройства.
* **Только для тестов.**
*
* @internal
*/
public __resetDebounce(slaveId: number): void {
const timeout = this._debounceTimeouts.get(slaveId);
if (timeout) clearTimeout(timeout);
this._debounceTimeouts.delete(slaveId);
}
}