tsvesync
Version:
A TypeScript library for interacting with VeSync smart home devices
926 lines (813 loc) • 34.2 kB
text/typescript
/**
* Helper functions for VeSync API
*/
import axios from 'axios';
import crypto from 'crypto';
import { VeSync } from './vesync';
import { logger } from './logger';
import { isCredentialError, isCrossRegionError } from './constants';
// API configuration - Support regional endpoints
let _apiBaseUrl = 'https://smartapi.vesync.com';
let _currentRegion = 'US';
export function getApiBaseUrl(): string {
return _apiBaseUrl;
}
export function setApiBaseUrl(url: string): void {
_apiBaseUrl = url;
}
export function getCurrentRegion(): string {
return _currentRegion;
}
export function setCurrentRegion(region: string): void {
_currentRegion = region;
if (region in REGION_ENDPOINTS) {
_apiBaseUrl = REGION_ENDPOINTS[region as keyof typeof REGION_ENDPOINTS];
}
}
// pyvesync parity:
// VeSync only exposes two API base URLs (US + EU). pyvesync intentionally uses a
// *deny-list* mapping here:
// - `US` region: US/CA/MX/JP
// - `EU` region: everything else
//
// This is counter-intuitive if you read it as "EU countries", but it's really
// "the EU *endpoint*". In practice, many accounts outside Europe (e.g. AU/NZ/SG)
// can be hosted on either endpoint depending on how/when the account was created.
//
// The correct/robust behavior comes from the Step 2 cross-region flow: when the
// region guess is wrong, the API returns `currentRegion`/`countryCode` plus a
// `bizToken`, and we retry Step 2 using the server-provided values (pyvesync-style).
export const NON_EU_COUNTRY_CODES = ['US', 'CA', 'MX', 'JP'];
// Determine region from country code
export function getRegionFromCountryCode(countryCode: string): 'US' | 'EU' {
const normalized = countryCode.trim().toUpperCase();
if (NON_EU_COUNTRY_CODES.includes(normalized)) {
return 'US';
}
return 'EU';
}
export const API_RATE_LIMIT = 30;
export const API_TIMEOUT = 15000;
export const APP_VERSION = '5.7.16'; // Updated to newer version
export const PHONE_BRAND = 'SM N9005';
export const PHONE_OS = 'Android';
export const CLIENT_INFO = 'SM N9005';
export const USER_TYPE = '1';
export const DEFAULT_TZ = 'America/New_York';
export const DEFAULT_REGION = 'US';
export const MOBILE_ID = '1234567890123456';
export const BYPASS_HEADER_UA = 'okhttp/3.12.1';
export const CLIENT_VERSION = 'VeSync 5.7.16';
// Regional API endpoints
export const REGION_ENDPOINTS = {
'US': 'https://smartapi.vesync.com',
'CA': 'https://smartapi.vesync.com',
'MX': 'https://smartapi.vesync.com',
'JP': 'https://smartapi.vesync.com',
'EU': 'https://smartapi.vesync.eu'
};
// Cross-region error codes - multiple versions exist
// CROSS_REGION_ERROR_CODES moved to constants.ts
export const APP_VERSION_TOO_LOW_ERROR_CODE = -11012022;
// Authentication error codes
export const AUTH_ERROR_CODES = {
ACCOUNT_PASSWORD_INCORRECT: -11201129,
ILLEGAL_ARGUMENT: -11000022,
CROSS_REGION: -11260022,
CROSS_REGION_ALT: -11261022
};
/**
* Get list of country codes that use the US endpoint
* These are tried when we get a cross-region error
*/
export function getUSEndpointCountryCodes(): string[] {
// Countries that use the US endpoint but might need their own country code
return ['US', 'AU', 'NZ', 'JP', 'CA', 'MX', 'SG'];
}
/**
* Get the appropriate API endpoint based on country code
*/
export function getEndpointForCountryCode(countryCode: string): 'US' | 'EU' {
// Alias for clarity: this is the region-to-endpoint mapping used by pyvesync.
return getRegionFromCountryCode(countryCode);
}
/**
* Detect user's home region from email domain or country hints
*/
export function detectUserRegion(email: string): string {
// Check for common EU email domains
const euDomains = ['.de', '.fr', '.it', '.es', '.nl', '.be', '.at', '.dk', '.se', '.no', '.fi', '.eu'];
const emailLower = email.toLowerCase();
for (const domain of euDomains) {
if (emailLower.includes(domain)) {
return 'EU';
}
}
// Default to US for unknown domains
return 'US';
}
/**
* Get country code from region
*/
export function getCountryCodeFromRegion(region: string, email?: string): string {
if (region === 'EU') {
// Try to detect specific EU country from email
if (email) {
const emailLower = email.toLowerCase();
if (emailLower.includes('.de') || emailLower.includes('german')) return 'DE';
if (emailLower.includes('.fr')) return 'FR';
if (emailLower.includes('.it')) return 'IT';
if (emailLower.includes('.es')) return 'ES';
if (emailLower.includes('.nl')) return 'NL';
}
return 'DE'; // Default to Germany for EU
}
return 'US'; // Default for non-EU
}
// Generate unique APP_ID for each session
export function generateAppId(): string {
const chars = 'ABCDEFGHIJKLMNOPqRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
// Generate unique terminal ID for authentication
export function generateTerminalId(): string {
// pyvesync parity: terminalId is a UUID-like identifier prefixed with "2".
return `2${crypto.randomUUID().replace(/-/g, '')}`;
}
export function generateTraceId(): string {
const timestamp = Math.floor(Date.now() / 1000);
const randomPart = Math.floor(Math.random() * 100000).toString().padStart(5, '0');
return `APP${MOBILE_ID.slice(-5, -1)}${timestamp}-${randomPart}`;
}
let _authTraceCallNumber = 0;
function generateAuthTraceId(terminalId: string): string {
_authTraceCallNumber += 1;
const suffix = terminalId.slice(-5, -1);
const timestamp = Math.floor(Date.now() / 1000);
return `APP${suffix}${timestamp}-${String(_authTraceCallNumber).padStart(5, '0')}`;
}
export interface RequestBody {
acceptLanguage?: string;
accountID?: string;
appVersion?: string;
cid?: string;
configModule?: string;
debugMode?: boolean;
deviceRegion?: string;
email?: string;
method?: string;
password?: string;
phoneBrand?: string;
phoneOS?: string;
timeZone?: string;
token?: string;
traceId?: string;
userType?: string;
uuid?: string;
status?: string;
// New authentication parameters
authorizeCode?: string;
bizToken?: string;
regionChange?: string;
userCountryCode?: string;
authProtocolType?: string;
clientType?: string;
sourceAppID?: string;
appID?: string;
clientVersion?: string;
[key: string]: any;
}
// New authentication response interfaces
export interface AuthResponse {
code: number;
msg: string;
result?: {
authorizeCode?: string;
bizToken?: string;
userCountryCode?: string;
[key: string]: any;
};
}
export interface LoginResponse {
code: number;
msg: string;
result?: {
token?: string;
accountID?: string;
countryCode?: string;
[key: string]: any;
};
}
export class Helpers {
static shouldRedact = true;
static generateTraceId(): string {
return generateTraceId();
}
static normalizeAirQuality(value: unknown): { level: number; label: string } {
const stringMap: Record<string, number> = {
'excellent': 1,
'very good': 1,
'good': 2,
'moderate': 3,
'fair': 3,
'inferior': 4,
'poor': 4,
'bad': 4,
};
if (typeof value === 'number') {
const level = Number.isFinite(value) ? Math.trunc(value) : -1;
if (level >= 1 && level <= 4) {
return {
level,
label: level === 1
? 'excellent'
: level === 2
? 'good'
: level === 3
? 'moderate'
: 'poor',
};
}
} else if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
const level = stringMap[normalized];
if (level) {
return {
level,
label: level === 1
? 'excellent'
: level === 2
? 'good'
: level === 3
? 'moderate'
: 'poor',
};
}
}
return { level: -1, label: 'unknown' };
}
/**
* Calculate MD5 hash
*/
static hashPassword(text: string): string {
return crypto.createHash('md5').update(text).digest('hex');
}
/**
* Build header for legacy api GET requests
*/
static reqHeaders(manager: VeSync): Record<string, string> {
if (!manager.accountId || !manager.token) {
throw new Error('Manager accountId and token must be set');
}
return {
'Content-Type': 'application/json; charset=UTF-8',
'User-Agent': BYPASS_HEADER_UA,
'accept-language': 'en',
'accountId': manager.accountId,
'appVersion': APP_VERSION,
'content-type': 'application/json',
'tk': manager.token,
'tz': manager.timeZone
};
}
/**
* Build header for api requests on 'bypass' endpoint
*/
static reqHeaderBypass(): Record<string, string> {
return {
'Content-Type': 'application/json; charset=UTF-8',
'User-Agent': BYPASS_HEADER_UA
};
}
/**
* Build header for new authentication endpoints
*/
static reqHeaderAuth(): Record<string, string> {
return {
'Content-Type': 'application/json; charset=UTF-8',
'User-Agent': BYPASS_HEADER_UA,
'accept-language': 'en',
'appVersion': APP_VERSION,
'clientVersion': CLIENT_VERSION
};
}
/**
* Return universal keys for body of api requests
*/
static reqBodyBase(manager: VeSync): Record<string, string> {
return {
'timeZone': manager.timeZone,
'acceptLanguage': 'en'
};
}
/**
* Keys for authenticating api requests
*/
static reqBodyAuth(manager: VeSync): Record<string, any> {
if (!manager.accountId || !manager.token) {
throw new Error('Manager accountId and token must be set');
}
return {
'accountID': manager.accountId,
'token': manager.token
};
}
/**
* Detail keys for api requests
*/
static reqBodyDetails(): Record<string, string> {
return {
'appVersion': APP_VERSION,
'phoneBrand': PHONE_BRAND,
'phoneOS': PHONE_OS,
'traceId': Date.now().toString()
};
}
/**
* Build request body for initial authentication step
*/
static reqBodyAuthStep1(manager: VeSync, appId: string, terminalId: string, userCountryCode: string): Record<string, any> {
// pyvesync parity:
// - Mirrors `pyvesync.models.vesync_models.RequestGetTokenModel`
// - `userCountryCode` defaults to `DEFAULT_REGION` ("US") unless the caller overrides it
// - `timeZone` defaults to `DEFAULT_TZ` ("America/New_York") during auth; the API later updates the
// effective timezone used for device operations.
return {
'email': manager.username,
'method': 'authByPWDOrOTM',
'password': this.hashPassword(manager.password),
'acceptLanguage': 'en',
'accountID': '',
'authProtocolType': 'generic',
'clientInfo': PHONE_BRAND,
'clientType': 'vesyncApp',
'clientVersion': CLIENT_VERSION,
'debugMode': false,
'osInfo': PHONE_OS,
'terminalId': terminalId,
'timeZone': DEFAULT_TZ,
'token': '',
'userCountryCode': userCountryCode.trim().toUpperCase(),
'appID': appId,
'sourceAppID': appId,
'traceId': generateAuthTraceId(terminalId)
};
}
/**
* Build request body for second authentication step (login with authorize code)
*/
static reqBodyAuthStep2(authorizeCode: string, bizToken: string | null, _appId: string, terminalId: string, userCountryCode?: string, regionChange?: string): Record<string, any> {
// pyvesync parity:
// - Mirrors `pyvesync.models.vesync_models.RequestLoginTokenModel`
// - `timeZone` remains `DEFAULT_TZ` during auth (device timezone is handled elsewhere)
// - Step 2 intentionally does NOT include `appID` / `sourceAppID`. Those are only sent in Step 1
// (`RequestGetTokenModel`). pyvesync's Step 2 request model has no appID fields.
// If you see older diagnostics including appID/sourceAppID in Step 2, treat it as a superset payload
// rather than a requirement — our goal here is to match the known-working pyvesync request shape.
// - IMPORTANT: `bizToken` here is the *region-change token* returned by VeSync on Step 2 CROSS_REGION
// errors. It is not part of the normal Step 1 -> Step 2 flow. pyvesync does not send a Step 1
// bizToken in the initial Step 2 request (Step 1 result is just `{ accountID, authorizeCode }`);
// it only includes `bizToken` + `regionChange='lastRegion'`
// when retrying Step 2 after a cross-region response. See `pyvesync/auth.py:_exchange_authorization_code`.
const body: Record<string, any> = {
'method': 'loginByAuthorizeCode4Vesync',
'authorizeCode': authorizeCode,
'acceptLanguage': 'en',
'accountID': '',
'clientInfo': PHONE_BRAND,
'clientType': 'vesyncApp',
'clientVersion': CLIENT_VERSION,
'debugMode': false,
'emailSubscriptions': false,
'osInfo': PHONE_OS,
'terminalId': terminalId,
'timeZone': DEFAULT_TZ,
'token': '',
'userCountryCode': (userCountryCode || DEFAULT_REGION).trim().toUpperCase(),
'traceId': generateAuthTraceId(terminalId)
};
// Only include bizToken if it's not null
if (bizToken) {
body.bizToken = bizToken;
}
// Only include regionChange if provided
if (regionChange) {
body.regionChange = regionChange;
}
return body;
}
/**
* Builder for body of api requests
*/
static reqBody(manager: VeSync, type: string): Record<string, any> {
const body = {
...this.reqBodyBase(manager)
};
if (type === 'login') {
return {
...body,
...this.reqBodyDetails(),
email: manager.username,
password: this.hashPassword(manager.password),
devToken: '',
userType: USER_TYPE,
method: 'login'
};
}
const authBody = {
...body,
...this.reqBodyAuth(manager)
};
if (type === 'devicestatus') {
return authBody;
}
const fullBody = {
...authBody,
...this.reqBodyDetails()
};
switch (type) {
case 'devicelist':
return {
...fullBody,
method: 'devices',
pageNo: '1',
pageSize: '100'
};
case 'devicedetail':
return {
...fullBody,
method: 'devicedetail',
mobileId: MOBILE_ID
};
case 'bypass':
return {
...fullBody,
method: 'bypass'
};
case 'bypassV2':
return {
...fullBody,
deviceRegion: DEFAULT_REGION,
method: 'bypassV2'
};
case 'bypass_config':
return {
...fullBody,
method: 'firmwareUpdateInfo'
};
default:
return fullBody;
}
}
/**
* Call VeSync API
*/
static async callApi(
endpoint: string,
method: string,
data: any = null,
headers: Record<string, string> = {},
manager: VeSync
): Promise<[any, number]> {
let url = '';
try {
// Prefer manager-scoped base URL (pyvesync resolves per-manager from current region).
// Keep the module-level base URL as a fallback for older callers.
let baseUrl = manager.apiBaseUrl || _apiBaseUrl;
// Ensure API base URL is properly set
if (!baseUrl || baseUrl === 'undefined') {
logger.error('API base URL is not properly configured. Falling back to US endpoint...');
baseUrl = 'https://smartapi.vesync.com';
manager.apiBaseUrl = baseUrl;
}
url = baseUrl + endpoint;
logger.debug(`Making API call to: ${url}`);
const response = await axios({
method,
url,
data,
headers,
timeout: API_TIMEOUT
});
return [response.data, response.status];
} catch (error: any) {
if (error.response) {
const responseData = error.response.data;
// Check for token expiration or auth invalidation
const httpStatus = error.response.status;
const msg: string | undefined = responseData?.msg;
if (
httpStatus === 401 ||
httpStatus === 419 ||
responseData?.code === 4001004 ||
(typeof msg === 'string' && /token\s*expired/i.test(msg))
) {
logger.debug('Token expired, attempting to re-login...');
// Re-login
if (await manager.login()) {
// Retry the original request
logger.debug('Re-login successful, retrying original request...');
return await this.callApi(endpoint, method, data, headers, manager);
}
}
// Log specific error details for debugging
logger.error('API call failed with response:', {
status: error.response.status,
code: responseData?.code,
message: responseData?.msg,
url
});
return [responseData, error.response.status];
}
logger.error('API call failed:', {
code: error.code,
message: error.message,
url: url || (manager.apiBaseUrl || _apiBaseUrl || '') + endpoint
});
return [null, 0];
}
}
/**
* Calculate hex value from energy response
*/
static calculateHex(hexStr: string): string {
if (!hexStr || !hexStr.includes(':')) {
return '0';
}
const [prefix, value] = hexStr.split(':');
if (!prefix || !value) {
return '0';
}
try {
const decoded = Buffer.from(value, 'hex');
return decoded.readFloatBE(0).toString();
} catch (error) {
logger.debug('Error decoding hex value:', error);
return '0';
}
}
/**
* Build energy dictionary from API response
*/
static buildEnergyDict(result: any): Record<string, any> {
if (!result) {
return {};
}
return {
energy_consumption_of_today: result.energyConsumptionOfToday || 0,
cost_per_kwh: result.costPerKWH || 0,
max_energy: result.maxEnergy || 0,
total_energy: result.totalEnergy || 0,
data: result.data || [],
energy_consumption: result.energy || 0,
start_time: result.startTime || '',
end_time: result.endTime || ''
};
}
/**
* Build configuration dictionary from API response
*/
static buildConfigDict(result: any): Record<string, any> {
if (!result) {
return {};
}
return {
configModule: result.configModule || '',
firmwareVersion: result.currentFirmVersion || '',
deviceRegion: result.deviceRegion || '',
debugMode: result.debugMode || false,
deviceTimezone: result.deviceTimezone || DEFAULT_TZ,
...result
};
}
/**
* Calculate MD5 hash
*/
static md5(text: string): string {
return crypto.createHash('md5').update(text).digest('hex');
}
/**
* Perform new two-step authentication flow
*/
static async authNewFlow(manager: VeSync, appId: string, region: string = 'US', countryCodeOverride?: string): Promise<[boolean, string | null, string | null, string | null]> {
try {
// Generate terminal ID to be used in both steps
const terminalId = generateTerminalId();
// pyvesync parity:
// - `userCountryCode` defaults to `DEFAULT_REGION` ("US"), even if we first try the EU base URL.
// - If the account truly belongs to another region, the Step 2 response includes the correct
// `countryCode`/`currentRegion` and a `bizToken` to retry Step 2 (handled below).
// Avoid "guessing" a country code from `region` or email heuristics here; it diverges from pyvesync.
const userCountryCode = (countryCodeOverride || DEFAULT_REGION).trim().toUpperCase();
// Step 1: Get authorization code
const step1Body = this.reqBodyAuthStep1(manager, appId, terminalId, userCountryCode);
const step1Headers = this.reqHeaderAuth();
// Set the correct regional endpoint
const originalUrl = manager.apiBaseUrl || getApiBaseUrl();
const knownBaseUrls = new Set(Object.values(REGION_ENDPOINTS));
const hasCustomBaseUrlOverride = !!manager.apiUrlOverride || !knownBaseUrls.has(originalUrl);
if (!hasCustomBaseUrlOverride && region in REGION_ENDPOINTS) {
const nextUrl = REGION_ENDPOINTS[region as keyof typeof REGION_ENDPOINTS];
manager.apiBaseUrl = nextUrl;
}
logger.debug('Step 1: Getting authorization code...', {
region,
endpoint: manager.apiBaseUrl || getApiBaseUrl(),
body: { ...step1Body, password: '[REDACTED]' }
});
const [authResponse, authStatus] = await this.callApi(
'/globalPlatform/api/accountAuth/v1/authByPWDOrOTM',
'post',
step1Body,
step1Headers,
manager
);
if (!authResponse || authStatus !== 200) {
logger.error('Step 1 failed:', authResponse);
manager.apiBaseUrl = originalUrl;
return [false, null, null, null];
}
if (authResponse.code !== 0) {
logger.error('Step 1 error code:', authResponse.code, 'message:', authResponse.msg);
// Check if it's a credential error (no point retrying)
if (isCredentialError(authResponse.code)) {
logger.error('Credential error detected - invalid username or password');
manager.apiBaseUrl = originalUrl;
return [false, null, null, 'credential_error'];
}
// Handle cross-region error (multiple error codes possible)
if (isCrossRegionError(authResponse.code)) {
logger.debug('Cross-region error detected:', authResponse.code, 'will try different region');
manager.apiBaseUrl = originalUrl;
return [false, null, null, 'cross_region'];
}
manager.apiBaseUrl = originalUrl;
return [false, null, null, null];
}
// pyvesync parity note:
// - Step 1 "authByPWDOrOTM" returns a result shaped like RespGetTokenResultModel:
// `{ accountID, authorizeCode }`. There is no Step 1 bizToken to forward.
// See `pyvesync/models/vesync_models.py:RespGetTokenResultModel`.
const { authorizeCode } = authResponse.result || {};
if (!authorizeCode) {
logger.error('Missing authorization code in step 1 response:', authResponse.result);
manager.apiBaseUrl = originalUrl;
return [false, null, null, null];
}
logger.debug('Step 1 successful, got authorization code');
// Step 2: Login with authorization code
// Use the override if provided, otherwise default to 'US'
// Users should specify their actual country code in the configuration
const countryCodeForStep2 = userCountryCode;
// pyvesync parity note:
// - Step 1 returns `authorizeCode` (and an `accountID`) but does not require/provide a bizToken for
// the initial Step 2 request.
// - Step 2 initial request omits `bizToken`.
// - Only if Step 2 returns a CROSS_REGION error does VeSync provide `result.bizToken`, which is then
// echoed back as `bizToken` along with `regionChange='lastRegion'` for a Step 2 retry.
// See `pyvesync/auth.py:_exchange_authorization_code` for the canonical behavior.
// - Step 2 also intentionally omits appID/sourceAppID (pyvesync parity); we still pass `appId` through
// to keep the function signature stable/historical.
const step2Body = this.reqBodyAuthStep2(authorizeCode, null, appId, terminalId, countryCodeForStep2);
logger.debug('Step 2: Logging in with authorization code...');
const [loginResponse, loginStatus] = await this.callApi(
'/user/api/accountManage/v1/loginByAuthorizeCode4Vesync',
'post',
step2Body,
step1Headers,
manager
);
logger.debug('Step 2 response:', {
status: loginStatus,
code: loginResponse?.code,
hasResult: !!loginResponse?.result,
msg: loginResponse?.msg
});
if (!loginResponse || loginStatus !== 200) {
logger.error('Step 2 failed:', loginResponse);
manager.apiBaseUrl = originalUrl;
return [false, null, null, null];
}
if (loginResponse.code !== 0) {
logger.debug('Step 2 error, code:', loginResponse.code, 'checking if cross-region...');
// Align with pyvesync: on cross-region errors, retry Step 2 using the server-provided
// country/region and bizToken + regionChange.
if (isCrossRegionError(loginResponse.code)) {
const serverCountryCode: string | undefined = loginResponse.result?.countryCode;
const serverRegion: string | undefined = loginResponse.result?.currentRegion;
const regionChangeToken: string | undefined = loginResponse.result?.bizToken;
logger.debug('Cross-region error detected in Step 2; attempting pyvesync-style retry', {
requestedRegion: region,
requestedCountryCode: countryCodeForStep2,
serverCountryCode,
serverRegion,
hasRegionChangeToken: !!regionChangeToken,
});
// If VeSync tells us which region to use, switch to it for subsequent requests.
if (!hasCustomBaseUrlOverride && serverRegion && serverRegion in REGION_ENDPOINTS) {
manager.region = serverRegion;
logger.debug(`Updated region to ${serverRegion} for Step 2 retry`);
}
// If VeSync gives us a region-change token, retry Step 2 without redoing Step 1.
if (regionChangeToken) {
const retryCountryCode = serverCountryCode || countryCodeForStep2;
const retryBody = this.reqBodyAuthStep2(
authorizeCode,
regionChangeToken,
appId,
terminalId,
retryCountryCode,
'lastRegion'
);
const [retryResponse, retryStatus] = await this.callApi(
'/user/api/accountManage/v1/loginByAuthorizeCode4Vesync',
'post',
retryBody,
step1Headers,
manager
);
if (retryResponse && retryStatus === 200 && retryResponse.code === 0) {
const { token, accountID, countryCode } = retryResponse.result || {};
if (token && accountID) {
logger.debug('Step 2 retry successful, got token and accountID');
return [true, token, accountID, countryCode || retryCountryCode];
}
logger.error('Missing required fields in Step 2 retry response:', retryResponse.result);
manager.apiBaseUrl = originalUrl;
return [false, null, null, null];
}
logger.error('Step 2 retry failed:', {
status: retryStatus,
code: retryResponse?.code,
msg: retryResponse?.msg,
});
manager.apiBaseUrl = originalUrl;
return [false, null, null, 'cross_region_retry'];
}
logger.error('Cross-region error returned no region-change token; cannot retry Step 2');
manager.apiBaseUrl = originalUrl;
return [false, null, null, 'cross_region_retry'];
}
logger.error('Step 2 error code:', loginResponse.code, 'message:', loginResponse.msg);
manager.apiBaseUrl = originalUrl;
return [false, null, null, null];
}
const { token, accountID, countryCode } = loginResponse.result || {};
if (!token || !accountID) {
logger.error('Missing required fields in step 2 response:', loginResponse.result);
return [false, null, null, null];
}
logger.debug('Step 2 successful, got token and accountID');
return [true, token, accountID, countryCode || countryCodeForStep2];
} catch (error) {
logger.error('New authentication flow error:', error);
return [false, null, null, null];
}
}
/**
* Perform legacy authentication (fallback)
*/
static async authLegacyFlow(manager: VeSync): Promise<[boolean, string | null, string | null, string | null]> {
try {
const body = this.reqBody(manager, 'login');
logger.debug('Legacy login attempt...', {
endpoint: manager.apiBaseUrl || getApiBaseUrl(),
body: { ...body, password: '[REDACTED]' }
});
const [response, status] = await this.callApi(
'/cloud/v1/user/login',
'post',
body,
{},
manager
);
if (!response || status !== 200) {
logger.error('Legacy login failed:', response);
return [false, null, null, null];
}
if (response.code && response.code !== 0) {
logger.error('Legacy login error code:', response.code, 'message:', response.msg);
// Check if it's a credential error
if (isCredentialError(response.code)) {
logger.error('Legacy auth: Credential error detected');
return [false, null, null, 'credential_error'];
}
return [false, null, null, null];
}
const { token, accountID, countryCode } = response.result || {};
if (!token || !accountID) {
logger.error('Missing required fields in legacy login response:', response.result);
return [false, null, null, null];
}
logger.debug('Legacy login successful');
return [true, token, accountID, countryCode];
} catch (error) {
logger.error('Legacy authentication flow error:', error);
return [false, null, null, null];
}
}
}