UNPKG

xmihome

Version:

The core library for interacting with Xiaomi Mi Home devices via Cloud, MiIO, and Bluetooth.

897 lines (844 loc) 36.7 kB
import EventEmitter from 'events'; import Miot from './miot.js'; import { sleep } from './index.js'; import { NOTIFY_POLLING_INTERVAL, RECONNECT_INITIAL_DELAY, RECONNECT_MAX_DELAY, RECONNECT_FACTOR, RECONNECT_MAX_ATTEMPTS_SHORT, RECONNECT_MAX_ATTEMPTS_LONG } from './constants.js'; /** @import { XiaomiMiHome } from './index.js' */ /** * @typedef {Object} Config * @property {string} [id] ID устройства в облаке Xiaomi (для облачного подключения). * @property {string} [name] Имя устройства (для удобства). * @property {string} [model] Модель устройства. Если не указана, будет попытаться определиться автоматически. * @property {string} [address] IP-адрес устройства (для MiIO подключения). * @property {string} [mac] MAC-адрес устройства (используется для Bluetooth / BLE). * @property {string} [token] Токен устройства (для MiIO подключения). * @property {string} [bindkey] Ключ привязки BLE (MiBeacon bindkey, 16 байт в hex). */ /** @typedef {Config & { isOnline?: boolean }} DiscoveredDevice */ /** * @typedef {{ * service: string; * characteristic: string; * siid?: never; * piid?: never; * format?: never; * }} BluetoothProperty */ /** * @typedef {{ * siid: number; * piid: number; * format: 'bool'|'float'|'uint8'|'string'; * service?: never; * characteristic?: never; * }} MiotProperty */ /** * @typedef {{ * access: ('read'|'write'|'notify')[]; * read?: (buf: Buffer) => any; * notify?: (buf: Buffer) => any; * write?: (data: any) => Buffer; * key?: string; * } & (BluetoothProperty | MiotProperty)} Property */ /** * @typedef {{ * siid: number; * aiid: number; * in?: any[]; * key?: string; * }} Action */ /** * @typedef {object} UuidMapping * @property {Object<string, string>} services - Карта полных UUID сервисов в их 16-битные псевдонимы. * @property {Object<string, string>} characteristics - Карта полных UUID характеристик в их 16-битные псевдонимы. */ /** * @typedef {object} Schema * @property {string} [key] * @property {Array<{ * key: string, * type: 'text'|'number'|'date'|'select', * options?: string[] * }>} fields */ /** * Базовый класс для управления устройствами Xiaomi. * @extends EventEmitter */ export default class Device extends EventEmitter { /** * Список альтернативных названий устройства (алиасов). * @type {string[]} */ static alias = []; /** * Список поддерживаемых моделей устройств. * @type {string[]} */ static models = []; /** * Карта для преобразования полных 128-битных UUID в короткие 16-битные. * @type {UuidMapping} */ static uuidMap = { services: {}, characteristics: {} }; /** * Схема для дополнительных полей конфигурации, которые требуются устройству. * Используется UI для динамической генерации форм. Если null, форма не требуется. * @type {Schema|null} */ static schema = null; /** * Кэш для списка классов моделей, должен быть заполнен извне. * @type {Object.<string, typeof Device>} */ static #classes = {}; /** * Метод для регистрации классов моделей из специфичного для платформы пакета. * @param {Object.<string, typeof Device>} models */ static registerModels(models) { this.#classes = models; }; /** * Получает список доступных моделей устройств из директории `devices`. * @returns {string[]} */ static getModels() { return Object.keys(this.#classes); }; /** * Находит класс устройства в карте `deviceClasses` по объекту устройства. * Проверяет соответствие по `models` и `alias`. * @param {Config} device - Объект с данными обнаруженного устройства (должен иметь `model` или `name`). * @returns {typeof Device|undefined} - Найденный класс устройства или undefined. */ static findModel(device) { for (const model of Object.values(this.#classes)) { if (model.valid(device, model)) return model; } }; /** * Проверяет, соответствует ли переданное устройство (`device`) * данному классу модели (`model`), используя `models` или `alias` класса. * @param {Config} device Объект с данными обнаруженного или создаваемого устройства (должен иметь `model` или `name`). * @param {typeof Device} model Класс (конструктор) конкретной модели устройства для проверки. * @returns {boolean} `true`, если устройство соответствует модели, `false` в противном случае. */ static valid(device, model) { return device.model ? model.models?.includes(device.model) : model.alias?.includes(device.name); }; /** * Генерирует уникальный строковый ключ для идентификации экземпляра устройства. * Используется для кэширования экземпляров Device. * Приоритет: D-Bus path (для BT), MAC-адрес, IP-адрес, ID облака, объект device. * @param {Config & {path?: string}} device Объект конфигурации устройства. * @returns {string} Строковый ключ для устройства. */ static getDeviceId(device) { return device.path?.split('/')?.pop() || device.mac || device.address || device.id || JSON.stringify(device); }; /** * Определяет предполагаемый тип подключения для устройства на основе его конфигурации. * @param {Config} device Объект конфигурации устройства. * @param {object} [credentials] Объект с учетными данными (username, password) для проверки облачного подключения. * @returns {'miio'|'bluetooth'|'cloud'|undefined} Определенный тип подключения или undefined, если не удалось определить. */ static getDeviceType(device, credentials) { if (device.address && device.token && !device.id?.startsWith('blt.')) return 'miio'; if (device.mac && device.model) return 'bluetooth'; if (device.id && credentials?.username && credentials?.password) return 'cloud'; }; /** * Создает экземпляр класса Device или его подкласса в зависимости от модели устройства. * Если модель устройства не найдена в локальных файлах, пытается загрузить спецификацию модели с miot-spec.org. * @param {object} device Конфигурация устройства. * @param {XiaomiMiHome} client Экземпляр класса XiaomiMiHome. * @returns {Promise<Device>} Экземпляр класса Device или его подкласса. * @throws {Error} Если устройство не найдено. */ static async create(device, client) { if (!device || (!device.name && !device.model)) { client?.log('error', 'Device.create failed: Device object is invalid or missing model/name.', device); throw new Error('Device not found'); } client?.log('debug', `Device.create called for:`, device); let instance; const model = this.findModel(device); if (model) { client?.log('info', `Using specific device class "${model.name}" for model ${device.model || device.name}`); instance = new model(device, client); } else if (device.model) { const spec = await Miot.findModel(device.model).catch(() => {}); if (spec) { client?.log('info', `Using generic MIoT spec definition for model ${device.model}`); client?.log('debug', `MIoT Spec details:`, spec); instance = new (class extends Device { static name = spec.name; static spec = `https://home.miot-spec.com/spec?type=${spec.type}`; properties = spec.properties; actions = spec.actions; })(device, client); } } if (!instance) { client?.log('info', `Using base Device class for model ${device.model || device.name}`); instance = new this(device, client); } if (instance.properties) for (const key in instance.properties) { instance.properties[key].key = key; } if (instance.actions) for (const key in instance.actions) { instance.actions[key].key = key; } return instance; }; /** * Описание свойств устройства. * @type {Object.<string, Property>} */ properties = {}; /** * Описание действий устройства. * @type {Object.<string, Action>} */ actions = {}; /** * Тип подключения устройства. * Возможные значения: * - `miio` - Подключение через протокол MiIO (токен + IP). * - `bluetooth` - Подключение через Bluetooth (MAC-адрес + шаблон). * - `cloud` - Подключение через облако Xiaomi (логин/пароль + ID устройства). * - undefined - Подключение не установлено (по умолчанию). * @type {'miio'|'bluetooth'|'cloud'|undefined} */ connectionType; /** * Флаг, указывающий, что устройство в данный момент подключено. * @type {boolean} */ isConnected = false; /** * Хранилище для активных и желаемых подписок на уведомления. * Ключ - строковый идентификатор свойства (prop.key). * @type {Object.<string, {prop: object, callbacks: Function[], characteristic: object | null, timerId: NodeJS.Timeout | null}>} */ notify = {}; /** * Промис, представляющий текущую активную операцию подключения. * @type {Promise<void> | undefined} */ #connectionPromise; /** * Контроллер для отмены текущей операции подключения. * @type {AbortController | undefined} */ #connectionController; /** * Промис, представляющий текущий активный процесс автоматического переподключения. * @type {Promise<void> | undefined} */ #reconnectPromise; /** * Контроллер для отмены текущего процесса автоматического переподключения. * @type {AbortController | undefined} */ #reconnectController; /** * Промис, представляющий текущую активную операцию отключения. * @type {Promise<void> | undefined} */ #disconnectPromise; /** * Счетчик активных подписок на мониторинг * @type {number} */ #monitoringCount = 0; /** * Конфигурация устройства. * @type {Config} */ config = null; /** * Экземпляр класса XiaomiMiHome. * @type {XiaomiMiHome} */ client = null; /** * Объект устройства. * @type {Object|null} */ device = null; /** * Текущее состояние устройства (последние полученные параметры и RSSI). * @type {Object} */ #state = {}; /** * Конструктор класса Device. * @param {Config} config Конфигурация устройства. * @param {XiaomiMiHome} client Экземпляр класса XiaomiMiHome. * @throws {Error} Если не удалось определить модель устройства. */ constructor(config, client) { super(); this.client = client; this.config = config; this.client.log('debug', `Device instance ${this.constructor.name} created for config:`, config); if (!this.config.model) { this.config.model = this.getModel(); if (!this.config.model) throw new Error('Model value not passed'); } this.on('external_disconnect', this.#handleExternalDisconnect); }; /** * Приватный геттер для доступа к статике с правильными типами. * @returns {typeof Device} */ get class() { return (/** @type {typeof Device} */ (/** @type {unknown} */ (this.constructor))); }; /** * Указывает, находится ли устройство в процессе первоначального подключения или смены типа подключения. * @type {boolean} */ get isConnecting() { return !!this.#connectionController; }; /** * Указывает, находится ли устройство в процессе автоматического переподключения. * @type {boolean} */ get isReconnecting() { return !!this.#reconnectController; }; /** * Получает модель устройства. Если модель не указана в конфигурации, пытается получить первую модель из списка `this.constructor.models`. * @returns {string|undefined} Модель устройства или `undefined`, если не удалось определить. */ getModel() { return this.config && (this.config.model || this.class.models?.[0]); }; /** * Получает название модель устройства. * @returns {string} */ getName() { return this.config.name || this.config.model || this.config.id; }; /** * Выполняет специфичную для устройства логику аутентификации. */ async auth() {}; /** * Устанавливает соединение с устройством. * Тип подключения определяется в следующем порядке приоритета: * 1. Явно переданный `connectionType` в метод. * 2. Тип `connectionType`, указанный в основной конфигурации клиента (`client.config.connectionType`), если для него есть необходимые данные у устройства. * 3. Автоматическое определение на основе доступных параметров конфигурации устройства (`address`/`token`, `mac`/`model`, `id`/`credentials`). * @param {('miio'|'bluetooth'|'cloud')} [connectionType] Предпочитаемый тип подключения ('miio', 'bluetooth', 'cloud'). * @throws {Error} Если недостаточно данных для определения типа подключения или невозможно установить выбранный тип подключения. */ async connect(connectionType = this.client.config.connectionType) { if (this.isConnecting) return this.#connectionPromise; if (this.isConnected && this.device) { if (!connectionType || (connectionType === this.connectionType)) { this.client.log('warn', `Device "${this.getName()}" connection attempt ignored, already connected via ${this.connectionType}.`); return; } this.client.log('info', `Device "${this.getName()}" changing connection from ${this.connectionType} to ${connectionType}. Disconnecting first.`); await this.disconnect(); } if (this.#connectionController) this.#connectionController.abort(); this.#connectionController = new AbortController(); this.#connectionPromise = (async () => { const signal = this.#connectionController.signal; try { if (signal.aborted) throw new Error('Connection cancelled'); this.client.log('info', `Connecting to device "${this.getName()}" ${connectionType ? 'using specified type: ' + connectionType : '(auto-detecting type)'}`); if (!connectionType) { connectionType = this.class.getDeviceType(this.config, this.client.config.credentials); if (!connectionType) throw new Error('Недостаточно данных для определения типа подключения'); } else { if ( (connectionType === 'miio' && !(this.config.address && this.config.token)) || (connectionType === 'bluetooth' && !(this.config.mac && this.config.model)) || (connectionType === 'cloud' && !(this.config.id && this.client.config.credentials?.username && this.client.config.credentials?.password)) ) throw new Error(`Невозможно установить тип подключения: ${connectionType}`); } if (signal.aborted) throw new Error('Connection cancelled'); this.client.log('debug', `Attempting connection via ${connectionType}`); if (connectionType === 'miio') { this.client.log('debug', `Connecting via MiIO to ${this.config.address}`); this.device = await Promise.race([ this.client.miot.miio.device({ address: this.config.address, token: this.config.token }), new Promise((_, reject) => { signal.addEventListener('abort', () => reject(new Error('Connection cancelled'))); }) ]); } else if (connectionType === 'bluetooth') { this.client.log('debug', `Connecting via Bluetooth to ${this.config.mac}`); const device = await this.client.bluetooth.getDevice(this.config.mac); this.proxy = device['$object']; let retries = 3; while (true) { if (signal.aborted) throw new Error('Connection cancelled'); await sleep(500, signal); try { await Promise.race([ device.connect(), new Promise((_, reject) => { signal.addEventListener('abort', () => reject(new Error('Connection cancelled'))); }) ]); break; } catch (err) { if (signal.aborted || (--retries === 0)) throw err; await sleep(1000, signal); } } const id = this.class.getDeviceId(this.proxy); this.device = device; this.client.bluetooth.connected[id] = this; } else if (connectionType === 'cloud') { this.client.log('debug', `Connection type set to 'cloud' for device ${this.config.id}. Ready for requests.`); this.device = { id: this.config.id }; } if (signal.aborted) throw new Error('Connection cancelled'); this.connectionType = connectionType; try { await this.auth(); } catch (err) { this.client.log('error', `Authentication failed:`, err); try { await this.disconnect(); } catch (err) {} throw err; } this.isConnected = true; this.client.log('info', `Device "${this.getName()}" connected via: ${this.connectionType}`); this.emit('connected', this.connectionType); } catch (err) { if (signal.aborted) this.client.log('info', `Connection to device "${this.getName()}" was cancelled`); else this.client.log('error', `Failed to connect to device "${this.getName()}" via ${connectionType}:`, err); this.connectionType = undefined; this.device = null; this.proxy = null; this.isConnected = false; throw err; } finally { this.#connectionPromise = null; this.#connectionController = null; } })(); return this.#connectionPromise; }; /** * Разрывает соединение с устройством. */ async disconnect() { if (this.#disconnectPromise) { this.client.log('debug', `Device "${this.getName()}" disconnect already in progress. Returning existing promise.`); return this.#disconnectPromise; } this.#disconnectPromise = (async () => { try { if (this.isConnecting) { this.client.log('info', `Cancelling connection to device "${this.getName()}"`); this.#connectionController.abort(); await this.#connectionPromise.catch(() => {}); } if (this.isReconnecting) { this.client.log('info', `Cancelling reconnection process for device "${this.getName()}"`); this.#reconnectController.abort(); await this.#reconnectPromise.catch(() => {}); } if (!this.device || !this.isConnected) { this.client.log('warn', `Device "${this.getName()}" disconnect attempt ignored, not connected.`); return; } this.isConnected = false; this.client.log('info', `Disconnecting from device "${this.getName()}" (type: ${this.connectionType})`); for (const key in this.notify) { this.client.log('debug', `Stopping notifications for ${key} during disconnect.`); await this.stopNotify(key).catch(err => this.client.log('warn', `Error stopping notify for ${key} during disconnect:`, err)); } this.notify = {}; if (this.connectionType === 'miio') await this.device.destroy(); else if (this.connectionType === 'bluetooth') { const id = this.class.getDeviceId(this.proxy); await this.device.Disconnect(); delete this.client.bluetooth.connected[id]; } this.client.log('debug', `Device "${this.getName()}" disconnected successfully.`); } catch (err) { this.client.log('error', `Error during disconnection from "${this.getName()}":`, err); throw err; } finally { this.device = null; this.proxy = null; this.connectionType = undefined; this.emit('disconnect'); this.#disconnectPromise = null; } })(); return this.#disconnectPromise; }; /** * Получает значения свойств устройства. * Если `properties` не указан, запрашивает значения всех доступных для чтения свойств. * @param {any} [properties] Массив ключей свойств или объектов свойств для запроса. * @returns {Promise<object>} Объект, где ключи - это ключи свойств, а значения - их значения. */ async getProperties(properties) { let result = {}; if (!properties) properties = Object.values(this.properties).filter(prop => prop.access?.includes('read') || prop.read); if (properties.length) { if (this.connectionType === 'bluetooth') for (var prop of properties) { result[prop.key] = await this.getProperty(prop); } else for (var prop of await this.getProperty(properties)) { if (!prop.code) { const key = properties.find(({ siid, piid }) => ((siid === prop.siid) && (piid === prop.piid)))?.key || `${prop.siid}/${prop.piid}`; result[key] = prop.value; } } } return result; }; /** * Получает значение конкретного свойства устройства. * @param {string|Property} prop Ключ свойства или объект свойства. * @returns {Promise<object>} Значение свойства. */ async getProperty(prop) { let result; if (typeof prop === 'string') prop = this.properties[prop]; this.client.log('debug', `Getting property for "${this.getName()}" via ${this.connectionType}`, prop); if (this.connectionType === 'bluetooth') { if (!prop.access?.includes('read')) throw new Error('The property does not support read'); result = prop.read(await (await this.device.getCharacteristic(prop)).readValue()); } else { const params = [].concat(prop).map(({ siid, piid }) => ({ siid, piid })); if (this.connectionType === 'miio') result = await this.device.call('get_properties', params); else if (this.connectionType === 'cloud') result = await this.client.miot.request(`/home/rpc/${this.config.id}`, { params, method: 'get_properties' }).then(({ result }) => result); if (result && (prop.constructor === Object)) result = result[0].value; } this.client.log('debug', `Got property value:`, result); return result; }; /** * Устанавливает значение свойства устройства. * @param {string|Property} prop Ключ свойства или объект свойства. * @param {object} value Значение для установки. * @throws {Error} Если свойство не поддерживает запись. */ async setProperty(prop, value) { if (typeof prop === 'string') prop = this.properties[prop]; this.client.log('debug', `Setting property for "${this.getName()}" via ${this.connectionType}`, prop, value); try { if (!prop.access?.includes('write')) throw new Error('The property does not support write'); if (this.connectionType === 'bluetooth') await (await this.device.getCharacteristic(prop)).writeValue(prop.write(value)); else { if (this.connectionType === 'miio') await this.device.call('set_properties', [{ siid: prop.siid, piid: prop.piid, value }]); else if (this.connectionType === 'cloud') await this.client.miot.request(`/home/rpc/${this.config.id}`, { method: 'set_properties', params: [{ siid: prop.siid, piid: prop.piid, value }] }); } this.client.log('info', `Property set to '${value}' successfully for "${this.getName()}"`); } catch (err) { this.client.log('error', `Failed to set property for "${this.getName()}":`, err); throw err; } }; /** * Начинает прослушивание уведомлений об изменении значения свойства. * @param {string|Property} prop Ключ свойства или объект свойства. * @param {function} callback Функция обратного вызова, вызываемая при изменении значения свойства. * @throws {Error} Если свойство не поддерживает уведомления. */ async startNotify(prop, callback) { let lastValue = null; if (typeof prop === 'string') prop = this.properties[prop]; this.client.log('info', `Starting notifications for property '${prop.key}' on ${this.getName()}`); if (!prop.access?.includes('notify')) throw new Error('The property does not support notifications'); if (!this.notify[prop.key]) this.notify[prop.key] = { prop, callbacks: [], characteristic: null, timerId: null }; this.notify[prop.key].callbacks.push(callback); if (this.connectionType === 'bluetooth') { if (!this.notify[prop.key].characteristic) { this.notify[prop.key].characteristic = await this.device.getCharacteristic(prop); await this.notify[prop.key].characteristic.startNotifications(); } this.notify[prop.key].characteristic.on('valuechanged', (/** @type {any} */ buf) => { const value = (prop.notify || prop.read)(buf); const str = JSON.stringify(value); this.client.log('debug', `Received BT notification for '${prop.key}': raw=${buf?.toString('hex')}, parsed=${str}`); if (str !== lastValue) { lastValue = str; callback(value); } }); } else { if (!this.notify[prop.key].timerId) { const poll = async () => { try { const value = await this.getProperty(prop); const str = JSON.stringify(value) this.client.log('debug', `Polling property '${prop.key}': parsed=${str}`); if (str !== lastValue) { lastValue = str; this.notify[prop.key].callbacks.forEach(cb => cb(value)); } } catch (err) { this.client.log('error', `Error during polling for property '${prop.key}' on device "${this.getName()}":`, err); } this.notify[prop.key].timerId = setTimeout(poll, NOTIFY_POLLING_INTERVAL); }; await poll(); } else if (lastValue) callback(lastValue); } }; /** * Останавливает прослушивание уведомлений об изменении значения свойства. * @param {string|Property} prop Ключ свойства или объект свойства. */ async stopNotify(prop) { if (typeof prop === 'string') prop = this.properties[prop]; this.client.log('info', `Stopping notifications for property '${prop.key}' on "${this.getName()}"`); if (this.notify[prop.key]) { if (this.connectionType === 'bluetooth') { if (this.client.bluetooth.bus && this.notify[prop.key].characteristic) { this.notify[prop.key].characteristic.removeAllListeners('valuechanged'); try { await this.notify[prop.key].characteristic.stopNotifications(); } catch (err) { this.client.log('warn', `Error during StopNotify for ${prop.key} (bus may be closing):`, err); } } } else if (this.notify[prop.key].timerId) clearTimeout(this.notify[prop.key].timerId); delete this.notify[prop.key]; } this.client.log('debug', `Notifications stopped successfully for '${prop.key}'`); }; /** * Начинает мониторинг рекламных пакетов устройства без создания постоянного соединения. * @param {function} callback Функция обратного вызова. */ async startMonitoring(callback) { let lastValue = null; const connectionType = this.class.getDeviceType(this.config); if (connectionType !== 'bluetooth') throw new Error('Monitoring is only available for local Bluetooth devices'); this.#monitoringCount++; this.client.bluetooth.registerBindKey(this.config.mac, this.config.bindkey); this.client.bluetooth.on(`advertisement:${this.config.mac}`, msg => { const rssiChanged = this.#state.rssi === undefined || Math.abs(msg.rssi - this.#state.rssi) >= 10; const params = { ...this.#state.params, ...msg.payload }; const paramsStr = JSON.stringify(params); if (paramsStr !== lastValue || rssiChanged) { lastValue = paramsStr; this.#state = { params, rssi: msg.rssi }; callback({ ...msg, params: this.#state.params }); } }); await this.client.bluetooth.startMonitoring(); }; /** * Очищает подписки и останавливает мониторинг для данного устройства. */ async stopMonitoring() { this.client.bluetooth.removeAllListeners(`advertisement:${this.config.mac}`); while (this.#monitoringCount > 0) { await this.client.bluetooth.stopMonitoring(); this.#monitoringCount--; } }; /** * Вызывает действие на устройстве. * @param {string|Action} action Ключ действия или объект действия. * @param {any[]} [value] Массив входных параметров для действия. * @returns {Promise<object>} Результат выполнения действия. */ async callAction(action, value) { if (typeof action === 'string') action = this.actions[action]; if (!action) throw new Error('Action not found'); if (!value) value = action.in || []; this.client.log('debug', `Calling action for "${this.getName()}" via ${this.connectionType}`, action, value); try { const params = { did: this.config.id, siid: action.siid, aiid: action.aiid, in: value }; let result; if (this.connectionType === 'miio') result = await this.device.call('action', params); else if (this.connectionType === 'cloud') { result = await this.client.miot.request(`/miotspec/action`, { method: 'action', params }).then(({ result }) => result); } else throw new Error(`Actions are not supported for ${this.connectionType} connection type.`); this.client.log('info', `Action '${action.key}' called successfully for "${this.getName()}"`); return result; } catch (err) { this.client.log('error', `Failed to call action for "${this.getName()}":`, err); throw err; } }; /** * Обрабатывает ситуацию, когда соединение с устройством было разорвано извне * (например, по сигналу от D-Bus для Bluetooth или при ошибке сети для MiIO/Cloud). * Очищает состояние устройства и, если были активные подписки, * пытается автоматически переподключиться. * @param {string} reason Причина внешнего дисконнекта. */ async #handleExternalDisconnect(reason) { if (!this.isConnected || this.isConnecting) { this.client.log('debug', `Device "${this.getName()}" #handleExternalDisconnect skipped: not connected, no device, or already reconnecting.`); return; } if (this.#reconnectController) this.#reconnectController.abort(); this.client.log('warn', `Device "${this.getName()}" was externally disconnected. Reason: ${reason}.`); const connectionType = this.connectionType; const notify = []; for (const key in this.notify) { for (const callback of this.notify[key].callbacks) { notify.push({ callback, prop: this.notify[key].prop }); } } await this.disconnect(); this.emit('reconnecting', { reason }); this.#reconnectController = new AbortController(); this.#reconnectPromise = (async () => { const signal = this.#reconnectController.signal; try { if (signal.aborted) throw new Error('Connection cancelled'); let currentAttempt = 0; let currentDelay = RECONNECT_INITIAL_DELAY; while (!signal.aborted && !this.isConnected) { currentAttempt++; const isShortAttemptPhase = currentAttempt <= RECONNECT_MAX_ATTEMPTS_SHORT; const maxAttemptsInPhase = isShortAttemptPhase ? RECONNECT_MAX_ATTEMPTS_SHORT : RECONNECT_MAX_ATTEMPTS_SHORT + RECONNECT_MAX_ATTEMPTS_LONG; if (currentAttempt > maxAttemptsInPhase) { this.client.log('error', `All ${maxAttemptsInPhase} reconnect attempts failed for "${this.getName()}". Automatic reconnection for this event stopped. Next operation will attempt to connect.`); this.emit('reconnect_failed', { attempts: maxAttemptsInPhase }); break; } try { this.client.log('info', `Reconnect attempt ${currentAttempt}/${maxAttemptsInPhase} for "${this.getName()}" (phase: ${isShortAttemptPhase ? 'short' : 'long'}) using type ${connectionType}.`); if (signal.aborted) throw new Error('Reconnection cancelled'); await this.connect(connectionType); this.client.log('info', `Device "${this.getName()}" reconnected successfully on attempt ${currentAttempt}.`); } catch (err) { if (signal.aborted) throw new Error('Reconnection cancelled'); if (isShortAttemptPhase && (currentAttempt === RECONNECT_MAX_ATTEMPTS_SHORT) && (RECONNECT_MAX_ATTEMPTS_LONG > 0)) { this.client.log('info', `Switching to long reconnect attempts for "${this.getName()}"`); currentDelay = RECONNECT_MAX_DELAY; } else if (currentAttempt > 1) currentDelay = Math.min(Math.floor(currentDelay * RECONNECT_FACTOR), RECONNECT_MAX_DELAY); this.client.log('debug', `Waiting ${currentDelay / 1_000}s before next reconnect attempt for "${this.getName()}".`); await sleep(currentDelay, signal); } } if (this.isConnected && (notify.length > 0)) { if (signal.aborted) throw new Error('Reconnection cancelled'); this.client.log('info', `Restoring ${notify.length} subscriptions for "${this.getName()}" after successful reconnect.`); for (const { prop, callback } of notify) { if (signal.aborted) throw new Error('Reconnection cancelled'); await this.startNotify(prop, callback); } } } catch (err) { if (!signal.aborted) this.client.log('error', `Reconnection process failed for "${this.getName()}":`, err); this.emit('reconnect_failed', { error: err.message }); throw err; } finally { this.#reconnectPromise = null; this.#reconnectController = null; } })(); return this.#reconnectPromise; }; };