UNPKG

homebridge-levoit-humidifiers

Version:
932 lines 40.9 kB
"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