xmihome
Version:
The core library for interacting with Xiaomi Mi Home devices via Cloud, MiIO, and Bluetooth.
532 lines (505 loc) • 23 kB
JavaScript
import { debuglog, inspect } from 'util';
import EventEmitter from 'events';
import Device from './device.js';
import Miot from './miot.js';
import Bluetooth from './bluetooth.js';
import { LOG_LEVELS, DEFAULT_LOG_LEVEL, LIB_ID, UUID, COUNTRIES } from './constants.js';
import { CREDENTIALS_FILE } from './paths.js';
import { devices } from 'xmihome-devices';
/** @import { Config as DeviceConfig, DiscoveredDevice } from './device.js' */
/**
* @typedef {Object} Credentials
* @property {(typeof COUNTRIES)[number]} [country] Страна для облачного подключения (например, 'ru', 'cn').
* @property {string} [username] Имя пользователя для облачного подключения.
* @property {string} [password] Пароль для облачного подключения.
* @property {string|number} [userId] ID пользователя Xiaomi. Если указан вместе с ssecurity и serviceToken, авторизация пропускается.
* @property {string} [ssecurity] Ключ безопасности ssecurity. Если указан вместе с userId и serviceToken, авторизация пропускается.
* @property {string} [serviceToken] Токен сервиса serviceToken. Если указан вместе с userId и ssecurity, авторизация пропускается.
*/
/**
* @typedef {Object} Config
* @property {Credentials} [credentials] Учетные данные для облачного подключения.
* @property {string} [credentialsFile] Путь к файлу с учетными данными.
* @property {('miio'|'bluetooth'|'cloud')} [connectionType] Тип подключения по умолчанию.
* @property {DeviceConfig[]} [devices] Массив устройств для поиска и подключения.
* @property {('none'|'error'|'warn'|'info'|'debug')} [logLevel='none'] Уровень логирования через console. По умолчанию 'none'.
*/
/**
* Класс для взаимодействия с устройствами Xiaomi Mi Home.
* @extends EventEmitter
*/
export default class XiaomiMiHome extends EventEmitter {
/**
* Экземпляр debuglog для вывода отладочной информации при NODE_DEBUG=xmihome.
*/
#debugLog;
/**
* Флаг, указывающий, включен ли вывод через NODE_DEBUG=xmihome.
* @type {boolean}
*/
#isDebugEnvEnabled = false;
/**
* Числовой уровень логирования, установленный через конструктор.
* @type {number}
*/
#logLevelNumber = LOG_LEVELS[DEFAULT_LOG_LEVEL];
/**
* Экземпляр класса Miot для взаимодействия через MiIO и облако.
* Инициализируется лениво через геттер `miot`.
* @type {Miot|undefined}
*/
#miot;
/**
* Экземпляр класса Bluetooth для взаимодействия через Bluetooth LE.
* Инициализируется лениво через геттер `bluetooth`.
* @type {Bluetooth|undefined}
*/
#bluetooth;
/**
* Кэш для хранения активных экземпляров устройств (Device).
* @type {Map<string, Device>}
*/
#deviceInstances = new Map();
/**
* Конфигурация для подключения.
* @type {Config}
*/
config = null;
/**
* Конструктор класса XiaomiMiHome.
* @param {Config} config Конфигурация для подключения.
*/
constructor(config = {}) {
super();
this.config = config;
this.#debugLog = debuglog(LIB_ID);
this.#isDebugEnvEnabled = process.env.NODE_DEBUG && new RegExp(`\\b${LIB_ID}\\b`, 'i').test(process.env.NODE_DEBUG);
this.#logLevelNumber = LOG_LEVELS[config?.logLevel] || LOG_LEVELS[DEFAULT_LOG_LEVEL];
this.log('debug', 'XiaomiMiHome instance created with config:', config);
};
/**
* Записывает лог-сообщение.
* Учитывает logLevel, установленный в конструкторе, и переменную окружения NODE_DEBUG=xmihome.
* @param {('error'|'warn'|'info'|'debug')} level Уровень сообщения.
* @param {...any} args Аргументы для логирования (как в console.log).
*/
log(level, ...args) {
const prefix = `[${level.toUpperCase()}]`;
this.#debugLog(prefix, ...args.map(arg => typeof arg === 'object' ? inspect(arg, { depth: null }) : arg));
if (!this.#isDebugEnvEnabled && (LOG_LEVELS[level] <= this.#logLevelNumber))
console[level](`[${LIB_ID}]`, prefix, ...args);
}
/**
* Возвращает экземпляр класса Miot для взаимодействия через MiIO и облако.
* @type {Miot}
*/
get miot() {
if (!this.#miot) {
this.#miot = new Miot(this);
this.log('debug', 'Creating Miot instance');
}
return this.#miot;
};
/**
* Возвращает экземпляр класса Bluetooth для взаимодействия через Bluetooth LE.
* @type {Bluetooth}
*/
get bluetooth() {
if (!this.#bluetooth)
this.#bluetooth = new Bluetooth(this);
return this.#bluetooth;
};
/**
* Освобождает ресурсы, используемые экземпляром XiaomiMiHome.
* В настоящее время останавливает Bluetooth-адаптер и связанные с ним процессы
* (сканирование, подключенные устройства).
* @returns {Promise<void>}
*/
async destroy() {
this.log('info', 'XiaomiMiHome client destroyed, all cached device instances disconnected.');
for (const [key, deviceInstance] of this.#deviceInstances.entries()) {
this.log('debug', `Destroying cached device instance for ${key}`);
try {
await deviceInstance.disconnect();
} catch (err) {
this.log('warn', `Error disconnecting cached device ${key} during client destroy:`, err);
}
}
this.#deviceInstances.clear();
if (this.#bluetooth)
await this.#bluetooth.destroy();
this.log('info', 'XiaomiMiHome client destroyed successfully.');
};
/**
* Получает список помещений (домов) пользователя.
* @returns {Promise<Array<object>>} Массив объектов помещений.
*/
async getHome() {
this.log('debug', 'Requesting home list from cloud');
try {
const { result } = await this.miot.request('/v2/homeroom/gethome', {
fg: true,
fetch_share: true,
fetch_share_dev: true,
limit: 300,
app_ver: 7
});
this.log('info', `Successfully fetched ${result?.homelist?.length || 0} homes`);
for (const home of result.homelist) {
home.id = parseInt(home.id);
}
return result.homelist;
} catch (err) {
this.log('error', 'Failed to get home list:', err);
throw err;
}
};
/**
* Получает данные об окружающей среде для указанного помещения.
* @param {number} home_id Идентификатор помещения.
* @returns {Promise<object>} Объект с данными об окружающей среде.
*/
async getEnv(home_id) {
this.log('debug', `Requesting env data for home_id: ${home_id}`);
try {
const { result } = await this.miot.request('/v2/home/get_env_data', {
home_id,
timestamp: Math.floor(Date.now() / 1_000) - 300,
prop_event_device: ['temp', 'hum', 'pm25']
});
this.log('info', `Successfully fetched env data for home_id: ${home_id}`);
return result;
} catch (err) {
this.log('error', `Failed to get env data for home_id ${home_id}:`, err);
throw err;
}
};
/**
* Создает и возвращает экземпляр класса Device для управления конкретным устройством.
* @param {DeviceConfig} deviceConfig Конфигурация устройства.
* @returns {Promise<Device>} Promise, который разрешится экземпляром класса Device.
*/
async getDevice(deviceConfig) {
this.log('debug', 'Getting device instance for:', deviceConfig);
const key = Device.getDeviceId(deviceConfig);
if (this.#deviceInstances.has(key)) {
const cachedInstance = this.#deviceInstances.get(key);
if (cachedInstance.device && (cachedInstance.connectionType !== undefined)) {
this.log('debug', `Returning cached device instance for: ${key}`);
return this.#deviceInstances.get(key);
}
this.log('debug', `Cached instance ${key} found but seems disconnected. Removing from cache to allow recreation.`);
this.#deviceInstances.delete(key);
}
const instance = await Device.create(deviceConfig, this);
this.log('debug', `Created new device instance: ${instance.constructor.name} for ${key}`);
this.#deviceInstances.set(key, instance);
const onDeviceDisconnect = () => {
this.log('debug', `Device instance ${key} reported self-disconnect. Removing from cache.`);
this.#deviceInstances.delete(key);
instance.off('disconnect', onDeviceDisconnect);
};
instance.on('disconnect', onDeviceDisconnect);
return instance;
};
/**
* Получает список устройств.
* Позволяет настроить тип поиска и прервать его досрочно с помощью callback-функции.
* @param {object} [options] Опции для поиска устройств.
* @param {number} [options.timeout=10000] Таймаут для локального поиска в миллисекундах.
* @param {('miio'|'bluetooth'|'miio+bluetooth'|'cloud')} [options.connectionType] Предпочитаемый тип поиска.
* @param {(
* device: DiscoveredDevice,
* devices: DiscoveredDevice[],
* type: 'miio'|'bluetooth'|'cloud'
* ) => boolean | {include?: boolean, stop?: boolean}} [options.onDeviceFound]
* Callback-функция, вызываемая для каждого нового уникального устройства. Позволяет фильтровать результаты и досрочно останавливать поиск.
* - `device`: Объект только что найденного устройства.
* - `devices`: Массив уже добавленных устройств.
* - `type`: Тип протокола, по которому было найдено устройство ('miio', 'bluetooth' или 'cloud').
* - Возвращаемое значение управляет процессом:
* - `true` (по умолчанию, если коллбэк не указан): Добавить устройство и продолжить поиск.
* - `false` или `undefined`: Игнорировать устройство и продолжить поиск.
* - Объект `{ include: boolean, stop: boolean }`:
* - `include: true`: Добавить устройство в итоговый список.
* - `stop: true`: Немедленно остановить поиск после обработки текущего устройства.
* @returns {Promise<DiscoveredDevice[]>} Promise, который разрешится массивом объектов найденных устройств.
* @throws {Error} Если запрошен тип 'cloud', но учетные данные не предоставлены, или если указан неверный `connectionType`.
*/
async getDevices({
timeout = 10_000,
connectionType = this.config.connectionType,
onDeviceFound = null
} = {}) {
const { username, password, userId, ssecurity, serviceToken } = this.config.credentials || {};
const hasCredentials = !!((username && password) || (userId && ssecurity && serviceToken) || this.config.credentialsFile);
const discoveryStrategy = connectionType || (hasCredentials ? 'cloud' : 'miio+bluetooth');
this.log('info', `Starting device discovery using strategy: "${discoveryStrategy}"`);
switch (discoveryStrategy) {
case 'cloud': {
if (!hasCredentials) {
const msg = 'Cannot fetch from cloud: credentials are required but missing.';
this.log('error', msg);
throw new Error(msg);
}
return this.#getCloudDevices(onDeviceFound);
};
case 'miio':
case 'bluetooth':
case 'miio+bluetooth': {
return this.#getLocalDevices(discoveryStrategy, timeout, onDeviceFound);
};
default: {
const msg = `Invalid connectionType: "${connectionType}". Allowed: 'cloud', 'miio', 'bluetooth' or undefined.`;
this.log('error', msg);
throw new Error(msg);
};
}
};
/**
* Получает список устройств из Xiaomi Cloud.
* @param {Function|null} onDeviceFound - Коллбэк от пользователя.
* @returns {Promise<DiscoveredDevice[]>} Promise с массивом устройств из облака.
* @throws {Error} Перебрасывает ошибку от API в случае неудачного запроса.
*/
async #getCloudDevices(onDeviceFound) {
this.log('info', 'Fetching device list from Xiaomi Cloud');
try {
const { result } = await this.miot.request('/home/device_list', {});
this.log('info', `Found ${result.list.length} raw devices in the cloud.`);
this.log('debug', 'Raw cloud devices found:', result.list);
const devices = [];
this.config.devices = [];
for (const dev of result.list) {
let bindkey = '';
if (dev.did.startsWith('blt.')) {
const { result: get_beaconkey } = await this.miot.request('/v2/device/blt_get_beaconkey', {
did: dev.did,
pdid: 1
});
if (get_beaconkey?.beaconkey)
bindkey = get_beaconkey?.beaconkey;
}
const device = {
id: dev.did,
name: dev.name,
model: dev.model,
token: dev.token,
address: dev.localip,
mac: dev.mac,
bindkey: bindkey,
isOnline: dev.isOnline
};
this.config.devices.push(device);
if (this.#processFoundDevice(device, devices, 'cloud', onDeviceFound))
break;
}
return devices;
} catch (err) {
this.log('error', 'Failed to get device list from cloud:', err);
throw err;
}
};
/**
* Выполняет локальный поиск устройств (MiIO и/или Bluetooth).
* Управляет процессом поиска, включая таймаут и досрочное завершение через `onDeviceFound`.
* @param {'miio'|'bluetooth'|'miio+bluetooth'} connectionType
* @param {number} timeout
* @param {Function|null} onDeviceFound - Коллбэк от пользователя.
* @returns {Promise<DiscoveredDevice[]>} Promise с массивом найденных локально устройств.
*/
async #getLocalDevices(connectionType, timeout, onDeviceFound) {
const devices = [];
const cleanupTasks = [];
let discoveryStopped = false;
let discoveryResolve;
const discoveryPromise = new Promise(resolve => { discoveryResolve = resolve; });
const discoveryStop = () => {
if (discoveryStopped)
return;
discoveryStopped = true;
this.log('debug', 'Stopping local discovery.');
discoveryResolve();
};
const handleDeviceFound = (
/** @type {DiscoveredDevice} */ device,
/** @type {'miio' | 'bluetooth'} */ type
) => {
if (discoveryStopped)
return;
const isDuplicate = devices.some(d =>
(d.id && d.id === device.id) ||
(d.address && d.address === device.address) ||
(d.mac && d.mac === device.mac)
);
if (isDuplicate)
return;
if (this.#processFoundDevice(device, devices, type, onDeviceFound))
discoveryStop();
};
this.log('info', `Starting local discovery (${connectionType}) for ${timeout}ms`);
try {
const timer = setTimeout(discoveryStop, timeout);
cleanupTasks.push(() => clearTimeout(timer));
if (connectionType.includes('miio')) {
const browser = this.miot.miio.browse();
const miioListener = (/** @type {object} */ dev) => {
if (discoveryStopped)
return;
const id = (dev.id || '').toString();
const model = dev.hostname?.replace(/_.*$/, '').replace(/-/g, '.');
const devConfig = this.config.devices?.find(d => (d.id === id) || (d.address === dev.address));
handleDeviceFound(mergePreferDefined(devConfig, {
id,
address: dev.address,
token: dev.token,
model: (model?.split('.').length >= 3) ? model : undefined,
isOnline: true
}, ['isOnline']), 'miio');
};
browser.on('available', miioListener);
cleanupTasks.push(() => {
this.log('debug', 'Cleaning up MiIO listener and browser.');
browser.off('available', miioListener);
setTimeout(() => browser.stop(), 500);
});
this.log('debug', 'Started MiIO discovery.');
}
if (connectionType.includes('bluetooth')) {
const btListener = async (/** @type {object} */ dev) => {
if (discoveryStopped)
return;
const devModels = Device.findModel(dev)?.models;
const devConfig = this.config.devices?.find(d => (d.mac === dev.mac));
handleDeviceFound(mergePreferDefined(devConfig, {
name: dev.name,
mac: dev.mac,
model: devModels?.[0],
isOnline: true
}, ['isOnline']), 'bluetooth');
};
try {
this.bluetooth.on('available', btListener);
const btStarted = await this.bluetooth.startDiscovery([...UUID]);
if (btStarted) {
cleanupTasks.push(async () => {
this.bluetooth.off('available', btListener);
await this.bluetooth.stopDiscovery();
});
this.log('debug', 'Started Bluetooth discovery.');
}
} catch (err) {
this.log('error', 'Failed to start Bluetooth discovery:', err);
}
}
if (cleanupTasks.length <= 1) {
this.log('warn', 'No discovery method was successfully started.');
discoveryStop();
}
await discoveryPromise;
} finally {
this.log('debug', 'Executing cleanup tasks.');
cleanupTasks.forEach(task => {
try {
task();
} catch (err) {
this.log('warn', 'Error during discovery cleanup:', err);
}
});
}
this.log('info', `Local discovery finished. Found ${devices.length} devices total.`);
return devices;
};
/**
* Обрабатывает найденное устройство, применяя коллбэк onDeviceFound.
* @param {DiscoveredDevice} device - Найденное устройство.
* @param {DiscoveredDevice[]} devices - Массив уже добавленных устройств.
* @param {'miio'|'bluetooth'|'cloud'} type - Тип обнаружения.
* @param {Function|null} onDeviceFound - Коллбэк от пользователя.
* @returns {boolean} - `true`, если поиск следует остановить, иначе `false`.
*/
#processFoundDevice(device, devices, type, onDeviceFound) {
this.log('debug', `Discovered ${type} device data:`, device);
let decision = { include: true, stop: false };
if (onDeviceFound) {
const result = onDeviceFound(device, [...devices], type);
if (typeof result === 'boolean')
decision.include = result;
else if ((typeof result === 'object') && (result !== null))
decision = { ...decision, ...result };
else
decision.include = false;
}
if (decision.include) {
devices.push(device);
this.log('info', `${type} device added: ${device.model || device.name} at ${device.address || device.mac || device.id}`);
}
if (decision.stop)
this.log('info', `Discovery stopped by onDeviceFound callback.`);
return decision.stop;
};
};
/**
* Приостанавливает выполнение на указанное количество миллисекунд.
* Этот метод можно прервать с помощью переданного AbortSignal.
* @param {number} ms Количество миллисекунд для ожидания.
* @param {AbortSignal} [signal] Опциональный AbortSignal для отмены ожидания.
* @returns {Promise<void>} Promise, который разрешается после указанной задержки.
* @throws {Error} Если ожидание было отменено сигналом.
*/
export function sleep(ms, signal = undefined) {
return new Promise((resolve, reject) => {
const timerId = setTimeout(resolve, ms);
if (signal) {
const abortHandler = () => {
clearTimeout(timerId);
reject(new Error('Operation cancelled'));
};
signal.addEventListener('abort', abortHandler, { once: true });
const cleanup = () => signal.removeEventListener('abort', abortHandler);
const promise = Promise.resolve();
promise.then(cleanup, cleanup);
}
});
};
/**
* Сливает два объекта. Свойства из `priorityObj` имеют приоритет,
* но только если их значение не `undefined`.
* @param {object} [priority={}] - Объект, чьи значения в приоритете.
* @param {object} [base={}] - Базовый объект со значениями по умолчанию.
* @param {string[]} [exclude=[]] - Массив ключей, которые нужно игнорировать в `priority` объекте.
* @returns {object} Новый объединенный объект.
*/
export function mergePreferDefined(priority = {}, base = {}, exclude = []) {
const definedPriorityValues = Object.fromEntries(
Object.entries(priority).filter(([key, value]) => ((value !== undefined) && !exclude.includes(key)))
);
return { ...base, ...definedPriorityValues };
}
/**
* Создает Proxy-обертку, которая объединяет два объекта.
* @template T
* @template F
* @param {T} target - Основной объект, который может переопределять методы.
* @param {F} fallback - Запасной объект, который предоставляет базовую функциональность.
* @returns {T & F} Готовый к использованию прокси-объект.
*/
export function createFallbackProxy(target, fallback) {
if ((typeof target !== 'object') || (target === null))
throw new TypeError('Proxy target must be an object.');
if ((typeof fallback !== 'object') || (fallback === null))
throw new TypeError('Proxy fallback must be an object.');
const proxy = new Proxy(target, {
get(target, prop, receiver) {
if (prop in target)
return Reflect.get(target, prop, receiver);
const fallbackProp = fallback[prop];
if (typeof fallbackProp === 'function')
return fallbackProp.bind(fallback);
return fallbackProp;
}
});
return /** @type {T & F} */ (proxy);
};
export { XiaomiMiHome, Device, Miot, Bluetooth, CREDENTIALS_FILE };
Device.registerModels(devices);