xmihome
Version:
The core library for interacting with Xiaomi Mi Home devices via Cloud, MiIO, and Bluetooth.
268 lines (250 loc) • 8.67 kB
JavaScript
import { input, password, select } from '@inquirer/prompts';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { mkdir, readFile, writeFile, unlink } from 'fs/promises';
import path from 'path';
import XiaomiMiHome from '../src/index.js';
import { CREDENTIALS_FILE, DEVICE_CACHE_FILE, CLOUD_DEVICE_LIST_FILE, CONFIG_DIR } from '../src/paths.js';
import { COUNTRIES, CACHE_TTL } from '../src/constants.js';
/** @import { Credentials } from '../src/index.js' */
/** @import { DiscoveredDevice } from '../src/device.js' */
/** @import { ArgumentsCamelCase } from 'yargs' */
const CONNECTION_TYPES = /** @type {const} */ (['all', 'miio', 'bluetooth', 'cloud']);
/**
* @typedef {object} LoginCommandArgs
* @property {string} [username]
* @property {string} [password]
* @property {typeof COUNTRIES[number]} [country]
* @property {boolean} [verbose]
*/
/**
* @typedef {object} DevicesCommandArgs
* @property {typeof CONNECTION_TYPES[number]} [type]
* @property {boolean} [force]
* @property {boolean} [verbose]
*/
const ensureConfigDir = async () => {
try {
await mkdir(CONFIG_DIR, { recursive: true });
} catch (error) {
console.error(`Error creating config directory at ${CONFIG_DIR}:`, error);
process.exit(1);
}
};
const readJsonFile = async (/** @type {string} */ filePath) => {
try {
return JSON.parse(await readFile(filePath, 'utf-8'));
} catch (error) {
return null;
}
};
const writeJsonFile = async (/** @type {string} */ filePath, /** @type {object} */ data) => {
await ensureConfigDir();
await writeFile(filePath, JSON.stringify(data, null, 2));
};
const loadCredentials = () => readJsonFile(CREDENTIALS_FILE);
const saveCredentials = (/** @type {Credentials} */ credentials) => writeJsonFile(CREDENTIALS_FILE, credentials);
const loadCloudDeviceList = () => readJsonFile(CLOUD_DEVICE_LIST_FILE);
const loadDeviceCache = async (/** @type {string} */ type) => {
const cache = await readJsonFile(DEVICE_CACHE_FILE);
if (cache && cache.type === type && (Date.now() - cache.timestamp) < CACHE_TTL)
return cache.devices;
return null;
};
const saveDeviceCache = async (/** @type {DiscoveredDevice[]} */ devices, /** @type {string} */ type) => {
writeJsonFile(DEVICE_CACHE_FILE, { timestamp: Date.now(), type, devices });
};
const formatTable = (/** @type {DiscoveredDevice[]} */ devices) => {
if (!devices || devices.length === 0) {
console.log('No devices found.');
return;
}
const headers = ['Name', 'Model', 'ID / IP / MAC', 'Key (BLE / MiIO)', 'Online'];
const rows = devices.map(d => [
d.name || '',
d.model || '',
d.id ? `ID: ${d.id}` : (d.address ? `IP: ${d.address}` : (d.mac ? `MAC: ${d.mac}` : '')),
d.bindkey ? `BLE: ${d.bindkey}` : (d.token ? `MiIO: ${d.token}` : 'N/A'),
typeof d.isOnline === 'boolean' ? (d.isOnline ? 'Yes' : 'No') : 'N/A'
]);
const colWidths = headers.map(h => h.length);
rows.forEach(row => {
row.forEach((cell, i) => {
colWidths[i] = Math.max(colWidths[i], String(cell).length);
});
});
const printRow = (/** @type {string[]} */ row) => console.log(row.map((cell, i) => String(cell).padEnd(colWidths[i])).join(' | '));
const printSeparator = () => console.log(colWidths.map(w => '-'.repeat(w)).join('-|-'));
printRow(headers);
printSeparator();
rows.forEach(printRow);
};
const handleLoginCommand = async (/** @type {LoginCommandArgs} */ argv) => {
try {
if (!argv.username)
argv.username = await input({
message: 'Username (Email/Phone/ID)',
required: true
});
if (!argv.password)
argv.password = await password({
message: 'Password',
validate: v => !!v
});
if (!argv.country)
argv.country = await select({
message: 'Country Code',
choices: COUNTRIES
});
} catch (err) {
console.error(err.message);
process.exit(1);
}
const logLevel = argv.verbose ? 'debug' : 'none';
const client = new XiaomiMiHome({ credentials: argv, logLevel });
const handlers = {
on2fa: async (/** @type {string} */ _notificationUrl) => {
console.warn('\n--- TWO-FACTOR AUTHENTICATION REQUIRED ---');
console.log('A verification code has been sent to your registered email or phone.');
return await input({
message: 'Please enter the code you received',
required: true
});
},
onCaptcha: async (/** @type {string} */ imageB64) => {
console.warn('\n--- CAPTCHA REQUIRED ---');
const base64Data = imageB64.replace(/^data:image\/\w+;base64,/, '');
const captchaPath = path.join(process.cwd(), 'xiaomi_captcha.jpg');
await writeFile(captchaPath, base64Data, 'base64');
console.log(`Captcha image saved to: ${captchaPath}`);
console.log('Please open this image file, read the characters, and enter them below.');
const code = await input({
message: 'Enter captcha code',
required: true
});
try {
await unlink(captchaPath);
} catch (e) {}
return code;
}
};
try {
console.log('\nAttempting to log in...');
const credentials = await client.miot.login(handlers);
await saveCredentials(credentials);
console.log(`\n✅ Login successful! Credentials saved to ${CREDENTIALS_FILE}`);
return credentials;
} catch (error) {
console.error(`\n❌ Login failed: ${error.message}`);
process.exit(1);
}
};
const handleDevicesCommand = async (/** @type {DevicesCommandArgs} */ argv) => {
const { force, type, verbose } = argv;
const logLevel = verbose ? 'debug' : 'none';
let credentials = await loadCredentials();
const devices = await loadCloudDeviceList() || [];
if ((type === 'cloud' || type === 'all') && !credentials) {
console.log('Cloud device list requires authentication.');
credentials = await handleLoginCommand({ verbose });
}
if (!force) {
const cachedDevices = await loadDeviceCache(type);
if (cachedDevices) {
console.log(`Displaying cached device list for type "${type}" (use --force to refresh).`);
formatTable(cachedDevices);
return;
}
}
console.log(`Searching for devices (type: ${type})... This may take a moment.`);
const client = new XiaomiMiHome({ credentials, devices, logLevel });
try {
let finalDevices = [], cloudDevices = [], localDevices = [];
const searchCloud = type === 'cloud' || type === 'all';
const searchLocal = type === 'miio' || type === 'bluetooth' || type === 'all';
if (searchCloud) {
cloudDevices = await client.getDevices({ connectionType: 'cloud' });
await writeJsonFile(CLOUD_DEVICE_LIST_FILE, cloudDevices);
}
if (searchLocal) {
const localSearchType = type === 'all' ? 'miio+bluetooth' : type;
localDevices = await client.getDevices({ connectionType: localSearchType });
}
if (type === 'all') {
const deviceMap = new Map();
[...cloudDevices, ...localDevices].forEach(d => {
const key = d.mac || d.address || d.id;
if (key)
deviceMap.set(key, { ...(deviceMap.get(key) || {}), ...d });
});
finalDevices = Array.from(deviceMap.values());
} else
finalDevices = type === 'cloud' ? cloudDevices : localDevices;
await saveDeviceCache(finalDevices, type);
formatTable(finalDevices);
} catch (error) {
console.error(`\n❌ Error fetching devices: ${error.message}`);
process.exit(1);
} finally {
await client.destroy();
}
};
yargs(hideBin(process.argv))
.command(
'login',
'Interactively log in to Xiaomi Cloud and save credentials.',
(yargs) => {
return yargs
.option('username', {
alias: 'u',
type: 'string',
description: 'Xiaomi account username'
})
.option('password', {
alias: 'p',
type: 'string',
description: 'Xiaomi account password'
})
.option('country', {
alias: 'c',
description: 'Xiaomi account country code',
choices: COUNTRIES
});
},
async (/** @type {ArgumentsCamelCase<LoginCommandArgs>} */ argv) => {
await handleLoginCommand(argv);
}
)
.command(
'devices',
'List devices from local network and/or cloud.',
(yargs) => {
return yargs
.option('type', {
description: 'Specify discovery type',
choices: CONNECTION_TYPES,
default: 'all'
})
.option('force', {
type: 'boolean',
description: 'Force a new discovery, ignoring the cache',
default: false
});
},
async (/** @type {ArgumentsCamelCase<DevicesCommandArgs>} */ argv) => {
await handleDevicesCommand(argv)
}
)
.option('verbose', {
type: 'boolean',
description: 'Run with verbose logging',
global: true
})
.completion('completion', 'Generate completion script')
.demandCommand(1, '')
.strict()
.showHelpOnFail(false, 'Specify --help for available options')
.help().alias('h', 'help')
.version().alias('v', 'version')
.parse();