homebridge-levoit-humidifiers
Version:
Homebridge plugin for Levoit Humidifiers
932 lines • 40.9 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.BypassMethod = void 0;
const axios_1 = __importDefault(require("axios"));
const async_lock_1 = __importDefault(require("async-lock"));
const uuid_1 = require("uuid");
const crypto = __importStar(require("node:crypto"));
const fs = __importStar(require("node:fs"));
const path = __importStar(require("node:path"));
const deviceTypes_1 = __importDefault(require("./deviceTypes"));
const VeSyncFan_1 = __importDefault(require("./VeSyncFan"));
/**
* VeSync API bypass methods for device control.
* These methods are sent to the VeSync API to control device features.
*/
var BypassMethod;
(function (BypassMethod) {
BypassMethod["STATUS"] = "getHumidifierStatus";
BypassMethod["MODE"] = "setHumidityMode";
BypassMethod["NIGHT_LIGHT_BRIGHTNESS"] = "setNightLightBrightness";
BypassMethod["DISPLAY"] = "setDisplay";
BypassMethod["SWITCH"] = "setSwitch";
BypassMethod["HUMIDITY"] = "setTargetHumidity";
BypassMethod["MIST_LEVEL"] = "setVirtualLevel";
BypassMethod["LEVEL"] = "setLevel";
BypassMethod["LIGHT_STATUS"] = "setLightStatus";
BypassMethod["DRYING_MODE"] = "setDryingMode";
})(BypassMethod || (exports.BypassMethod = BypassMethod = {}));
// Known API hosts
const US_HOST = 'https://smartapi.vesync.com';
const EU_HOST = 'https://smartapi.vesync.eu';
const ACCOUNT_HOST = 'https://accountapi.vesync.com';
/**
* Error message returned by VeSync API when device is offline.
*/
const DEVICE_OFFLINE_MSG = 'device offline';
/**
* Standard error message for unreachable devices.
*/
const DEVICE_UNREACHABLE_ERROR = 'Device was unreachable. Ensure it is plugged in and connected to WiFi.';
/**
* VeSync API error code for daily request quota exceeded.
* Quota formula: 3200 + 1500 * user owned device number
*/
const QUOTA_EXCEEDED_CODE = -16906086;
/**
* VeSync API error code for expired authentication token.
*/
const TOKEN_EXPIRED_CODE = -11001022;
// Start on US host for a small set of known non-EU regions – everyone else uses EU
const EU_COUNTRY_CODES = new Set([
'AL',
'AD',
'AT',
'BY',
'BE',
'BA',
'BG',
'HR',
'CY',
'CZ',
'DK',
'EE',
'FI',
'FR',
'DE',
'GR',
'HU',
'IS',
'IE',
'IT',
'LV',
'LI',
'LT',
'LU',
'MT',
'MD',
'MC',
'ME',
'NL',
'MK',
'NO',
'PL',
'PT',
'RO',
'RU',
'SM',
'RS',
'SK',
'SI',
'ES',
'SE',
'CH',
'TR',
'UA',
'GB',
'UK',
]);
/**
* Determines the initial API host based on country code.
* EU countries use the EU host, all others use the US host.
*
* @param cc - Country code (2-letter ISO code)
* @returns The appropriate API host URL
*/
function initialHostForCountry(cc) {
const upper = (cc || '').toUpperCase();
if (EU_COUNTRY_CODES.has(upper))
return EU_HOST;
return US_HOST;
}
const lock = new async_lock_1.default();
/**
* Decodes JWT timestamps (issued at, expires at) from a token.
* Best-effort decoder with no signature verification.
* Used to validate session token expiration.
*
* @param token - JWT token string
* @returns Object with iat (issued at) and exp (expires at) timestamps, or empty object on error
*/
function decodeJwtTimestamps(token) {
try {
const parts = token.split('.');
if (parts.length < 2)
return {};
const part = parts[1];
if (!part)
return {};
const payload = part
.replaceAll('-', '+')
.replaceAll('_', '/')
.padEnd(Math.ceil(part.length / 4) * 4, '=');
const json = Buffer.from(payload, 'base64').toString('utf8');
const obj = JSON.parse(json);
return { iat: obj.iat, exp: obj.exp };
}
catch (_a) {
return {};
}
}
/**
* VeSync API client for authenticating and communicating with VeSync devices.
*
* Features:
* - Two-step authentication with session persistence
* - Automatic cross-region detection and switching
* - Session token caching to disk for faster re-authentication
* - Automatic token refresh on 401 errors
* - Login backoff to prevent API abuse
* - Support for US and EU API endpoints
*
* The authentication flow:
* 1. Step 1: authByPWDOrOTM - Authenticates with email/password, returns authorizeCode
* 2. Step 2: loginByAuthorizeCode4Vesync - Exchanges authorizeCode for session token
* 3. If cross-region detected, retries step 2 with correct region
*/
class VeSync {
constructor(email, password, config, debugMode, log, sessionPath) {
var _a, _b, _c, _d, _e;
this.email = email;
this.password = password;
this.config = config;
this.debugMode = debugMode;
this.log = log;
this.VERSION = '5.6.60';
this.FULL_VERSION = `VeSync ${this.VERSION}`;
this.AGENT = `VeSync/${this.VERSION} (iPhone; iOS 17.2.1; Humidifier/5.00)`;
this.TIMEZONE = 'America/New_York';
this.OS = 'iOS 17.2.1';
this.BRAND = 'iPhone 15 Pro';
this.LANG = 'en';
/**
* Terminal/device identifier that VeSync expects to remain stable across sessions.
* Generated once per instance and used for all API calls.
*/
this.terminalId = '2' + (0, uuid_1.v4)().replaceAll('-', '');
/**
* Application ID used for authentication requests.
* Randomly generated per instance.
*/
this.appID = Math.random().toString(36).substring(2, 10);
/**
* Simple login backoff to prevent hammering the API on repeated failures.
* Starts at 10 seconds, doubles on each failure, caps at 5 minutes.
*/
this.lastLoginAttempt = 0;
this.loginBackoffMs = 10000; // start at 10s, max 5min
/**
* Maximum age for session tokens (25 days).
* Tokens older than this are considered invalid even if JWT doesn't specify expiration.
*/
this.TOKEN_MAX_AGE_MS = 25 * 24 * 60 * 60 * 1000;
// Auth headers/body constants
this.BYPASS_HEADER_UA = 'okhttp/3.12.1';
this.AUTH_APP_VERSION = '5.7.16';
this.AUTH_CLIENT_VERSION = `VeSync ${this.AUTH_APP_VERSION}`;
this.AUTH_CLIENT_INFO = 'SM N9005';
this.AUTH_OS_INFO = 'Android';
const cc = (((_a = config.options) === null || _a === void 0 ? void 0 : _a.countryCode) || 'US').toUpperCase();
this.countryCode = cc;
this.baseURL = ((_b = config.options) === null || _b === void 0 ? void 0 : _b.apiHost) || initialHostForCountry(cc);
// Session file path: use provided path, or config option, or default to cwd
this.sessionFilePath =
sessionPath ||
((_c = config.options) === null || _c === void 0 ? void 0 : _c.sessionPath) ||
path.join(process.cwd(), 'vesync-session.json');
(_e = (_d = this.debugMode).debug) === null || _e === void 0 ? void 0 : _e.call(_d, '[CONFIG]', `countryCode=${cc}, initialBaseURL=${this.baseURL}, sessionFile=${this.sessionFilePath}`);
}
/**
* Gets axios options for device API calls.
* @returns Axios configuration with baseURL and timeout
*/
AXIOS_OPTIONS() {
var _a;
return {
baseURL: this.baseURL,
timeout: ((_a = this.config.options) === null || _a === void 0 ? void 0 : _a.apiTimeout) || 15000,
};
}
/**
* Gets axios options for authentication API calls.
* @param host - Optional host override (defaults to baseURL)
* @returns Axios configuration with authentication headers
*/
AUTH_AXIOS_OPTIONS(host) {
var _a;
return {
baseURL: host !== null && host !== void 0 ? host : this.baseURL,
timeout: ((_a = this.config.options) === null || _a === void 0 ? void 0 : _a.apiTimeout) || 15000,
headers: {
'Content-Type': 'application/json; charset=UTF-8',
'User-Agent': this.BYPASS_HEADER_UA,
'accept-language': this.LANG,
appVersion: this.AUTH_APP_VERSION,
clientVersion: this.AUTH_CLIENT_VERSION,
},
};
}
/**
* Generates detail body for device API requests.
* Contains app version, device info, and trace ID.
* @returns Detail body object
*/
generateDetailBody() {
return {
appVersion: this.FULL_VERSION,
phoneBrand: this.BRAND,
traceId: `APP${Date.now()}-00001`,
phoneOS: this.OS,
};
}
/**
* Generates base body for API requests.
* @param includeAuth - Whether to include accountID and token
* @returns Base body object with language, timezone, and optionally auth
*/
generateBody(includeAuth = false) {
return {
acceptLanguage: this.LANG,
timeZone: this.TIMEZONE,
...(includeAuth
? {
accountID: this.accountId,
token: this.token,
}
: {}),
};
}
/**
* Generates V2 bypass body for device control commands.
* @param fan - The device to send command to
* @param method - The bypass method to execute
* @param data - Command-specific data payload
* @returns V2 bypass body object
*/
generateV2Body(fan, method, data = {}) {
return {
method: 'bypassV2',
debugMode: false,
deviceRegion: fan.region,
cid: fan.cid,
configModule: fan.configModule,
payload: {
data: {
...data,
},
method,
source: 'APP',
},
};
}
/**
* Generates a unique trace ID for authentication requests.
* Format: APP{appID}{timestamp}
* @returns Trace ID string
*/
generateAuthTraceId() {
return `APP${this.appID}${Math.floor(Date.now() / 1000)}`;
}
// --- Session persistence ---------------------------------------------------
/**
* Loads persisted session from disk if available and valid.
* Validates token expiration and account match before returning.
*
* @returns Session data if valid, null otherwise
*/
async loadSessionFromDisk() {
var _a;
if (!this.sessionFilePath)
return null;
try {
const raw = await fs.promises.readFile(this.sessionFilePath, 'utf8');
const session = JSON.parse(raw);
const persistedBaseURL = session.apiBaseUrl || session.baseURL;
if (!session.token || !session.accountId || !persistedBaseURL) {
this.debugMode.debug('[SESSION]', 'Session file missing required fields, ignoring.');
return null;
}
if (session.username && session.username !== this.email) {
this.debugMode.debug('[SESSION]', 'Persisted session is for a different account; ignoring.');
return null;
}
const now = Date.now();
const { iat, exp } = decodeJwtTimestamps(session.token);
if (exp && exp * 1000 <= now) {
this.debugMode.debug('[SESSION]', 'Persisted token is expired, ignoring.');
return null;
}
// Also protect against extremely old tokens if exp is missing
const issuedMs = (_a = session.issuedAt) !== null && _a !== void 0 ? _a : (iat ? iat * 1000 : now);
if (now - issuedMs > this.TOKEN_MAX_AGE_MS * 1.5) {
this.debugMode.debug('[SESSION]', 'Persisted token appears too old, ignoring.');
return null;
}
session.baseURL = persistedBaseURL;
this.debugMode.debug('[SESSION]', 'Loaded persisted session from disk.');
return session;
}
catch (e) {
const error = e;
if (error.code !== 'ENOENT') {
this.debugMode.debug('[SESSION]', 'Failed to load session from disk:', String(e));
}
return null;
}
}
/**
* Saves current session to disk for faster re-authentication.
* Includes token, account ID, country code, and expiration info.
*/
async saveSessionToDisk() {
if (!this.sessionFilePath || !this.token || !this.accountId)
return;
try {
const { iat, exp } = decodeJwtTimestamps(this.token);
const session = {
token: this.token,
accountId: this.accountId,
countryCode: this.countryCode,
apiBaseUrl: this.baseURL,
baseURL: this.baseURL,
region: this.region,
username: this.email,
issuedAt: iat !== null && iat !== void 0 ? iat : null,
expiresAt: exp !== null && exp !== void 0 ? exp : null,
lastValidatedAt: Date.now(),
};
await fs.promises.writeFile(this.sessionFilePath, JSON.stringify(session, null, 2), 'utf8');
this.debugMode.debug('[SESSION]', 'Persisted VeSync session to disk.');
}
catch (e) {
this.debugMode.debug('[SESSION]', 'Failed to save session to disk:', String(e));
}
}
/**
* Checks if the current token is still valid.
* Validates JWT expiration if present, or checks token age against max age.
*
* @returns true if token is valid, false if expired or missing
*/
isTokenValid() {
if (!this.token) {
return false;
}
const now = Date.now();
const { iat, exp } = decodeJwtTimestamps(this.token);
// Check JWT expiration if present
if (exp && exp * 1000 <= now) {
this.debugMode.debug('[TOKEN]', 'Token expired according to JWT exp claim');
return false;
}
// If no exp claim, check against max age (25 days)
// We use iat from JWT or fall back to a conservative estimate
if (!exp) {
const issuedMs = iat ? iat * 1000 : now - this.TOKEN_MAX_AGE_MS;
if (now - issuedMs > this.TOKEN_MAX_AGE_MS) {
this.debugMode.debug('[TOKEN]', 'Token appears too old (no exp claim)');
return false;
}
}
return true;
}
/**
* Builds and configures the axios API client with authentication headers.
* Sets up automatic token refresh on 401 errors.
*
* @throws Error if token or accountId is missing
*/
buildApiClient() {
if (!this.token || !this.accountId) {
throw new Error('Cannot build API client without token/accountId');
}
this.api = axios_1.default.create({
...this.AXIOS_OPTIONS(),
headers: {
'content-type': 'application/json',
'accept-language': this.LANG,
accountid: this.accountId,
'user-agent': this.AGENT,
appversion: this.FULL_VERSION,
tz: this.TIMEZONE,
tk: this.token,
},
});
// Automatic token refresh on 401 Unauthorized and token error codes
this.api.interceptors.response.use((resp) => {
var _a;
// Check for token errors in successful responses (HTTP 200 with error code in body)
if (resp.status === 200 && ((_a = resp.data) === null || _a === void 0 ? void 0 : _a.code) === TOKEN_EXPIRED_CODE) {
// Convert this into a rejection so the error handler below can retry
const error = new Error('Token expired');
error.response = resp;
error.config = resp.config;
error.isTokenExpired = true;
return Promise.reject(error);
}
return resp;
}, async (err) => {
var _a, _b, _c, _d, _e;
const isTokenError = ((_a = err === null || err === void 0 ? void 0 : err.response) === null || _a === void 0 ? void 0 : _a.status) === 401 ||
((_b = err === null || err === void 0 ? void 0 : err.response) === null || _b === void 0 ? void 0 : _b.status) === 419 ||
((_d = (_c = err === null || err === void 0 ? void 0 : err.response) === null || _c === void 0 ? void 0 : _c.data) === null || _d === void 0 ? void 0 : _d.code) === TOKEN_EXPIRED_CODE ||
(err === null || err === void 0 ? void 0 : err.isTokenExpired);
if (isTokenError) {
// Prevent infinite retry loops
if ((_e = err.config) === null || _e === void 0 ? void 0 : _e._retryAttempted) {
this.log.error('Token refresh failed after retry. Authentication may be broken.');
throw err;
}
this.debugMode.debug('[AUTH]', 'Token error detected, re-authenticating…');
const ok = await this.login();
if (ok && err.config && this.api) {
// Mark this request as already retried
err.config._retryAttempted = true;
// Retry the original request with new token
err.config.headers = err.config.headers || {};
err.config.headers.tk = this.token;
err.config.headers.accountid = this.accountId;
return this.api.request(err.config);
}
}
throw err;
});
}
// --- Public API ------------------------------------------------------------
/**
* Handles device offline error response.
* Checks if the response indicates device is offline and handles accordingly.
*
* @param responseMsg - The message from the API response (may be undefined)
* @param returnValue - Value to return if showOffWhenDisconnected is enabled
* @returns The returnValue if showOffWhenDisconnected is enabled and device is offline
* @throws Error if showOffWhenDisconnected is disabled and device is offline
* @returns undefined if device is not offline (caller should continue normal processing)
*/
handleDeviceOffline(responseMsg, returnValue) {
var _a;
if (responseMsg === DEVICE_OFFLINE_MSG) {
this.log.error('VeSync cannot communicate with humidifier! Check the VeSync App.');
if ((_a = this.config.options) === null || _a === void 0 ? void 0 : _a.showOffWhenDisconnected) {
return returnValue;
}
else {
throw new Error(DEVICE_UNREACHABLE_ERROR);
}
}
return undefined;
}
/**
* Checks if the API response indicates quota exceeded error.
* Logs a warning and returns true if quota is exceeded.
*
* @param responseCode - The error code from the API response
* @param responseMsg - The error message from the API response
* @returns true if quota is exceeded, false otherwise
*/
handleQuotaExceeded(responseCode, responseMsg) {
if (responseCode === QUOTA_EXCEEDED_CODE) {
this.log.warn('VeSync API daily quota exceeded. The quota formula is "3200 + 1500 * user owned device number".');
this.log.warn('Polling frequency has been reduced to 30 seconds. Quota resets daily.');
if (responseMsg) {
this.debugMode.debug('[QUOTA]', responseMsg);
}
return true;
}
return false;
}
/**
* Ensures the authentication token is valid before making API calls.
* Proactively checks token expiration and refreshes if needed.
*
* @throws Error if token refresh fails or API client is unavailable
*/
async ensureValidToken() {
if (!this.isTokenValid()) {
this.debugMode.debug('[TOKEN]', 'Token invalid, refreshing before API call');
const ok = await this.login();
if (!ok) {
throw new Error('Failed to refresh expired token');
}
// login() rebuilds the API client, but we need to ensure it's ready
if (!this.api) {
throw new Error('API client not available after token refresh');
}
}
}
/**
* Sends a control command to a device.
* Thread-safe: Uses AsyncLock to prevent concurrent API calls.
* Automatically refreshes token if expired before making the request.
*
* @param fan - The device to send command to
* @param method - The bypass method to execute
* @param body - Command-specific data payload
* @returns true if command succeeded (code === 0), false otherwise
* @throws Error if not logged in or device is unreachable (unless showOffWhenDisconnected is enabled)
*/
async sendCommand(fan, method, body = {}) {
return lock.acquire('api-call', async () => {
var _a, _b;
if (!this.api) {
throw new Error('The user is not logged in!');
}
await this.ensureValidToken();
this.debugMode.debug('[SEND COMMAND]', `Sending command ${method} to ${fan.name}`, `with (${JSON.stringify(body)})...`);
const response = await this.api.put('cloud/v2/deviceManaged/bypassV2', {
...this.generateV2Body(fan, method, body),
...this.generateDetailBody(),
...this.generateBody(true),
});
const offlineResult = this.handleDeviceOffline((_a = response.data) === null || _a === void 0 ? void 0 : _a.msg, false);
if (offlineResult !== undefined) {
return offlineResult;
}
if (!(response === null || response === void 0 ? void 0 : response.data)) {
this.debugMode.debug('[SEND COMMAND]', 'No response data!! JSON:', JSON.stringify(response === null || response === void 0 ? void 0 : response.data));
}
const isSuccess = ((_b = response === null || response === void 0 ? void 0 : response.data) === null || _b === void 0 ? void 0 : _b.code) === 0;
if (isSuccess) {
this.debugMode.debug('[SEND COMMAND]', `Successfully sent command ${method} to ${fan.name}`, `with (${JSON.stringify(body)})!`, `Response: ${JSON.stringify(response.data)}`);
}
else {
this.debugMode.debug('[SEND COMMAND]', `Failed to send command ${method} to ${fan.name}`, `with (${JSON.stringify(body)})!`, `Response: ${JSON.stringify(response === null || response === void 0 ? void 0 : response.data)}`);
}
return isSuccess;
});
}
/**
* Gets current device state/info from the VeSync API.
* Thread-safe: Uses AsyncLock to prevent concurrent API calls.
* Automatically refreshes token if expired before making the request.
*
* @param fan - The device to get info for
* @returns Device info response, or null if device is offline and showOffWhenDisconnected is enabled
* @throws Error if not logged in or device is unreachable (unless showOffWhenDisconnected is enabled)
*/
async getDeviceInfo(fan) {
return lock.acquire('api-call', async () => {
var _a, _b, _c;
if (!this.api) {
throw new Error('The user is not logged in!');
}
await this.ensureValidToken();
this.debugMode.debug('[GET DEVICE INFO]', 'Getting device info...');
const response = await this.api.post('cloud/v2/deviceManaged/bypassV2', {
...this.generateV2Body(fan, BypassMethod.STATUS),
...this.generateDetailBody(),
...this.generateBody(true),
});
this.debugMode.debug('[DEVICE INFO]', JSON.stringify(response.data));
// Check for quota exceeded error
if (this.handleQuotaExceeded((_a = response.data) === null || _a === void 0 ? void 0 : _a.code, (_b = response.data) === null || _b === void 0 ? void 0 : _b.msg)) {
// Return null to indicate failure, but don't throw (allows graceful degradation)
return null;
}
const offlineResult = this.handleDeviceOffline((_c = response.data) === null || _c === void 0 ? void 0 : _c.msg, null);
if (offlineResult !== undefined) {
return offlineResult;
}
if (!(response === null || response === void 0 ? void 0 : response.data)) {
this.debugMode.debug('[GET DEVICE INFO]', 'No response data!! JSON:', JSON.stringify(response === null || response === void 0 ? void 0 : response.data));
}
return response.data;
});
}
/**
* Starts an authentication session.
* First attempts to reuse a persisted session from disk.
* If no valid session exists, performs a fresh login.
*
* @returns true if session started successfully, false otherwise
*/
async startSession() {
var _a;
this.debugMode.debug('[START SESSION]', 'Starting auth session…');
// 1) Try to reuse persisted session
const session = await this.loadSessionFromDisk();
if (session) {
this.debugMode.debug('[SESSION]', 'Reusing persisted VeSync session.');
this.token = session.token;
this.accountId = session.accountId;
this.countryCode = (session.countryCode ||
this.countryCode ||
'US').toUpperCase();
const persistedBaseURL = session.apiBaseUrl || session.baseURL;
this.baseURL =
((_a = this.config.options) === null || _a === void 0 ? void 0 : _a.apiHost) || persistedBaseURL || this.baseURL;
if (session.region) {
this.region = String(session.region).toUpperCase();
}
try {
this.buildApiClient();
return true;
}
catch (e) {
this.debugMode.debug('[SESSION]', 'Failed to hydrate persisted session, falling back to fresh login:', String(e));
}
}
else {
this.debugMode.debug('[SESSION]', 'No valid persisted session found; logging in.');
}
// 2) Fresh login if no valid session
const ok = await this.login();
if (!ok) {
this.log.error('VeSync initial login failed – check credentials / region.');
}
return ok;
}
// --- Login flow (auth + token + cross-region) ------------------------------
/**
* Performs a two-step login flow with cross-region detection.
* Step 1: Authenticates with email/password to get authorizeCode
* Step 2: Exchanges authorizeCode for session token
* If cross-region detected, automatically retries with correct region.
*
* Implements login backoff to prevent API abuse on failures.
*
* @returns true if login successful, false otherwise
* @throws Error if email/password are missing
*/
async login() {
return lock.acquire('auth-call', async () => {
var _a, _b, _c, _d, _e, _f, _g;
if (!this.email || !this.password) {
throw new Error('Email and password are required');
}
// Avoid spamming VeSync on failing accounts
const now = Date.now();
const delta = now - this.lastLoginAttempt;
if (delta < this.loginBackoffMs) {
const wait = this.loginBackoffMs - delta;
this.debugMode.debug('[LOGIN]', `Backing off for ${wait}ms before next login attempt…`);
await new Promise((resolve) => setTimeout(resolve, wait));
}
this.lastLoginAttempt = Date.now();
const configuredCC = (((_a = this.config.options) === null || _a === void 0 ? void 0 : _a.countryCode) ||
this.countryCode ||
'US').toUpperCase();
this.countryCode = configuredCC;
if (!((_b = this.config.options) === null || _b === void 0 ? void 0 : _b.apiHost)) {
this.baseURL = initialHostForCountry(this.countryCode);
}
this.debugMode.debug('[LOGIN]', 'Step 1: authByPWDOrOTM…');
const { authorizeCode, bizToken: initialBizToken } = await this.authByPWDOrOTM(this.countryCode);
// Guard: authorizeCode is required for step 2; avoid calling step2 with empty code
if (!authorizeCode ||
typeof authorizeCode !== 'string' ||
authorizeCode.trim().length === 0) {
this.debugMode.debug('[LOGIN]', 'Step 1 returned an empty authorizeCode; cannot proceed to step 2. Increasing backoff and aborting.');
this.loginBackoffMs = Math.min(this.loginBackoffMs * 2, 300000);
return false;
}
this.debugMode.debug('[LOGIN]', `Step 2: loginByAuthorizeCode on ${this.baseURL}…`);
let step2Resp = await this.loginByAuthorizeCode4Vesync({
userCountryCode: this.countryCode,
authorizeCode,
bizToken: initialBizToken,
host: this.baseURL,
});
this.debugMode.debug('[LOGIN]', 'Raw step 2 response:', JSON.stringify(step2Resp));
const codeIsNonZero = typeof (step2Resp === null || step2Resp === void 0 ? void 0 : step2Resp.code) === 'number' ? step2Resp.code !== 0 : true;
if (codeIsNonZero &&
((_c = step2Resp === null || step2Resp === void 0 ? void 0 : step2Resp.result) === null || _c === void 0 ? void 0 : _c.bizToken) &&
step2Resp.result.countryCode) {
const result = step2Resp.result;
const newCountryCode = ((_d = result.countryCode) !== null && _d !== void 0 ? _d : this.countryCode).toUpperCase();
const crossBizToken = result.bizToken || initialBizToken || null;
this.debugMode.debug('[LOGIN]', `Cross-region detected. Switching to countryCode=${newCountryCode} and retrying…`);
const regionHost = initialHostForCountry(newCountryCode);
this.baseURL = ((_e = this.config.options) === null || _e === void 0 ? void 0 : _e.apiHost) || regionHost;
this.countryCode = newCountryCode;
step2Resp = await this.loginByAuthorizeCode4Vesync({
userCountryCode: this.countryCode,
authorizeCode,
bizToken: crossBizToken,
host: this.baseURL,
regionChange: 'lastRegion',
});
this.debugMode.debug('[LOGIN]', 'Raw step 2 response after retry:', JSON.stringify(step2Resp));
}
if (!((_f = step2Resp === null || step2Resp === void 0 ? void 0 : step2Resp.result) === null || _f === void 0 ? void 0 : _f.token) ||
step2Resp.code !== 0 ||
!step2Resp.result.accountID) {
this.debugMode.debug('[LOGIN] Failed final step', JSON.stringify(step2Resp));
// increase backoff on failure (cap at 5 minutes)
this.loginBackoffMs = Math.min(this.loginBackoffMs * 2, 300000);
return false;
}
// Reset backoff on success
this.loginBackoffMs = 10000;
const result = step2Resp.result;
if (!(result === null || result === void 0 ? void 0 : result.token) || !result.accountID) {
throw new Error('Invalid login response');
}
const { token, accountID, countryCode } = result;
this.debugMode.debug('[LOGIN]', 'Authentication was successful');
this.accountId = accountID;
this.token = token;
if (!this.token) {
throw new Error('No token found in login response');
}
if (countryCode) {
this.countryCode = countryCode.toUpperCase();
}
if (result.currentRegion) {
this.region = String(result.currentRegion).toUpperCase();
}
if (!((_g = this.config.options) === null || _g === void 0 ? void 0 : _g.apiHost)) {
this.baseURL = initialHostForCountry(this.countryCode);
}
this.buildApiClient();
await this.saveSessionToDisk();
return true;
});
}
/**
* Step 1 of authentication: Authenticates with email/password.
* Returns an authorizeCode that is used in step 2 to get the session token.
* Falls back to accountapi.vesync.com if smartapi fails.
*
* @param userCountryCode - Country code for the authentication request
* @returns Object with authorizeCode and optional bizToken
* @throws Error if authentication fails
*/
async authByPWDOrOTM(userCountryCode) {
var _a;
const pwdHashed = crypto
.createHash('md5')
.update(this.password)
.digest('hex');
const body = {
email: this.email,
method: 'authByPWDOrOTM',
password: pwdHashed,
acceptLanguage: this.LANG,
accountID: '',
authProtocolType: 'generic',
clientInfo: this.AUTH_CLIENT_INFO,
clientType: 'vesyncApp',
clientVersion: this.AUTH_CLIENT_VERSION,
debugMode: false,
osInfo: this.AUTH_OS_INFO,
terminalId: this.terminalId,
timeZone: this.TIMEZONE,
token: '',
userCountryCode,
appID: this.appID,
sourceAppID: this.appID,
traceId: this.generateAuthTraceId(),
};
let resp;
try {
resp = await axios_1.default.post('/globalPlatform/api/accountAuth/v1/authByPWDOrOTM', body, this.AUTH_AXIOS_OPTIONS(this.baseURL));
}
catch (e) {
this.debugMode.debug('[AUTH] accountAuth on smartapi failed, falling back to accountapi', String(e));
resp = await axios_1.default.post('/globalPlatform/api/accountAuth/v1/authByPWDOrOTM', body, this.AUTH_AXIOS_OPTIONS(ACCOUNT_HOST));
}
if (!((_a = resp === null || resp === void 0 ? void 0 : resp.data) === null || _a === void 0 ? void 0 : _a.result) || resp.data.code !== 0) {
this.debugMode.debug('[AUTH] Failed authByPWDOrOTM', JSON.stringify(resp === null || resp === void 0 ? void 0 : resp.data));
throw new Error('VeSync authentication failed at step 1');
}
const { authorizeCode = null, bizToken = null } = resp.data.result;
return { authorizeCode, bizToken };
}
/**
* Step 2 of authentication: Exchanges authorizeCode for session token.
* May return a cross-region response indicating the account is in a different region.
*
* @param opts - Login options including country code, host, authorizeCode, etc.
* @returns Login response with token and account info, or undefined on network error
*/
async loginByAuthorizeCode4Vesync(opts) {
const { userCountryCode, host, authorizeCode, bizToken = null, regionChange, } = opts;
const body = {
method: 'loginByAuthorizeCode4Vesync',
authorizeCode,
acceptLanguage: this.LANG,
clientInfo: this.AUTH_CLIENT_INFO,
clientType: 'vesyncApp',
clientVersion: this.AUTH_CLIENT_VERSION,
debugMode: false,
emailSubscriptions: false,
osInfo: this.AUTH_OS_INFO,
terminalId: this.terminalId,
timeZone: this.TIMEZONE,
userCountryCode,
traceId: this.generateAuthTraceId(),
};
if (bizToken)
body.bizToken = bizToken;
if (regionChange)
body.regionChange = regionChange;
this.debugMode.debug('[LOGIN STEP 2] POST body', JSON.stringify({
...body,
bizToken: bizToken ? '***' : undefined,
}));
try {
const resp = await axios_1.default.post('/user/api/accountManage/v1/loginByAuthorizeCode4Vesync', body, this.AUTH_AXIOS_OPTIONS(host));
return resp === null || resp === void 0 ? void 0 : resp.data;
}
catch (e) {
this.debugMode.debug('[LOGIN STEP 2] network error', String(e));
return undefined;
}
}
// --- Devices ---------------------------------------------------------------
/**
* Gets all supported humidifier devices from the VeSync account.
* Filters devices to only include supported models (wifi-air type).
* Thread-safe: Uses AsyncLock to prevent concurrent API calls.
*
* Token expiration is handled automatically by the axios interceptor.
*
* @returns Array of VeSyncFan instances for supported devices
*/
async getDevices() {
return lock.acquire('api-call', async () => {
var _a, _b, _c, _d, _e;
if (!this.api) {
this.log.error('The user is not logged in!');
return [];
}
await this.ensureValidToken();
const response = await this.api.post('cloud/v2/deviceManaged/devices', {
method: 'devices',
pageNo: 1,
pageSize: 1000,
...this.generateDetailBody(),
...this.generateBody(true),
});
// Check for quota exceeded error
if (this.handleQuotaExceeded((_a = response.data) === null || _a === void 0 ? void 0 : _a.code, (_b = response.data) === null || _b === void 0 ? void 0 : _b.msg)) {
// Return empty array to indicate failure, but don't throw (allows graceful degradation)
return [];
}
if (!(response === null || response === void 0 ? void 0 : response.data)) {
this.debugMode.debug('[GET DEVICES]', 'No response data!! JSON:', JSON.stringify(response === null || response === void 0 ? void 0 : response.data));
return [];
}
if (!Array.isArray((_d = (_c = response.data) === null || _c === void 0 ? void 0 : _c.result) === null || _d === void 0 ? void 0 : _d.list)) {
this.debugMode.debug('[GET DEVICES]', 'No list found!! JSON:', JSON.stringify(response.data));
return [];
}
const { list } = (_e = response.data.result) !== null && _e !== void 0 ? _e : { list: [] };
this.debugMode.debug('[GET DEVICES]', 'Device List -> JSON:', JSON.stringify(list));
const devices = list
.filter(({ deviceType, type }) => deviceTypes_1.default.some(({ isValid }) => isValid(deviceType)) &&
type === 'wifi-air')
.map(VeSyncFan_1.default.fromResponse(this));
return devices;
});
}
}
exports.default = VeSync;
//# sourceMappingURL=VeSync.js.map