UNPKG

xmihome

Version:

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

769 lines (730 loc) 29.2 kB
import EventEmitter from 'events'; import MiBeacon from './mibeacon.js'; import { UUID, GET_DEVICE_DISCOVERY_TIMEOUT } from './constants.js'; import { createFallbackProxy } from './index.js'; /** @import { XiaomiMiHome } from './index.js' */ /** @import { default as Device, Config as DeviceConfig } from './device.js' */ /** * Класс-обертка для GATT-характеристики. */ export class BluetoothCharacteristic { /** * @param {import('dbus-next').ClientInterface} dbusInterface - Интерфейс org.bluez.GattCharacteristic1 */ constructor(dbusInterface) { this.dbusInterface = dbusInterface; }; /** * Включает уведомления для этой характеристики. */ async startNotifications() { return this.dbusInterface.StartNotify(); }; /** * Отключает уведомления для этой характеристики. */ async stopNotifications() { return this.dbusInterface.StopNotify(); }; /** * Читает значение характеристики. * @returns {Promise<Buffer>} Промис, который разрешится буфером со значением. */ async readValue() { return this.dbusInterface.ReadValue({}); }; /** * Записывает значение в характеристику. * @param {Buffer} buffer - Буфер данных для записи. */ async writeValue(buffer) { return this.dbusInterface.WriteValue(buffer, {}); }; }; /** * Класс-обертка для Bluetooth-устройства. */ export class BluetoothDevice { /** * @typedef {Object<string, { * path: string, * characteristics: Object<string, {path: string, flags: string[]}> * }>} GattProfile */ /** @type {GattProfile|null} */ gattProfile = null; /** * @param {import('dbus-next').ClientInterface} dbusInterface - Интерфейс org.bluez.Device1 * @param {import('dbus-next').ProxyObject} proxy - Прокси-объект устройства * @param {Bluetooth} bluetooth - Экземпляр класса Bluetooth. */ constructor(dbusInterface, proxy, bluetooth) { this.dbusInterface = dbusInterface; this.proxy = proxy; this.bluetooth = bluetooth; this.client = bluetooth.client; this.objectManager = null; this.characteristics = new Map(); }; /** * Возвращает уникальный идентификатор устройства в формате D-Bus (dev_XX_XX_XX_...). * Этот ID извлекается из пути D-Bus объекта. * @type {string} */ get id() { return this.proxy.path.split('/').pop(); }; /** * Устанавливает соединение с устройством. * @returns {Promise<void>} */ async connect() { const sec = process.env.DBUS_SYSTEM_BUS_ADDRESS ? 30 : 10; const properties = this.proxy.getInterface('org.freedesktop.DBus.Properties'); const checkResolved = async () => { try { const { value } = await properties.Get('org.bluez.Device1', 'ServicesResolved'); return value; } catch { return false; } }; if (await checkResolved()) return; return new Promise(async (resolve, reject) => { let timerId; const onPropertiesChanged = (/** @type {any} */ changedProps) => { if (changedProps.ServicesResolved) { cleanup(); return resolve(); } }; const cleanup = () => { clearTimeout(timerId); this.bluetooth.off(`properties:${this.id}`, onPropertiesChanged); }; timerId = setTimeout(() => { cleanup(); reject(new Error(`Timed out after ${sec}s waiting for services to be resolved.`)); }, sec * 1000); this.bluetooth.on(`properties:${this.id}`, onPropertiesChanged); try { await this.dbusInterface.Connect(); if (await checkResolved()) { cleanup(); return resolve(); } } catch (err) { if (err.type === 'org.bluez.Error.InProgress' || err.type === 'org.bluez.Error.AlreadyConnected') { if (await checkResolved()) { cleanup(); return resolve(); } } else { cleanup(); return reject(err); } } }); }; /** * Разрывает соединение с устройством. * @returns {Promise<void>} */ async disconnect() { return this.dbusInterface.Disconnect(); }; /** * Получает и кэширует экземпляр обертки для GATT-характеристики. * Метод является универсальным и поддерживает два режима работы: * 1. Поиск по UUID: передайте { service: 'uuid', characteristic: 'uuid' }. * 2. Прямое формирование пути: передайте { service: '0004', characteristic: '000f' }. * @param {object} props - Описание характеристики. * @param {string} props.service - UUID сервиса или его короткий ID для пути. * @param {string} props.characteristic - UUID характеристики или ее короткий ID для пути. * @returns {Promise<BluetoothCharacteristic>} Прокси-объект характеристики. * @throws {Error} Если характеристика не найдена. */ async getCharacteristic({ service, characteristic }) { let path; const uuidMap = this.bluetooth.connected[this.id]?.class?.uuidMap; if (uuidMap) { if (service.includes('-') && uuidMap.services?.[service]) service = uuidMap.services[service]; if (characteristic.includes('-') && uuidMap.characteristics?.[characteristic]) characteristic = uuidMap.characteristics[characteristic]; } if (service.includes('-') || characteristic.includes('-')) { await this.discoverGattProfile(); const serviceInfo = this.gattProfile[service]; if (!serviceInfo) throw new Error(`Service with UUID ${service} not found on device.`); const charInfo = serviceInfo.characteristics[characteristic]; if (!charInfo) throw new Error(`Characteristic with UUID ${characteristic} not found in service ${service}.`); path = charInfo.path; } else path = `${this.proxy.path}/service${service}/char${characteristic}`; if (this.characteristics.has(path)) return this.characteristics.get(path); const proxy = await this.proxy.bus.getProxyObject('org.bluez', path); const iface = proxy.getInterface('org.bluez.GattCharacteristic1') const charProxied = createFallbackProxy(new BluetoothCharacteristic(iface), iface); this.characteristics.set(path, charProxied); return charProxied; }; /** * Обнаруживает и кэширует полный GATT-профиль устройства (все сервисы и характеристики). * Этот метод является относительно дорогостоящей операцией и должен вызываться только при необходимости. * Результаты кэшируются для последующих вызовов. * @returns {Promise<GattProfile>} */ async discoverGattProfile() { if (this.gattProfile) { this.client?.log('debug', `GATT profile for ${this.id} already cached. Returning cached version.`); return this.gattProfile; } this.client?.log('info', `Discovering GATT profile for device ${this.id}...`); if (!this.objectManager) { this.client?.log('debug', `D-Bus ObjectManager not found, getting it now.`); const bluezProxy = await this.proxy.bus.getProxyObject('org.bluez', '/'); this.objectManager = bluezProxy.getInterface('org.freedesktop.DBus.ObjectManager'); } const managedObjects = await this.objectManager.GetManagedObjects(); this.client?.log('debug', `Got ${Object.keys(managedObjects).length} managed objects from D-Bus.`); const /** @type {GattProfile} */ services = {}; const characteristicsByServicePath = {}; for (const path in managedObjects) { if (!path.startsWith(this.proxy.path)) continue; const interfaces = managedObjects[path]; const serviceInterface = interfaces['org.bluez.GattService1']; const charInterface = interfaces['org.bluez.GattCharacteristic1']; if (serviceInterface) { const uuid = serviceInterface.UUID.value; this.client?.log('debug', `Found GATT Service: UUID=${uuid}, Path=${path}`); services[uuid] = { path, characteristics: {} }; characteristicsByServicePath[path] = services[uuid].characteristics; } else if (charInterface) { const parentServicePath = charInterface.Service.value; const parentServiceChars = characteristicsByServicePath[parentServicePath]; if (parentServiceChars) { const uuid = charInterface.UUID.value; const flags = charInterface.Flags.value; this.client?.log('debug', ` - Found GATT Characteristic: UUID=${uuid}, Flags=[${flags.join(', ')}], Path=${path}`); parentServiceChars[uuid] = { path, flags }; } else this.client?.log('warn', `Found characteristic ${path} but its parent service ${parentServicePath} was not found in the map.`); } } this.gattProfile = services; this.client?.log('info', `GATT profile discovery complete for ${this.id}. Found ${Object.keys(services).length} services.`); return this.gattProfile; }; }; /** * Класс для взаимодействия с Bluetooth LE устройствами. * @extends EventEmitter */ export default class Bluetooth extends EventEmitter { /** * Объект для хранения подключенных Bluetooth устройств, где ключ - это ID устройства. * @type {Object.<string, Device>} */ connected = {}; /** * Объект для хранения обнаруженных Bluetooth устройств, где ключ - это ID устройства. * @type {Object.<string, DeviceConfig & {path: string}>} */ devices = {}; /** * Фильтры UUID для поиска Bluetooth устройств. * @type {string[]|null} */ filters = null; /** * Карта соответствия MAC-адреса и bindkey. * @type {Map<string, Buffer>} */ bindKeys = new Map(); /** * Флаг, указывающий, выполняется ли в данный момент обнаружение Bluetooth устройств. * @type {boolean} */ isDiscovering = false; /** * Флаг активного мониторинга рекламных пакетов. * @type {boolean} */ isMonitoring = false; /** * Счетчик активных запросов на обнаружение. * @type {number} */ #discoveringCount = 0; /** * Счетчик активных подписок на мониторинг * @type {number} */ #monitoringCount = 0; /** * Кэш последних значений RSSI для каждого устройства. * @type {Map<string, number>} */ #rssi = new Map(); /** * Экземпляр класса XiaomiMiHome. * @type {XiaomiMiHome} */ client = null; /** * Создает и инициализирует экземпляр класса Bluetooth. * @returns {Promise<Bluetooth>} Экземпляр класса Bluetooth. */ static async createBluetooth() { const bluetooth = new this(); await bluetooth.defaultAdapter(); process.once('SIGINT', async () => { await bluetooth.destroy(); process.exit(130); }); process.once('uncaughtException', async err => { await bluetooth.destroy(); throw err; }); return bluetooth; }; /** * Регистрирует bindkey для устройства. * @param {string} mac MAC-адрес устройства. * @param {string} bindkey Ключ. */ registerBindKey(mac, bindkey) { if (mac && bindkey) this.bindKeys.set(mac.toUpperCase(), Buffer.from(bindkey, 'hex')); }; /** * Конструктор класса Bluetooth. * @param {XiaomiMiHome} [client] Экземпляр класса XiaomiMiHome. */ constructor(client) { super(); this.client = client; client?.config?.devices?.forEach(({ mac, bindkey }) => this.registerBindKey(mac, bindkey)); }; /** * Проверяет доступность BlueZ сервиса в системе. * @returns {Promise<boolean>} true если сервис доступен, false в противном случае. */ async checkBlueZService() { if (process.env.DBUS_SYSTEM_BUS_ADDRESS) return true; try { const dbus = await import('dbus-next'); const bus = dbus.systemBus(); const dbusProxy = await bus.getProxyObject('org.freedesktop.DBus', '/org/freedesktop/DBus'); const dbusInterface = dbusProxy.getInterface('org.freedesktop.DBus'); const services = await dbusInterface.ListNames(); const isBlueZAvailable = services.includes('org.bluez'); bus.disconnect(); return isBlueZAvailable; } catch (err) { this.client?.log('debug', `Failed to check BlueZ service availability:`, err); return false; } }; /** * Инициализирует адаптер Bluetooth по умолчанию (обновленная версия). * @param {string} [device='hci0'] Имя адаптера Bluetooth. * @returns {Promise<object>} Интерфейс адаптера Bluetooth. * @throws {Error} Если нет доступа к Bluetooth сервисам через D-Bus. */ async defaultAdapter(device = 'hci0') { this.client?.log('info', `Initializing Bluetooth adapter: ${device}`); const isBlueZAvailable = await this.checkBlueZService(); if (!isBlueZAvailable) throw new Error([ 'Bluetooth Service Unavailable', 'The BlueZ Bluetooth service is not running or not available on this system.', 'To fix this issue, try the following steps:', '1. Install BlueZ if it\'s not installed:', ' sudo apt-get install bluez # On Debian/Ubuntu', ' sudo yum install bluez # On RHEL/CentOS', '2. Start the Bluetooth service:', ' sudo systemctl start bluetooth', ' sudo systemctl enable bluetooth', '3. Check if your Bluetooth adapter is available:', ' hciconfig', ' sudo hciconfig hci0 up', '4. Verify the service is running:', ' systemctl status bluetooth' ].join('\n')); try { const dbus = await import('dbus-next'); this.device = device; this.path = `/org/bluez/${device}`; this.client?.log('debug', `Connecting to D-Bus system bus for Bluetooth`); this.bus = await new Promise((resolve, reject) => { try { let /** @type {any} */ options; if (process.env.DBUS_SYSTEM_BUS_ADDRESS) options = { authMethods: ['ANONYMOUS', 'EXTERNAL'] }; const bus = dbus.systemBus(options); bus.once('message', () => resolve(bus)); bus.once('error', err => reject(err)); } catch (err) { reject(err); } }); this.client?.log('debug', `Getting D-Bus proxy object for org.bluez at ${this.path}`); try { this.bluez = await this.bus.getProxyObject('org.bluez', this.path); } catch (err) { if (err.type === 'org.freedesktop.DBus.Error.AccessDenied') { const { fileURLToPath } = await import('url') const path = await import('path'); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(path.join(__filename, '..')); throw new Error([ 'Bluetooth Access Denied', 'Your user account doesn\'t have permission to access Bluetooth services via D-Bus.', '', 'To fix this issue, run the following command:', `sudo cp ${__dirname}/xmihome_bluetooth.conf /etc/dbus-1/system.d/`, '', 'After running this command, restart the Bluetooth service with:', 'sudo systemctl restart bluetooth', '', 'This will grant your user the necessary Bluetooth permissions.' ].join('\n')); } throw err; } this.client?.log('debug', `Getting D-Bus interface org.bluez.Adapter1`); this.adapter = this.bluez.getInterface('org.bluez.Adapter1'); this.client?.log('debug', `Adding D-Bus signal match`); await this.bus.call(new dbus.Message({ destination: 'org.freedesktop.DBus', path: '/org/freedesktop/DBus', interface: 'org.freedesktop.DBus', member: 'AddMatch', signature: 's', body: ["type='signal'"] })); this.bus.on('message', this.#listener.bind(this)); this.client?.log('info', `Bluetooth adapter ${device} initialized successfully`); return this.adapter; } catch (err) { this.adapter = null; this.client?.log('error', `Failed to initialize Bluetooth adapter ${device}:`, err); throw err; } }; /** * Извлекает свойства из объекта Variant D-Bus. * @param {object} properties Объект свойств D-Bus. * @returns {object} Объект извлеченных свойств. */ extractProperties(properties) { const result = {}; if (!Object.keys(properties).length) return; for (const key in properties) { const prop = properties[key]; if (prop?.constructor.name === 'Variant') { const { value } = prop; if (value?.constructor.name === 'Object') result[key] = this.extractProperties(value); else result[key] = value; } else result[key] = prop; } return result; }; /** * Слушатель сообщений D-Bus для обработки событий Bluetooth. * @param {object} msg Сообщение D-Bus. */ async #listener(msg) { const path = msg.path; if (!path?.startsWith(this.path) || !Array.isArray(msg.body)) return; const iface = msg.body[0]; const device = path.split('/')[4]; const properties = this.extractProperties(msg.body[1]); if (!properties) return; this.client?.log('debug', `D-Bus signal received: path=${path}, interface=${iface}, member=${msg.member}`); switch (iface) { case 'org.bluez.Adapter1': { this.emit('adapter', properties); break; }; case 'org.bluez.Device1': { if (!device) return; if (this.connected[device]) { if (properties.hasOwnProperty('Connected')) { this.client?.log('debug', `Device ${device} Connected property changed to: ${properties.Connected}`); if (!properties.Connected && this.connected[device].isConnected) this.connected[device].emit('external_disconnect', 'D-Bus Connected property became false'); } this.connected[device].emit('properties', properties); } if (this.isDiscovering && !this.devices[device]) { this.client?.log('debug', `Processing potential new device: ${device}`); this.devices[device] = { path }; if (!properties.Address || !properties.Name || (this.filters && !properties.ServiceData)) { const proxy = await this.bus.getProxyObject('org.bluez', path); const proxyProperties = await proxy.getInterface('org.freedesktop.DBus.Properties').GetAll(iface).then(this.extractProperties.bind(this)); for (const key in proxyProperties) { properties[key] = proxyProperties[key]; } } if (this.filters) { const uuid = properties.ServiceData && Object.keys(properties.ServiceData)[0]; if (!uuid || !this.filters.includes(uuid)) return; } const config = { path, name: properties.Name, mac: properties.Address }; this.devices[device] = config; this.emit(`available:${device}`, config); this.emit('available', config); } if (properties.RSSI !== undefined) this.#rssi.set(device, properties.RSSI); if (this.isMonitoring && properties.ServiceData) { const id = UUID.find(id => properties.ServiceData[id]); if (id) { const beacon = new MiBeacon(this, device, properties.ServiceData[id], id); const parsed = beacon.parse(); if (parsed) { const rssi = properties.RSSI ?? this.#rssi.get(device); this.emit(`advertisement:${parsed.mac}`, { rssi, ts: parsed.ts, payload: parsed.payload }); this.emit('advertisement', { rssi, ...parsed }); } } } this.emit(`properties:${device}`, properties); break; }; case 'org.bluez.GattCharacteristic1': { if ((msg.interface !== 'org.freedesktop.DBus.Properties') || (msg.member !== 'PropertiesChanged') || !device || !this.connected[device]) return; this.connected[device].emit('properties', properties); const characteristic = this.connected[device].device?.characteristics?.get(path); if (characteristic && (properties.Value !== undefined)) { this.client?.log('debug', `Characteristic value changed: path=${path}, device=${device}, value=${properties.Value?.toString('hex')}`); characteristic.emit('valuechanged', properties.Value); } break; }; }; }; /** * Ожидает обнаружения определенного Bluetooth-устройства. * Сначала проверяет кэш уже обнаруженных устройств. Если устройство не найдено, * запускает сканирование (если оно еще не запущено) и ждет события 'available'. * @param {string} mac MAC-адрес устройства для ожидания. * @param {number|null} [ms=null] - Максимальное время ожидания в миллисекундах. Если null или 0, будет ждать бессрочно. * @returns {Promise<DeviceConfig & {path: string}>} Промис, который разрешается объектом конфигурации найденного устройства (`{ path, name, mac }`). * @throws {Error} Срабатывает, если время ожидания истекло до обнаружения устройства. */ async waitDevice(mac, ms = null) { const id = mac.replace(new RegExp(':', 'g'), '_').toUpperCase(); const config = Object.values(this.devices).find(device => device.mac === mac); if (config) { this.client?.log('debug', `Device ${mac} found immediately in discovery cache.`); return config; } this.client?.log('debug', `Waiting for device ${mac} to be discovered...${ms ? ` (timeout: ${ms}ms)` : ''}`); return new Promise((resolve, reject) => { let timerId; const isDiscovering = this.isDiscovering; if (!isDiscovering) this.startDiscovery(); const cleanup = () => { clearTimeout(timerId); this.off(`available:dev_${id}`, onDeviceAvailable); if (!isDiscovering) this.stopDiscovery(); }; const onDeviceAvailable = (/** @type {DeviceConfig & {path: string}} */ config) => { this.client?.log('debug', `Device ${mac} was discovered via event.`); cleanup(); resolve(config); }; this.once(`available:dev_${id}`, onDeviceAvailable); if (ms && (ms > 0)) timerId = setTimeout(() => { this.client?.log('warn', `Discovery timeout after ${ms}ms for device ${mac}.`); cleanup(); reject(new Error(`Discovery timeout after ${ms}ms for device ${mac}`)); }, ms); }); }; /** * Получает интерфейс устройства Bluetooth по MAC-адресу. * Если устройство не найдено в кэше, выполняет поиск устройства. * @param {string} mac MAC-адрес устройства. * @returns {Promise<object>} Прокси-объект интерфейса устройства Bluetooth. * @throws {Error} Если произошла ошибка D-Bus или устройство не найдено в течение таймаута. */ async getDevice(mac) { let proxy, device; const id = mac.replace(new RegExp(':', 'g'), '_').toUpperCase(); this.client?.log('debug', `Getting Bluetooth device interface for MAC: ${mac} (ID: ${id})`); if (!this.adapter) { this.client?.log('info', 'Bluetooth adapter not initialized, initializing now.'); await this.defaultAdapter(); } try { proxy = await this.bus.getProxyObject('org.bluez', `${this.path}/dev_${id}`); this.client?.log('debug', `Found existing D-Bus proxy for device ${id}`); device = proxy.getInterface('org.bluez.Device1'); } catch (err) { this.client?.log('info', `Device ${mac} not found directly, starting discovery search (timeout: ${GET_DEVICE_DISCOVERY_TIMEOUT}ms)...`); const config = await this.waitDevice(mac, GET_DEVICE_DISCOVERY_TIMEOUT); this.client?.log('debug', `Device ${mac} discovered, getting proxy from path: ${config.path}`); proxy = await this.bus.getProxyObject('org.bluez', config.path); device = proxy.getInterface('org.bluez.Device1'); }; this.client?.log('debug', `Returning device interface for ${mac}`); return createFallbackProxy(new BluetoothDevice(device, proxy, this), device); }; /** * Удаляет устройство из кэша BlueZ и разрывает соединение, если оно установлено. * @param {string} mac MAC-адрес устройства. * @returns {Promise<boolean>} */ async removeDevice(mac) { const id = mac.replace(/:/g, '_').toUpperCase(); if (!this.adapter) { this.client?.log('info', 'Bluetooth adapter not initialized, initializing now.'); await this.defaultAdapter(); } const path = `${this.path}/dev_${id}`; try { this.client?.log('debug', `Removing device from BlueZ cache: ${path}`); await this.adapter.RemoveDevice(path); return true; } catch (err) { return false; } }; /** * Запускает обнаружение Bluetooth устройств. * @param {string[]} [filters] Массив UUID фильтров для обнаружения устройств. * @returns {Promise<boolean>} */ async startDiscovery(filters) { this.#discoveringCount++; if (this.isDiscovering) { this.client?.log('warn', 'Attempted to start discovery, but it is already running.'); return; } if (!this.adapter) await this.defaultAdapter(); if (this.adapter) try { this.client?.log('info', `Starting Bluetooth discovery${filters ? ' with filters: ' + filters.join(', ') : ''}`); this.filters = filters; this.isDiscovering = true; await this.adapter.StartDiscovery(); this.client?.log('debug', 'Bluetooth discovery started successfully via D-Bus'); return true; } catch (err) { if (err.type === 'org.bluez.Error.InProgress') { this.client?.log('debug', 'Discovery already in progress, ignoring error.'); return true; } this.isDiscovering = false; this.client?.log('error', 'Failed to start Bluetooth discovery:', err); throw err; } return false; }; /** * Останавливает обнаружение Bluetooth устройств. * @param {boolean} [force=false] Если true, принудительно сбрасывает счетчик и останавливает обнаружение. * @returns {Promise<void>} */ async stopDiscovery(force = false) { if (!this.isDiscovering) return; this.#discoveringCount = force ? 0 : Math.max(0, this.#discoveringCount - 1); if (this.#discoveringCount > 0) return; this.client?.log('info', 'Stopping Bluetooth discovery'); this.isDiscovering = false; this.devices = {}; try { await this.adapter.StopDiscovery(); this.client?.log('debug', 'Bluetooth discovery stopped successfully via D-Bus'); } catch (err) { if (err.type === 'org.bluez.Error.Failed') this.client?.log('debug', 'Discovery was not running on the adapter, ignoring error.'); else { this.client?.log('error', 'Failed to stop Bluetooth discovery:', err); throw err; } } }; /** * Запускает режим мониторинга рекламных пакетов (passive scanning). * @returns {Promise<boolean>} */ async startMonitoring() { this.#monitoringCount++; if (this.isMonitoring) return; this.client?.log('info', 'Starting Bluetooth monitoring'); this.isMonitoring = true; return this.startDiscovery(); }; /** * Останавливает режим мониторинга. * @param {boolean} [force=false] Если true, принудительно сбрасывает счетчик и останавливает мониторинг. * @returns {Promise<void>} */ async stopMonitoring(force = false) { if (!this.isMonitoring) return; this.#monitoringCount = force ? 0 : Math.max(0, this.#monitoringCount - 1); if (this.#monitoringCount > 0) return; this.client?.log('info', 'Stopping Bluetooth monitoring'); this.isMonitoring = false; await this.stopDiscovery(); }; /** * Освобождает ресурсы и отключается от Bluetooth адаптера. * @returns {Promise<void>} */ async destroy() { this.client?.log('info', 'Destroying Bluetooth instance...'); if (this.isMonitoring) await this.stopMonitoring(true); if (this.isDiscovering) await this.stopDiscovery(true); if (this.bus) { this.bus.off('message', this.#listener); for (const device in this.connected) { await this.connected[device].disconnect(); } this.bus.disconnect(); this.bus = null; } this.adapter = null; this.client?.log('info', 'Bluetooth instance destroyed.'); }; };