xmihome
Version:
The core library for interacting with Xiaomi Mi Home devices via Cloud, MiIO, and Bluetooth.
481 lines (439 loc) • 19.2 kB
JavaScript
import miio from 'mijia-io';
import crypto from 'crypto';
import { readFile } from 'fs/promises';
import { COUNTRIES } from './constants.js';
import { expandPath } from './paths.js';
/** @import { Credentials, 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';
/**
* Экземпляр класса XiaomiMiHome.
* @type {XiaomiMiHome}
*/
client = null;
/**
* Конструктор класса Miot.
* @param {XiaomiMiHome} client Экземпляр класса XiaomiMiHome.
*/
constructor(client) {
this.client = client;
};
/**
* Возвращает учетные данные для облачного подключения из конфигурации клиента.
* @type {Credentials}
*/
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');
};
/**
* Загружает учетные данные из файла.
* @returns {Promise<Credentials|null>}
*/
async #loadCredentials() {
try {
const data = await readFile(expandPath(this.client.config.credentialsFile), 'utf-8');
return JSON.parse(data);
} catch (error) {
return null;
}
};
/**
* Выполняет вход в аккаунт Xiaomi и возвращает учетные данные.
* @param {object} [handlers] - Объект с колбэками для обработки интерактивных шагов.
* @param {(url: string) => Promise<string>} [handlers.on2fa] - Колбэк для получения 2FA тикета.
* @param {(imageB64: string) => Promise<string>} [handlers.onCaptcha] - Колбэк для разгадывания капчи.
* @returns {Promise<Omit<Credentials, 'username'|'password'>>} - Объект с полученными учетными данными.
* @throws {Error} Если не удалось выполнить вход на каком-либо из этапов.
*/
async login(handlers) {
if (this.client.config.credentialsFile) {
const credentials = await this.#loadCredentials();
this.client.config.credentials = { ...credentials, ...this.credentials };
}
if (this.credentials.userId && this.credentials.ssecurity && this.credentials.serviceToken && this.credentials.country) {
this.client.log('info', 'Credentials (tokens) already available, skipping login.');
const { username, password, ...safeCredentials } = this.credentials;
return safeCredentials;
}
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');
let currentUrl, ssecurity, userId, serviceToken;
const serviceLoginUrl = 'https://account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true';
const userAgent = 'APP/com.xiaomi.mihome APPV/10.5.201';
const cookieJar = new Map();
const updateCookieJar = (/** @type {Headers} */ responseHeaders) => {
const setCookie = responseHeaders.getSetCookie?.() || [responseHeaders.get('set-cookie')];
if (!setCookie?.length)
return;
for (const cookie of setCookie) {
if (!cookie)
continue;
const parts = cookie.split(';');
const cookiePair = parts[0].split('=');
if (cookiePair.length < 2)
continue;
const [name, ...valueParts] = cookiePair;
const value = valueParts.join('=');
if (cookie.toLowerCase().includes('max-age=0') || cookie.toLowerCase().includes('expires='))
cookieJar.delete(name.trim());
else if (name && value)
cookieJar.set(name.trim(), parts[0]);
}
this.client.log('debug', `Cookie JAR updated:`, Object.fromEntries(cookieJar));
};
const getCookieHeader = () => Array.from(cookieJar.values()).join('; ');
const getCookieValue = (/** @type {string} */ name) => {
const cookie = cookieJar.get(name);
if (!cookie)
return null;
const valuePart = cookie.split(';')[0];
return valuePart.substring(valuePart.indexOf('=') + 1);
};
this.client.log('debug', `Fetching _sign from ${serviceLoginUrl}`);
const step1Response = await fetch(serviceLoginUrl, { headers: { 'User-Agent': userAgent, 'Cookie': getCookieHeader() } });
updateCookieJar(step1Response.headers);
this.client.log('debug', `Response status: ${step1Response.status}`);
const step1Data = this.parseJson(await step1Response.text());
if (!step1Data._sign)
throw new Error('Login step 1 failed: _sign not found');
const sign = step1Data._sign;
const step2Url = 'https://account.xiaomi.com/pass/serviceLoginAuth2';
let step2Data;
let captCode = null;
for (let attempt = 0; attempt < 3; attempt++) {
this.client.log('debug', `Sending credentials to ${step2Url} (Attempt ${attempt + 1})`);
const 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 (captCode)
body.append('captCode', captCode);
const step2Response = await fetch(step2Url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': userAgent, 'Cookie': getCookieHeader() },
body
});
updateCookieJar(step2Response.headers);
this.client.log('debug', `Response status: ${step2Response.status}`);
step2Data = this.parseJson(await step2Response.text());
this.client.log('debug', `Response body:`, step2Data);
if (step2Data.captchaUrl) {
if (!handlers?.onCaptcha)
throw new Error('Captcha is required, but no "onCaptcha" handler was provided.');
this.client.log('debug', `Captcha required. Fetching image from ${step2Data.captchaUrl}`);
const captchaResponse = await fetch(`https://account.xiaomi.com${step2Data.captchaUrl}`, {
headers: { 'User-Agent': userAgent, 'Cookie': getCookieHeader() }
});
updateCookieJar(captchaResponse.headers);
const captchaBuffer = await captchaResponse.arrayBuffer();
const captchaBase64 = Buffer.from(captchaBuffer).toString('base64');
const dataUri = `data:image/jpeg;base64,${captchaBase64}`;
captCode = await handlers.onCaptcha(dataUri);
if (!captCode)
throw new Error('Captcha code was not provided. Login aborted.');
continue;
}
break;
}
if (step2Data.notificationUrl) {
if (!handlers?.on2fa)
throw new Error('Two-factor authentication is required, but no "on2fa" handler was provided.');
const context = new URL(step2Data.notificationUrl).searchParams.get('context');
this.client.log('debug', '2FA Step: Listing verification methods to prime session.');
const listUrl = new URL('https://account.xiaomi.com/identity/list');
listUrl.searchParams.set('sid', 'xiaomiio');
listUrl.searchParams.set('context', context);
listUrl.searchParams.set('_locale', 'en_US');
const listResponse = await fetch(listUrl.toString(), { headers: { 'User-Agent': userAgent, 'Cookie': getCookieHeader() } });
updateCookieJar(listResponse.headers);
const listData = this.parseJson(await listResponse.text());
this.client.log('debug', '2FA Step: Identity list response:', listData);
const flag = listData.flag === 4 ? 4 : 8;
const authType = flag === 4 ? 'Phone' : 'Email';
const sendEndpoint = `send${authType}Ticket`;
const verifyEndpoint = `verify${authType}`;
this.client.log('debug', `2FA Step: Detected verification method: ${authType} (flag: ${flag})`);
this.client.log('debug', '2FA Step: Attempting to request verification code.');
const sendUrl = new URL(`https://account.xiaomi.com/identity/auth/${sendEndpoint}`);
sendUrl.searchParams.set('_dc', String(Date.now()));
const sendResponse = await fetch(sendUrl.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': userAgent, 'Cookie': getCookieHeader() },
body: new URLSearchParams({
context,
sid: 'xiaomiio',
_json: 'true',
ick: getCookieValue('ick') || ''
})
});
updateCookieJar(sendResponse.headers);
const sendData = this.parseJson(await sendResponse.text());
this.client.log('debug', `2FA Step: "${sendEndpoint}" response:`, sendData);
if (sendData.code === 0 && sendData.location) {
this.client.log('info', '2FA Step: Server skipped code verification, proceeding directly.');
currentUrl = sendData.location;
} else if (sendData.code === 0) {
const ticket = await handlers.on2fa(step2Data.notificationUrl);
if (!ticket)
throw new Error('2FA ticket was not provided. Login aborted.');
this.client.log('debug', '2FA Step: Verifying ticket.');
const verifyUrl = new URL(`https://account.xiaomi.com/identity/auth/${verifyEndpoint}`);
verifyUrl.searchParams.set('_json', 'true');
const verifyResponse = await fetch(verifyUrl.toString(), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'User-Agent': userAgent, 'Cookie': getCookieHeader() },
body: new URLSearchParams({
ticket, context,
trust: 'true',
_json: 'true',
_flag: String(flag)
})
});
updateCookieJar(verifyResponse.headers);
const verifyData = this.parseJson(await verifyResponse.text());
this.client.log('debug', '2FA Step: Verification response:', verifyData);
if (verifyData.code !== 0 || !verifyData.location)
throw new Error(`2FA verification failed: ${verifyData.tips || verifyData.desc || 'Unknown error'}`);
currentUrl = verifyData.location;
} else
throw new Error(`Failed to request 2FA code: ${sendData.tips || sendData.desc}`);
this.client.log('debug', '2FA Step: Following redirect chain.');
for (let i = 0; i < 10; i++) {
this.client.log('debug', `REDIRECT LOOP ${i}: Fetching URL: ${currentUrl}`);
const redirectResponse = await fetch(currentUrl, { redirect: 'manual', headers: { 'User-Agent': userAgent, 'Cookie': getCookieHeader() } });
updateCookieJar(redirectResponse.headers);
const pragma = redirectResponse.headers.get('extension-pragma');
if (pragma)
try {
const pragmaJson = JSON.parse(pragma);
if (pragmaJson.ssecurity) {
ssecurity = pragmaJson.ssecurity;
this.client.log('info', `SUCCESS: ssecurity captured: ${ssecurity}`);
}
} catch (e) {
this.client.log('warn', 'Could not parse extension-pragma header', pragma);
}
if (redirectResponse.status >= 300 && redirectResponse.status < 400 && redirectResponse.headers.has('location'))
currentUrl = new URL(redirectResponse.headers.get('location'), currentUrl).toString();
else {
this.client.log('debug', `End of redirect chain at loop ${i}.`);
break;
}
}
userId = getCookieValue('userId');
serviceToken = getCookieValue('serviceToken');
} else if (step2Data.ssecurity) {
ssecurity = step2Data.ssecurity;
userId = step2Data.userId;
const step3Response = await fetch(step2Data.location, { headers: { 'User-Agent': userAgent, 'Cookie': getCookieHeader() } });
updateCookieJar(step3Response.headers);
serviceToken = getCookieValue('serviceToken');
} else
throw new Error(`Login failed at Step 2. Server response: ${JSON.stringify(step2Data)}`);
if (!ssecurity || !userId || !serviceToken) {
this.client.log('error', `Login failed. ssecurity: ${!!ssecurity}, userId: ${!!userId}, serviceToken: ${!!serviceToken}`);
this.client.log('debug', 'Final state of cookie jar:', Object.fromEntries(cookieJar));
throw new Error(`Login failed: Could not retrieve all required credentials.`);
}
this.credentials.ssecurity = ssecurity;
this.credentials.userId = userId;
this.credentials.serviceToken = serviceToken;
this.client.emit('login', this.credentials);
return {
userId, ssecurity, serviceToken,
country: this.credentials.country
};
};
/**
* Выполняет запрос к облачному 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 (!COUNTRIES.includes(this.credentials.country))
throw new Error(`The country ${this.credentials.country} is not support, list supported countries is ${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);
}
};
};