matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
256 lines • 9.83 kB
JavaScript
import crypto from 'node:crypto';
import { URLSearchParams } from 'node:url';
import axios from 'axios';
import * as AxiosLogger from 'axios-logger';
import { AuthenticateResponseCode } from '../enums/index.js';
export class RoborockAuthenticateApi {
logger;
axiosFactory;
baseUrl;
sessionId;
username;
authToken;
cachedBaseUrl;
cachedCountry;
cachedCountryCode;
constructor(logger, axiosFactory = axios, sessionId, baseUrl = 'https://usiot.roborock.com') {
this.logger = logger;
this.axiosFactory = axiosFactory;
this.baseUrl = baseUrl;
this.sessionId = sessionId ?? crypto.randomUUID();
}
async loginWithUserData(username, userData) {
this.loginWithAuthToken(username, userData.token);
return userData;
}
async loginWithPassword(username, password) {
const api = await this.getAPIFor(username);
const response = await api.post('api/v1/login', new URLSearchParams({
username: username,
password: password,
needtwostepauth: 'false',
}).toString());
return this.auth(username, response.data);
}
/**
* Request a verification code to be sent to the user's email
* @param email - The user's email address
* @throws Error if the account is not found, rate limited, or other API error
*/
async requestCodeV4(email) {
const api = await this.getAPIFor(email);
const response = await api.post('api/v4/email/code/send', new URLSearchParams({
email: email,
type: 'login',
platform: '',
}), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const apiResponse = response.data;
if (apiResponse.code === AuthenticateResponseCode.AccountNotFound) {
throw new Error(`Account not found for email: ${email}`);
}
if (apiResponse.code === AuthenticateResponseCode.RateLimited) {
throw new Error('Rate limited. Please wait before requesting another code.');
}
if (apiResponse.code !== AuthenticateResponseCode.Success && apiResponse.code !== undefined) {
throw new Error(`Failed to send verification code: ${apiResponse.msg} (code: ${apiResponse.code})`);
}
this.logger.debug('Verification code requested successfully');
}
/**
* Login with a verification code received via email
* @param email - The user's email address
* @param code - The 6-digit verification code
* @returns UserData on successful authentication
* @throws Error if the code is invalid, rate limited, or other API error
*/
async loginWithCodeV4(email, code) {
const api = await this.getAPIFor(email);
// Generate x_mercy_ks (random 16-char alphanumeric string)
const xMercyKs = this.generateRandomString(16);
// Get signed key from API
const xMercyK = await this.signKeyV3(api, xMercyKs);
// Fallback for missing country info to avoid 1002 parameter error
let country = this.cachedCountry;
let countryCode = this.cachedCountryCode;
if (!country || !countryCode) {
if (this.baseUrl.includes('euiot')) {
country ??= 'Germany';
countryCode ??= 'DE';
}
else if (this.baseUrl.includes('usiot')) {
country ??= 'United States';
countryCode ??= 'US';
}
else if (this.baseUrl.includes('cniot')) {
country ??= 'China';
countryCode ??= 'CN';
}
else if (this.baseUrl.includes('ruiot')) {
country ??= 'Russia';
countryCode ??= 'RU';
}
}
const response = await api.post('api/v4/auth/email/login/code', null, {
params: {
email: email,
code: code,
country: country ?? '',
countryCode: countryCode ?? '',
majorVersion: '14',
minorVersion: '0',
},
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'x-mercy-ks': xMercyKs,
'x-mercy-k': xMercyK,
header_appversion: '4.54.02',
header_phonesystem: 'iOS',
header_phonemodel: 'iPhone16,1',
},
});
return this.authV4(email, response.data);
}
async getBasicHomeInfo() {
if (!this.username || !this.authToken) {
this.logger.error(`Cannot get home details: not authenticated,
username is ${this.username}
authToken is ${this.authToken}`);
return undefined;
}
const api = await this.getAPIFor(this.username);
const response = await api.get('api/v1/getHomeDetail');
const apiResponse = response.data;
if (!apiResponse.data) {
throw new Error('Failed to retrieve the home details');
}
return apiResponse.data;
}
/**
* Get cached country info from the last base URL lookup
*/
getCachedCountryInfo() {
return {
country: this.cachedCountry,
countryCode: this.cachedCountryCode,
};
}
async getAPIFor(username) {
const baseUrl = await this.getBaseUrl(username);
return this.apiForUser(username, baseUrl);
}
async getBaseUrl(username) {
if (this.cachedBaseUrl && this.username === username) {
return this.cachedBaseUrl;
}
const api = await this.apiForUser(username);
const response = await api.post('api/v1/getUrlByEmail', new URLSearchParams({
email: username,
needtwostepauth: 'false',
}).toString());
const apiResponse = response.data;
if (!apiResponse.data) {
throw new Error(`Failed to retrieve base URL: ${apiResponse.msg ?? ''}`);
}
this.cachedBaseUrl = apiResponse.data.url;
this.cachedCountry = apiResponse.data.country;
this.cachedCountryCode = apiResponse.data.countrycode;
this.username = username;
return apiResponse.data.url;
}
async apiForUser(username, baseUrl = this.baseUrl) {
const instance = this.axiosFactory.create({
baseURL: baseUrl,
headers: {
header_clientid: crypto.createHash('md5').update(username).update(this.sessionId).digest('base64'),
Authorization: this.authToken,
header_clientlang: 'en',
},
});
instance.interceptors.request.use((request) => {
return AxiosLogger.requestLogger(request, {
prefixText: 'Roborock Authenticate API',
dateFormat: 'HH:MM:ss',
headers: true,
data: true,
method: true,
url: true,
params: true,
logger: this.logger.debug.bind(this.logger),
});
}, AxiosLogger.errorLogger);
instance.interceptors.response.use((response) => {
AxiosLogger.responseLogger(response, {
prefixText: 'Roborock Authenticate API',
dateFormat: 'HH:MM:ss',
headers: true,
data: true,
status: true,
statusText: true,
params: true,
logger: this.logger.debug.bind(this.logger),
});
return response;
}, AxiosLogger.errorLogger);
return instance;
}
auth(username, response) {
const userdata = response.data;
if (!userdata?.token) {
throw new Error(`Authentication failed: ${response.msg ?? ''} code: ${response.code}`);
}
this.loginWithAuthToken(username, userdata.token);
return userdata;
}
/**
* Handle v4 authentication response with specific error code handling
*/
authV4(email, response) {
if (response.code === AuthenticateResponseCode.InvalidCode) {
throw new Error('Invalid verification code. Please check and try again.');
}
if (response.code === AuthenticateResponseCode.RateLimited) {
throw new Error('Rate limited. Please wait before trying again.');
}
const userdata = response.data;
if (!userdata?.token) {
throw new Error(`Authentication failed: ${response.msg ?? ''} code: ${response.code}`);
}
this.loginWithAuthToken(email, userdata.token);
return userdata;
}
loginWithAuthToken(username, token) {
this.logger.notice(`Login using auth token for user: ${username} successfully`);
this.username = username;
this.authToken = token;
}
/**
* Generate a random alphanumeric string of specified length
*/
generateRandomString(length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
const randomBytes = crypto.randomBytes(length);
for (let i = 0; i < length; i++) {
result += chars[randomBytes[i] % chars.length];
}
return result;
}
/**
* Sign a key using the v3 API endpoint
*/
async signKeyV3(api, s) {
const response = await api.post('api/v3/key/sign', null, {
params: { s },
});
const apiResponse = response.data;
if (!apiResponse.data?.k) {
throw new Error(`Failed to sign key: ${apiResponse.msg ?? ''}`);
}
return apiResponse.data.k;
}
}
//# sourceMappingURL=authClient.js.map