xmihome
Version:
The core library for interacting with Xiaomi Mi Home devices via Cloud, MiIO, and Bluetooth.
565 lines (537 loc) • 22.6 kB
JavaScript
import EventEmitter from 'events';
import { 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() {
await this.dbusInterface.Connect();
return new Promise(async (resolve, reject) => {
const properties = this.proxy.getInterface('org.freedesktop.DBus.Properties');
const isResolved = await properties.Get('org.bluez.Device1', 'ServicesResolved');
if (isResolved.value)
return resolve();
let timerId;
const onPropertiesChanged = (/** @type {any} */ changedProps) => {
if (changedProps.ServicesResolved) {
clearTimeout(timerId);
this.bluetooth.off(`properties:${this.id}`, onPropertiesChanged);
resolve();
}
};
timerId = setTimeout(() => {
this.bluetooth.off(`properties:${this.id}`, onPropertiesChanged);
reject(new Error(`Timed out after 10s waiting for services to be resolved.`));
}, 10_000);
this.bluetooth.on(`properties:${this.id}`, onPropertiesChanged);
});
};
/**
* Разрывает соединение с устройством.
* @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;
/**
* Флаг, указывающий, выполняется ли в данный момент обнаружение Bluetooth устройств.
* @type {boolean}
*/
isDiscovering = false;
/**
* Создает и инициализирует экземпляр класса 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;
};
/**
* Конструктор класса Bluetooth.
* @param {XiaomiMiHome} [client] Экземпляр класса XiaomiMiHome.
*/
constructor(client) {
super();
this.client = client;
};
/**
* Инициализирует адаптер 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}`);
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 {
const bus = dbus.systemBus();
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.client?.log('error', `Failed to initialize Bluetooth adapter ${device}:`, err);
if (err.type !== 'org.freedesktop.DBus.Error.AccessDenied')
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);
}
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(this.device);
}
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);
};
/**
* Запускает обнаружение Bluetooth устройств.
* @param {string[]} [filters] Массив UUID фильтров для обнаружения устройств.
* @returns {Promise<void>}
*/
async startDiscovery(filters) {
if (this.isDiscovering) {
this.client?.log('warn', 'Attempted to start discovery, but it is already running.');
return;
}
this.client?.log('info', `Starting Bluetooth discovery${filters ? ' with filters: ' + filters.join(', ') : ''}`);
this.filters = filters;
this.isDiscovering = true;
if (!this.adapter)
await this.defaultAdapter(this.device);
try {
await this.adapter.StartDiscovery();
this.client?.log('debug', 'Bluetooth discovery started successfully via D-Bus');
} catch (err) {
this.isDiscovering = false;
this.client?.log('error', 'Failed to start Bluetooth discovery:', err);
throw err;
}
};
/**
* Останавливает обнаружение Bluetooth устройств.
* @returns {Promise<void>}
*/
async stopDiscovery() {
if (!this.isDiscovering)
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) {
this.client?.log('error', 'Failed to stop Bluetooth discovery:', err);
throw err;
}
};
/**
* Освобождает ресурсы и отключается от Bluetooth адаптера.
* @returns {Promise<void>}
*/
async destroy() {
this.client?.log('info', 'Destroying Bluetooth instance...');
if (this.isDiscovering)
await this.stopDiscovery();
if (this.bus) {
this.bus.off('message', this.#listener);
for (const device in this.connected) {
await this.connected[device].disconnect();
}
await this.bus.disconnect();
this.bus = null;
}
this.adapter = null;
this.client?.log('info', 'Bluetooth instance destroyed.');
};
};