xmihome
Version:
The core library for interacting with Xiaomi Mi Home devices via Cloud, MiIO, and Bluetooth.
302 lines (282 loc) • 11.6 kB
JavaScript
import crypto from 'crypto';
import miio from 'mijia-io';
/** @import { XiaomiMiHome } from './index.js' */
/**
* Класс для взаимодействия с MiIO и облаком Xiaomi.
*/
export default class Miot {
/**
* Находит спецификацию модели устройства на home.miot-spec.com.
* @param {string} model Модель устройства.
* @returns {Promise<object|undefined>} Объект спецификации модели или `undefined`, если модель не найдена.
*/
static async findModel(model) {
const instancesResponse = await fetch('https://miot-spec.org/miot-spec-v2/instances?status=released');
const /** @type {{ instances: any[] }} */ { instances } = await instancesResponse.json();
const instance = instances.sort((a, b) => (b.ts - a.ts)).find(instance => instance.model === model);
if (!instance)
return;
const specResponse = await fetch(`https://miot-spec.org/miot-spec-v2/instance?type=${instance.type}`);
const /** @type {{ type: string, description: string, services: any[] }} */ spec = await specResponse.json();
const properties = {};
const actions = {};
for (const s of /** @type {{ iid: number, type: string, description: string, properties?: any[], actions?: any[] }[]} */ (spec.services).slice(1)) {
const skp = s.type.split(':');
if (skp[1] === 'miot-spec-v2') {
if (s.properties)
for (const p of /** @type {{ iid: number, type: string, description: string, format: string, access: any[] }[]} */ (s.properties)) {
const pkp = p.type.split(':');
if (p.access.length)
properties[`${skp[3]}_${pkp[3]}`] = {
siid: s.iid,
piid: p.iid,
format: p.format,
access: p.access
};
}
if (s.actions)
for (const a of /** @type {{ iid: number, type: string, in: any[] }[]} */ (s.actions)) {
const akp = a.type.split(':');
actions[`${skp[3]}_${akp[3]}`] = {
siid: s.iid,
aiid: a.iid,
in: a.in
};
}
}
}
return {
name: spec.description,
type: spec.type,
properties, actions
};
};
/**
* Локализация для запросов к облаку Xiaomi (по умолчанию 'en').
* @type {string}
*/
locale = 'en';
/**
* Список поддерживаемых стран для облачных запросов.
* @type {string[]}
*/
countries = ['sg', 'cn', 'ru', 'us', 'tw', 'de'];
/**
* Экземпляр класса XiaomiMiHome.
* @type {XiaomiMiHome}
*/
client = null;
/**
* Конструктор класса Miot.
* @param {XiaomiMiHome} client Экземпляр класса XiaomiMiHome.
*/
constructor(client) {
this.client = client;
};
/**
* Возвращает учетные данные для облачного подключения из конфигурации клиента.
* @type {object}
*/
get credentials() {
return this.client.config.credentials || {};
};
/**
* Возвращает модуль miio для прямого взаимодействия с MiIO устройствами.
* @type {object}
*/
get miio() {
return miio;
};
/**
* Разбирает JSON строку, удаляя префикс '&&&START&&&', если он присутствует.
* @param {string} str JSON строка.
* @returns {object} Разобранный JSON объект.
*/
parseJson(str) {
if (str.indexOf('&&&START&&&') === 0)
str = str.replace('&&&START&&&', '');
return JSON.parse(str);
};
/**
* Возвращает URL API для указанной страны.
* @param {string} country Код страны (например, 'ru', 'cn').
* @returns {string} URL API.
*/
getApiUrl(country) {
return `https://${country === 'cn' ? '' : `${country}.`}api.io.mi.com/app`;
};
/**
* Генерирует подпись запроса для облачного API Xiaomi.
* @param {string} path Путь API запроса.
* @param {string} _signedNonce Signed Nonce.
* @param {string} nonce Nonce.
* @param {object} params Параметры запроса.
* @returns {string} Подпись запроса в base64.
*/
generateSignature(path, _signedNonce, nonce, params) {
const exps = [path, _signedNonce, nonce];
const paramKeys = Object.keys(params);
paramKeys.sort();
for (let i = 0, { length } = paramKeys; i < length; i++) {
const key = paramKeys[i];
exps.push(`${key}=${params[key]}`);
}
return crypto.createHmac('sha256', Buffer.from(_signedNonce, 'base64')).update(exps.join('&')).digest('base64');
};
/**
* Генерирует Nonce для запросов к облачному API Xiaomi.
* @returns {string} Nonce в base64.
*/
generateNonce() {
const buf = Buffer.allocUnsafe(12);
buf.write(crypto.randomBytes(8).toString('hex'), 0, 'hex');
buf.writeInt32BE(Math.floor(Date.now() / 60_000), 8);
return buf.toString('base64');
};
/**
* Генерирует Signed Nonce.
* @param {string} ssecret Ssecurity.
* @param {string} nonce Nonce.
* @returns {string} Signed Nonce в base64.
*/
signedNonce(ssecret, nonce) {
const s = Buffer.from(ssecret, 'base64');
const n = Buffer.from(nonce, 'base64');
return crypto.createHash('sha256').update(s).update(n).digest('base64');
};
/**
* Выполняет вход в аккаунт Xiaomi и получает учетные данные (ssecurity, userId, serviceToken).
* @throws {Error} Если не удалось выполнить вход на каком-либо из этапов.
*/
async login() {
this.client.log('info', `Attempting login for user: ${this.credentials.username}`);
if (!this.credentials.username)
throw new Error('username empty');
if (!this.credentials.password)
throw new Error('password empty');
// --- Шаг 1: Получение _sign ---
this.client.log('debug', 'Login Step 1: Fetching _sign');
const step1Response = await fetch('https://account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true');
if (!step1Response.ok)
throw new Error(`Response step 1 error with status ${step1Response.statusText}`);
const step1Text = await step1Response.text();
const step1Data = this.parseJson(step1Text);
if (!step1Data._sign)
throw new Error('Login step 1 failed: _sign not found');
const sign = step1Data._sign;
this.client.log('debug', 'Login Step 1: Got _sign successfully');
// --- Шаг 2: Отправка учетных данных и получение токенов ---
this.client.log('debug', 'Login Step 2: Sending credentials');
const step2Response = await fetch('https://account.xiaomi.com/pass/serviceLoginAuth2', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
user: this.credentials.username,
hash: crypto.createHash('md5').update(this.credentials.password).digest('hex').toUpperCase(),
_json: 'true',
sid: 'xiaomiio',
callback: 'https://sts.api.io.mi.com/sts',
qs: '%3Fsid%3Dxiaomiio%26_json%3Dtrue',
_sign: sign
})
});
if (!step2Response.ok)
throw new Error(`Response step 2 error with status ${step2Response.statusText}`);
const step2Text = await step2Response.text();
const { ssecurity, userId, location } = this.parseJson(step2Text);
if (!ssecurity || !userId || !location) {
this.client.log('error', 'Login Step 2 Failed: Missing ssecurity, userId, or location.');
throw new Error('Failed to sign in at step 2. Please sign in manually https://account.xiaomi.com/');
}
this.client.log('debug', 'Login Step 2: Got ssecurity and userId successfully');
// --- Шаг 3: Получение serviceToken ---
this.client.log('debug', 'Login Step 3: Fetching serviceToken from location');
const step3Response = await fetch(location); // Упростил логику, location должен быть всегда
if (!step3Response.ok)
throw new Error(`Response step 3 error with status ${step3Response.statusText}`);
const cookies = step3Response.headers.get('set-cookie');
if (!cookies)
throw new Error('Login step 3 failed: No set-cookie header found.');
const serviceToken = cookies.match(/serviceToken=([^;]+)/)?.[1];
if (!serviceToken) {
this.client.log('error', 'Login Step 3 Failed: Could not extract serviceToken from cookies.');
throw new Error('Login step 3 failed');
}
this.client.log('debug', 'Login Step 3: Got serviceToken successfully');
// --- Завершение: Сохранение учетных данных ---
this.client.log('info', `Login successful for user ${userId}`);
this.credentials.ssecurity = ssecurity;
this.credentials.userId = userId;
this.credentials.serviceToken = serviceToken;
this.client.emit('login', this.credentials);
};
/**
* Выполняет запрос к облачному API Xiaomi.
* @param {string} path Путь API запроса.
* @param {object} data Данные запроса.
* @returns {Promise<object>} Ответ API в формате JSON.
* @throws {Error} Если запрос завершился с ошибкой.
*/
async request(path, data) {
this.client.log('debug', `Cloud request to ${path} with data:`, data);
if (!this.credentials.serviceToken) {
this.client.log('info', 'No serviceToken found, attempting login before request');
await this.login();
}
if (!this.countries.includes(this.credentials.country))
throw new Error(`The country ${this.credentials.country} is not support, list supported countries is ${this.countries.join(', ')}`);
const params = {
data: JSON.stringify(data)
};
const _nonce = this.generateNonce();
const signedNonce = this.signedNonce(this.credentials.ssecurity, _nonce);
const signature = this.generateSignature(path, signedNonce, _nonce, params);
this.client.log('debug', `Sending cloud request to: ${this.getApiUrl(this.credentials.country)}${path}`);
const controller = new AbortController();
const timerId = setTimeout(() => controller.abort(), 5_000);
try {
const res = await fetch(this.getApiUrl(this.credentials.country) + path, {
method: 'POST',
signal: controller.signal,
headers: {
'x-xiaomi-protocal-flag-cli': 'PROTOCAL-HTTP2',
'Content-Type': 'application/x-www-form-urlencoded',
Cookie: [
`userId=${this.credentials.userId}`,
`serviceToken=${this.credentials.serviceToken}`,
`locale=${this.locale}`,
].join('; '),
},
body: new URLSearchParams({
signature, _nonce,
data: params.data
})
});
if (res.ok) {
const result = await res.json();
this.client.log('debug', `Cloud request to ${path} successful, result:`, result);
this.client.log('info', `Cloud request to ${path} successful.`);
return result;
} else {
this.client.log('error', `Cloud request to ${path} failed with status ${res.status} ${res.statusText}`);
let errorBody = '';
try {
errorBody = await res.text();
} catch (err) {}
this.client.log('debug', `Cloud request error body: ${errorBody}`);
throw new Error(`Request error with status ${res.statusText}`);
}
} catch (err) {
if (err.name === 'AbortError') {
this.client.log('error', `Cloud request to ${path} timed out after 5000ms`);
throw new Error('Request timed out');
}
this.client.log('error', `Network error during cloud request to ${path}:`, err);
throw err;
} finally {
clearTimeout(timerId);
}
};
};