tsvesync
Version:
A TypeScript library for interacting with VeSync smart home devices
896 lines (895 loc) • 42 kB
JavaScript
"use strict";
/**
* VeSync API Device Library
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.VeSync = void 0;
const helpers_1 = require("./helpers");
const session_1 = require("./session");
const vesyncFanImpl_1 = require("./vesyncFanImpl");
const vesyncOutletImpl_1 = require("./vesyncOutletImpl");
const vesyncSwitchImpl_1 = require("./vesyncSwitchImpl");
const vesyncBulbImpl_1 = require("./vesyncBulbImpl");
const logger_1 = require("./logger");
const constants_1 = require("./constants");
const DEFAULT_ENERGY_UPDATE_INTERVAL = 21600;
function normalizeApiBaseUrl(apiBaseUrl) {
return apiBaseUrl.trim().replace(/\/+$/, '');
}
const KNOWN_API_BASE_URLS = new Set(Object.values(helpers_1.REGION_ENDPOINTS).map(normalizeApiBaseUrl));
function inferRegionFromApiBaseUrl(apiBaseUrl) {
const normalized = normalizeApiBaseUrl(apiBaseUrl).toLowerCase();
if (normalized.includes('vesync.eu'))
return 'EU';
if (normalized.includes('vesync.com'))
return 'US';
return null;
}
/**
* Create device instance based on type
*/
function objectFactory(details, manager) {
const deviceType = details.deviceType;
let DeviceClass = null;
let category = 'unknown';
// Map of device categories to their module dictionaries
const allModules = {
outlets: vesyncOutletImpl_1.outletModules,
fans: vesyncFanImpl_1.fanModules,
bulbs: vesyncBulbImpl_1.bulbModules,
switches: vesyncSwitchImpl_1.switchModules
};
// First try exact match in all modules
for (const [cat, modules] of Object.entries(allModules)) {
if (deviceType in modules) {
DeviceClass = modules[deviceType];
category = cat;
logger_1.logger.debug(`Found exact match for ${deviceType} in ${cat} modules`);
break;
}
}
// If no exact match, try to find a base class
if (!DeviceClass) {
// Device type prefix mapping
const prefixMap = {
// Fans
'Core': 'fans',
'LAP': 'fans',
'LTF': 'fans',
'Classic': 'fans',
'Dual': 'fans',
'LUH': 'fans',
'LEH': 'fans',
'LV-PUR': 'fans',
'LV-RH': 'fans',
// Outlets
'wifi-switch': 'outlets',
'ESW03': 'outlets',
'ESW01': 'outlets',
'ESW10': 'outlets',
'ESW15': 'outlets',
'ESO': 'outlets',
// Switches
'ESWL': 'switches',
'ESWD': 'switches',
// Bulbs
'ESL': 'bulbs',
'XYD': 'bulbs'
};
// Find category based on device type prefix
for (const [prefix, cat] of Object.entries(prefixMap)) {
if (deviceType.startsWith(prefix)) {
category = cat;
logger_1.logger.debug(`Device type ${deviceType} matched prefix ${prefix} -> category ${cat}`);
// Try to find a base class in this category's modules
const modules = allModules[cat];
for (const [moduleType, ModuleClass] of Object.entries(modules)) {
const baseType = moduleType.split('-')[0]; // e.g., ESL100 from ESL100-USA
if (deviceType.startsWith(baseType)) {
DeviceClass = ModuleClass;
logger_1.logger.debug(`Found base type match: ${deviceType} -> ${baseType}`);
break;
}
}
break;
}
}
}
if (DeviceClass) {
try {
// Add category to device details
details.deviceCategory = category;
// Handle outdoor plug sub-devices
if (deviceType === 'ESO15-TB' && details.subDeviceNo) {
const devices = [];
// Create a device instance for each sub-device
const subDeviceDetails = {
...details,
deviceName: details.deviceName,
deviceStatus: details.deviceStatus,
subDeviceNo: details.subDeviceNo,
isSubDevice: true
};
const device = new DeviceClass(subDeviceDetails, manager);
devices.push([category, device]);
// Return array of sub-devices
return devices[0]; // Return first device, manager will handle adding all devices
}
else {
const device = new DeviceClass(details, manager);
return [category, device];
}
}
catch (error) {
logger_1.logger.error(`Error creating device instance for ${deviceType}:`, error);
return [category, null];
}
}
else {
logger_1.logger.debug(`No device class found for type: ${deviceType}`);
return [category, null];
}
}
/**
* VeSync Manager Class
*/
class VeSync {
/**
* Initialize VeSync Manager
* @param username - VeSync account username
* @param password - VeSync account password
* @param timeZone - Optional timezone for device operations (defaults to America/New_York)
* @param optionsOrDebug - Either options object or debug flag for backward compatibility
* @param redact - Optional redact mode flag (used when optionsOrDebug is boolean)
* @param apiUrl - Optional API base URL override (used when optionsOrDebug is boolean)
* @param customLogger - Optional custom logger implementation (used when optionsOrDebug is boolean)
* @param excludeConfig - Optional device exclusion configuration (used when optionsOrDebug is boolean)
*/
constructor(username, password, timeZone = helpers_1.DEFAULT_TZ, optionsOrDebug = false, redact = true, apiUrl, customLogger, excludeConfig) {
this._loginPromise = null;
// Handle options object or backward compatible parameters
let options = {};
if (typeof optionsOrDebug === 'object' && optionsOrDebug !== null) {
// Using new options pattern
options = optionsOrDebug;
this._debug = options.debug || options.debugMode || false;
this._redact = options.redact !== undefined ? options.redact : true;
this._excludeConfig = options.excludeConfig || null;
this._region = options.region || 'US';
this._countryCodeOverride = options.countryCode || null;
customLogger = options.customLogger;
apiUrl = options.apiUrl;
this._sessionStore = options.sessionStore;
this._onTokenChange = options.onTokenChange;
}
else {
// Using old parameter pattern for backward compatibility
this._debug = optionsOrDebug;
this._redact = redact;
this._excludeConfig = excludeConfig || null;
this._region = 'US';
this._countryCodeOverride = null;
}
this._apiUrlOverride = apiUrl || null;
this._energyUpdateInterval = DEFAULT_ENERGY_UPDATE_INTERVAL;
this._energyCheck = true;
this._lastUpdateTs = null;
this._inProcess = false;
this._appId = (0, helpers_1.generateAppId)();
this.username = username;
this.password = password;
this.token = null;
this.accountId = null;
this.countryCode = null;
this.devices = null;
this.enabled = false;
this.updateInterval = helpers_1.API_RATE_LIMIT;
this.fans = [];
this.outlets = [];
this.switches = [];
this.bulbs = [];
this.scales = [];
this._devList = {
fans: this.fans,
outlets: this.outlets,
switches: this.switches,
bulbs: this.bulbs
};
// Set timezone first
if (typeof timeZone === 'string' && timeZone) {
const regTest = /[^a-zA-Z/_]/;
if (regTest.test(timeZone)) {
this.timeZone = helpers_1.DEFAULT_TZ;
logger_1.logger.debug('Invalid characters in time zone - ', timeZone);
}
else {
this.timeZone = timeZone;
}
}
else {
this.timeZone = helpers_1.DEFAULT_TZ;
logger_1.logger.debug('Time zone is not a string');
}
// Set custom API URL if provided, otherwise use region-based endpoint
if (apiUrl) {
this.apiBaseUrl = apiUrl;
}
else {
// If country code is provided, use it to determine the endpoint
if (this._countryCodeOverride) {
const endpointRegion = (0, helpers_1.getEndpointForCountryCode)(this._countryCodeOverride);
this._region = endpointRegion;
logger_1.logger.debug(`Country code ${this._countryCodeOverride} maps to ${endpointRegion} endpoint`);
}
// Set endpoint based on region
const endpoint = helpers_1.REGION_ENDPOINTS[this._region] || helpers_1.REGION_ENDPOINTS.US;
this.apiBaseUrl = endpoint;
}
// Set custom logger if provided
if (customLogger) {
(0, logger_1.setLogger)(customLogger);
}
// Debug and redact are already set above in the options handling
}
/**
* Hydrate manager from a previously persisted session
*/
hydrateSession(session) {
var _a;
try {
if (!(session === null || session === void 0 ? void 0 : session.token) || !(session === null || session === void 0 ? void 0 : session.accountId)) {
throw new Error('Session missing token/accountId');
}
// Always hydrate core credentials first.
this.token = session.token;
this.accountId = session.accountId;
this.countryCode = (_a = session.countryCode) !== null && _a !== void 0 ? _a : null;
this.authFlowUsed = session.authFlowUsed;
// pyvesync parity:
// pyvesync persists `current_region` and derives the API base URL per-request from that region.
// It does NOT treat a stored base URL as authoritative. For backwards compatibility with older
// sessions, we normalize the fields here:
// - Prefer stored `region` when present
// - Otherwise derive region from stored `countryCode` (same mapping as pyvesync)
// - Otherwise infer region from stored apiBaseUrl (best-effort)
const sessionBaseUrl = session.apiBaseUrl ? normalizeApiBaseUrl(session.apiBaseUrl) : null;
const sessionBaseUrlIsKnown = !!sessionBaseUrl && KNOWN_API_BASE_URLS.has(sessionBaseUrl);
// If the session contains a custom base URL (not a known regional URL) and the caller did not
// explicitly set an API override, treat it as an override so region switching doesn't clobber it.
if (!this._apiUrlOverride && sessionBaseUrl && !sessionBaseUrlIsKnown) {
this._apiUrlOverride = sessionBaseUrl;
}
// Apply base URL override if present (pyvesync API_BASE_URL-style override).
if (this._apiUrlOverride) {
this.apiBaseUrl = this._apiUrlOverride;
}
let hydratedRegion = null;
if (typeof session.region === 'string' && session.region in helpers_1.REGION_ENDPOINTS) {
hydratedRegion = session.region;
}
else if (typeof session.countryCode === 'string' && session.countryCode.trim()) {
hydratedRegion = (0, helpers_1.getRegionFromCountryCode)(session.countryCode);
}
else if (sessionBaseUrl) {
hydratedRegion = inferRegionFromApiBaseUrl(sessionBaseUrl);
}
if (hydratedRegion && hydratedRegion in helpers_1.REGION_ENDPOINTS) {
this._region = hydratedRegion;
}
// Derive the API base URL from region unless explicitly overridden.
if (!this._apiUrlOverride) {
const endpoint = helpers_1.REGION_ENDPOINTS[this._region] || helpers_1.REGION_ENDPOINTS.US;
this.apiBaseUrl = endpoint;
}
else if (!this.apiBaseUrl && sessionBaseUrl) {
// Fallback: keep the persisted base URL if we have no other source.
this.apiBaseUrl = sessionBaseUrl;
}
this.enabled = true;
}
catch (e) {
logger_1.logger.warn('Failed to hydrate session; will require fresh login');
}
}
emitTokenChange() {
var _a, _b, _c, _d;
if (!this.token || !this.accountId || !this.apiBaseUrl)
return;
const timestamps = (0, session_1.decodeJwtTimestamps)(this.token);
const session = {
token: this.token,
accountId: this.accountId,
countryCode: this.countryCode,
region: this._region,
apiBaseUrl: this.apiBaseUrl,
authFlowUsed: this.authFlowUsed,
issuedAt: (_a = timestamps === null || timestamps === void 0 ? void 0 : timestamps.iat) !== null && _a !== void 0 ? _a : null,
expiresAt: (_b = timestamps === null || timestamps === void 0 ? void 0 : timestamps.exp) !== null && _b !== void 0 ? _b : null,
lastValidatedAt: Date.now(),
};
// Best-effort persist and callback; errors are non-fatal
Promise.resolve((_c = this._sessionStore) === null || _c === void 0 ? void 0 : _c.save(session)).catch(() => { });
try {
(_d = this._onTokenChange) === null || _d === void 0 ? void 0 : _d.call(this, session);
}
catch ( /* noop */_e) { /* noop */ }
}
/**
* Get/Set debug mode
*/
get debug() {
return this._debug;
}
set debug(flag) {
this._debug = flag;
}
/**
* Get/Set redact mode
*/
get redact() {
return this._redact;
}
set redact(flag) {
this._redact = flag;
helpers_1.Helpers.shouldRedact = flag;
}
/**
* Get/Set energy update interval
*/
get energyUpdateInterval() {
return this._energyUpdateInterval;
}
set energyUpdateInterval(interval) {
if (interval > 0) {
this._energyUpdateInterval = interval;
}
}
/**
* Get current App ID
*/
get appId() {
return this._appId;
}
/**
* Get current region
*/
get region() {
return this._region;
}
/**
* Return the configured API URL override, if any.
*/
get apiUrlOverride() {
return this._apiUrlOverride;
}
/**
* Set region and update API endpoint
*/
set region(region) {
if (region in helpers_1.REGION_ENDPOINTS) {
this._region = region;
if (!this._apiUrlOverride) {
this.apiBaseUrl = helpers_1.REGION_ENDPOINTS[region];
}
}
}
/**
* Test if device should be removed
*/
static removeDevTest(device, newList) {
if (Array.isArray(newList) && device.cid) {
for (const item of newList) {
if ('cid' in item && device.cid === item.cid) {
return true;
}
}
logger_1.logger.debug(`Device removed - ${device.deviceName} - ${device.deviceType}`);
return false;
}
return true;
}
/**
* Test if new device should be added
*/
addDevTest(newDev) {
if ('cid' in newDev) {
for (const devices of Object.values(this._devList)) {
for (const dev of devices) {
if (dev.cid === newDev.cid) {
return false;
}
}
}
}
return true;
}
/**
* Remove devices not found in device list return
*/
removeOldDevices(devices) {
for (const [key, deviceList] of Object.entries(this._devList)) {
const before = deviceList.length;
this._devList[key] = deviceList.filter(device => VeSync.removeDevTest(device, devices));
const after = this._devList[key].length;
if (before !== after) {
logger_1.logger.debug(`${before - after} ${key} removed`);
}
}
return true;
}
/**
* Correct devices without cid or uuid
*/
static setDevId(devices) {
const devRem = [];
devices.forEach((dev, index) => {
if (!dev.cid) {
if (dev.macID) {
dev.cid = dev.macID;
}
else if (dev.uuid) {
dev.cid = dev.uuid;
}
else {
devRem.push(index);
logger_1.logger.warn(`Device with no ID - ${dev.deviceName || ''}`);
}
}
});
if (devRem.length > 0) {
return devices.filter((_, index) => !devRem.includes(index));
}
return devices;
}
/**
* Process devices from API response
*/
processDevices(deviceList) {
try {
// Clear existing devices
for (const category of Object.keys(this._devList)) {
this._devList[category].length = 0;
}
if (!deviceList || deviceList.length === 0) {
logger_1.logger.warn('No devices found in API response');
return false;
}
// Process each device
deviceList.forEach(dev => {
const [category, device] = objectFactory(dev, this);
// Handle outdoor plug sub-devices
if (dev.deviceType === 'ESO15-TB' && dev.subDeviceNo) {
const subDeviceDetails = {
...dev,
deviceName: dev.deviceName,
deviceStatus: dev.deviceStatus,
subDeviceNo: dev.subDeviceNo,
isSubDevice: true,
};
const [subCategory, subDevice] = objectFactory(subDeviceDetails, this);
if (subDevice && subCategory in this._devList) {
this._devList[subCategory].push(subDevice);
}
}
else if (device && category in this._devList) {
this._devList[category].push(device);
}
});
// Update device list reference
this.devices = Object.values(this._devList).flat();
// Return true if we processed at least one device successfully
return this.devices.length > 0;
}
catch (error) {
logger_1.logger.error('Error processing devices:', error);
return false;
}
}
/**
* Get list of VeSync devices
*/
async getDevices() {
var _a, _b, _c;
if (!this.enabled) {
logger_1.logger.error('Not logged in to VeSync');
return false;
}
this._inProcess = true;
let success = false;
try {
const fetchDeviceList = async () => {
return await helpers_1.Helpers.callApi('/cloud/v1/deviceManaged/devices', 'post', helpers_1.Helpers.reqBody(this, 'devicelist'), helpers_1.Helpers.reqHeaderBypass(), this);
};
const originalRegion = this._region;
let didRetryToken = false;
let didRetryCrossRegion = false;
let response;
let status;
[response, status] = await fetchDeviceList();
// Handle VeSync API codes similarly to pyvesync:
// - TOKEN_ERROR triggers a reauth + retry
// - CROSS_REGION triggers a region correction + retry
//
// pyvesync reference:
// - Token error retry: `pyvesync/vesync.py:async_call_api` (ErrorTypes.TOKEN_ERROR)
// - Region correction: `pyvesync/auth.py:_exchange_authorization_code` (ErrorTypes.CROSS_REGION)
while (true) {
if (!response || status !== 200) {
break;
}
const code = response === null || response === void 0 ? void 0 : response.code;
if (typeof code === 'number' && code !== 0) {
if (!didRetryToken && (0, constants_1.isTokenError)(code)) {
didRetryToken = true;
logger_1.logger.warn('Device list request returned token error; re-authenticating and retrying (pyvesync parity)', {
code,
msg: response === null || response === void 0 ? void 0 : response.msg,
region: this._region,
endpoint: this.apiBaseUrl
});
const reloginOk = await this.login();
if (!reloginOk) {
this.enabled = false;
return false;
}
[response, status] = await fetchDeviceList();
continue;
}
if (!didRetryCrossRegion && (0, constants_1.isCrossRegionError)(code) && !this.apiUrlOverride) {
didRetryCrossRegion = true;
const serverRegion = (_a = response === null || response === void 0 ? void 0 : response.result) === null || _a === void 0 ? void 0 : _a.currentRegion;
const normalizedServerRegion = typeof serverRegion === 'string' ? serverRegion.trim().toUpperCase() : null;
const nextRegion = normalizedServerRegion && normalizedServerRegion in helpers_1.REGION_ENDPOINTS
? normalizedServerRegion
: (this._region === 'US' ? 'EU' : 'US');
logger_1.logger.warn('Device list request returned cross-region error; retrying against corrected endpoint (pyvesync-style)', {
code,
msg: response === null || response === void 0 ? void 0 : response.msg,
region: this._region,
endpoint: this.apiBaseUrl,
serverRegion: normalizedServerRegion,
nextRegion
});
this.region = nextRegion;
[response, status] = await fetchDeviceList();
// If this was a best-effort alternate-region guess (no serverRegion provided) and it
// didn't help, revert so we don't leave the manager stuck on the wrong endpoint.
if (!normalizedServerRegion && (!response || response.code !== 0)) {
this.region = originalRegion;
}
continue;
}
}
break;
}
if (!response) {
logger_1.logger.error('No response received from VeSync API');
return false;
}
if (status !== 200) {
logger_1.logger.error('Device list request failed with non-200 status:', {
status,
code: response === null || response === void 0 ? void 0 : response.code,
msg: response === null || response === void 0 ? void 0 : response.msg,
region: this._region,
endpoint: this.apiBaseUrl
});
return false;
}
if (typeof response.code === 'number' && response.code !== 0) {
logger_1.logger.error('Device list request returned error code:', {
status,
code: response.code,
msg: response === null || response === void 0 ? void 0 : response.msg,
region: this._region,
endpoint: this.apiBaseUrl
});
return false;
}
if (!((_b = response.result) === null || _b === void 0 ? void 0 : _b.list)) {
logger_1.logger.error('No device list found in response');
return false;
}
const deviceList = response.result.list;
success = this.processDevices(deviceList);
if (success) {
// Log device discovery results
logger_1.logger.debug('\n=== Device Discovery Summary ===');
logger_1.logger.debug(`Total devices processed: ${deviceList.length}`);
// Log device types found
const deviceTypes = deviceList.map((d) => d.deviceType);
logger_1.logger.debug('\nDevice types found:', deviceTypes);
// Log devices by category with details
logger_1.logger.debug('\nDevices by Category:');
logger_1.logger.debug('---------------------');
for (const [category, devices] of Object.entries(this._devList)) {
if (devices.length > 0) {
logger_1.logger.debug(`\n${category.toUpperCase()} (${devices.length} devices):`);
devices.forEach((d) => {
logger_1.logger.debug(` • ${d.deviceName}`);
logger_1.logger.debug(` Type: ${d.deviceType}`);
logger_1.logger.debug(` Status: ${d.deviceStatus}`);
logger_1.logger.debug(` ID: ${d.cid}`);
});
}
}
// Log summary statistics
logger_1.logger.debug('\nSummary Statistics:');
logger_1.logger.debug('-------------------');
logger_1.logger.debug(`Total Devices: ${((_c = this.devices) === null || _c === void 0 ? void 0 : _c.length) || 0}`);
for (const [category, devices] of Object.entries(this._devList)) {
logger_1.logger.debug(`${category}: ${devices.length} devices`);
}
logger_1.logger.debug('\n=== End of Device Discovery ===\n');
}
}
catch (err) {
const error = err;
if (error.code === 'ECONNABORTED') {
logger_1.logger.error('VeSync API request timed out');
}
else if (error.code === 'ECONNREFUSED') {
logger_1.logger.error('Unable to connect to VeSync API');
}
else {
logger_1.logger.error('Error getting device list:', error.message || 'Unknown error');
}
}
this._inProcess = false;
return success;
}
/**
* Login to VeSync server with new authentication flow and backwards compatibility
*/
async login(retryAttempts = 3, initialDelayMs = 1000) {
if (this._loginPromise) {
return this._loginPromise;
}
this._loginPromise = (async () => {
logger_1.logger.debug('Starting VeSync authentication...', {
username: this.username,
appId: this._appId,
region: this._region
});
// Track which regions have been attempted to prevent infinite loops
const triedRegions = new Set();
for (let attempt = 0; attempt < retryAttempts; attempt++) {
try {
logger_1.logger.debug(`Authentication attempt ${attempt + 1} of ${retryAttempts}`);
// Try new authentication flow first with detected region.
// NOTE: authNewFlow may temporarily change `this.region` during pyvesync-style
// cross-region Step 2 retry. Capture the attempted region so our fallback logic
// (try opposite endpoint) doesn't get confused by that internal mutation.
const attemptedRegion = this._region;
let [success, token, accountId, countryCode] = await helpers_1.Helpers.authNewFlow(this, this._appId, attemptedRegion, this._countryCodeOverride || undefined);
// Track if new flow succeeded
if (success) {
this.authFlowUsed = 'new';
}
if (!success) {
// Check if it's a credential error (no point retrying)
if (countryCode === 'credential_error') {
logger_1.logger.error('Authentication failed due to invalid credentials');
return false; // Exit immediately, don't retry
}
// Check if we need to try a different region
if (countryCode === 'cross_region' || countryCode === 'cross_region_retry') {
// Mark attempted region as tried (do not rely on `this._region`, which may have been
// mutated by authNewFlow during pyvesync-style cross-region handling).
triedRegions.add(attemptedRegion);
logger_1.logger.debug('Cross-region error detected, trying opposite region...');
const alternateRegion = attemptedRegion === 'US' ? 'EU' : 'US';
// Check if we've already tried this region to prevent infinite loops
if (triedRegions.has(alternateRegion)) {
logger_1.logger.error('═══════════════════════════════════════════════════════════════');
logger_1.logger.error('AUTHENTICATION FAILED: COUNTRY CODE REQUIRED');
logger_1.logger.error('');
logger_1.logger.error('Both US and EU endpoints rejected your account.');
logger_1.logger.error('This means you MUST specify your country code.');
logger_1.logger.error('');
logger_1.logger.error('SOLUTION:');
logger_1.logger.error('1. In Homebridge UI:');
logger_1.logger.error(' - Go to plugin settings');
logger_1.logger.error(' - Select your country from the "Country Code" dropdown');
logger_1.logger.error('');
logger_1.logger.error('2. In config.json:');
logger_1.logger.error(' Add: "countryCode": "YOUR_COUNTRY_CODE"');
logger_1.logger.error('');
logger_1.logger.error('Common non-US/EU country codes:');
logger_1.logger.error(' 🇦🇺 Australia: "AU"');
logger_1.logger.error(' 🇳🇿 New Zealand: "NZ"');
logger_1.logger.error(' 🇯🇵 Japan: "JP"');
logger_1.logger.error(' 🇨🇦 Canada: "CA"');
logger_1.logger.error(' 🇸🇬 Singapore: "SG"');
logger_1.logger.error(' 🇲🇽 Mexico: "MX"');
logger_1.logger.error('═══════════════════════════════════════════════════════════════');
return false;
}
logger_1.logger.debug(`Switching from ${attemptedRegion} to ${alternateRegion} region`);
this.region = alternateRegion;
logger_1.logger.debug(`API endpoint set to ${this.apiBaseUrl}`);
// Retry with the alternate region
[success, token, accountId, countryCode] = await helpers_1.Helpers.authNewFlow(this, this._appId, alternateRegion, this._countryCodeOverride || undefined);
if (success) {
this.authFlowUsed = 'new';
logger_1.logger.debug(`Authentication successful with ${alternateRegion} region`);
}
else if (countryCode === 'cross_region' || countryCode === 'cross_region_retry') {
// Both regions failed with cross-region error
logger_1.logger.error('Authentication failed: Account rejected by both US and EU regions');
logger_1.logger.error('This may indicate your account was created in a region not yet supported');
logger_1.logger.error('Please contact VeSync support to determine your account region');
// Don't retry further attempts as we've exhausted both regions
return false;
}
}
// If new flow still fails, try legacy authentication as fallback
if (!success) {
logger_1.logger.debug('New authentication flow failed, trying legacy flow...');
[success, token, accountId, countryCode] = await helpers_1.Helpers.authLegacyFlow(this);
if (success) {
this.authFlowUsed = 'legacy';
}
else if (countryCode === 'credential_error') {
// Legacy auth also detected bad credentials
logger_1.logger.error('Both authentication methods report invalid credentials');
return false; // Exit immediately
}
}
}
if (success && token && accountId) {
this.token = token;
this.accountId = accountId;
this.countryCode = countryCode;
this.enabled = true;
this.apiBaseUrl = this.apiBaseUrl || 'https://smartapi.vesync.com'; // Track the API endpoint being used
// Persist session details for reuse across restarts
this.emitTokenChange();
// DO NOT change the region/endpoint after successful authentication!
// The endpoint that successfully authenticated is the one we must continue using.
// Tokens are endpoint-specific and won't work if we switch endpoints.
// This is especially important for AU/NZ users who may authenticate via EU endpoint
// but have AU/NZ country codes.
// Log a warning if the country code doesn't match the successful region
if (countryCode) {
const expectedRegion = (0, helpers_1.getRegionFromCountryCode)(countryCode);
if (expectedRegion !== this._region) {
logger_1.logger.warn(`Note: Account authenticated with ${this._region} endpoint despite country code ${countryCode} typically using ${expectedRegion}`);
logger_1.logger.warn(`This is normal for accounts created through different regional apps`);
}
}
logger_1.logger.debug('Authentication successful!', {
authFlow: this.authFlowUsed,
region: this._region,
countryCode: countryCode,
endpoint: this.apiBaseUrl
});
return true;
}
// If we reach here, authentication failed
const delay = initialDelayMs * Math.pow(2, attempt);
logger_1.logger.debug(`Authentication attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
catch (error) {
logger_1.logger.error(`Authentication attempt ${attempt + 1} error:`, error);
if (attempt === retryAttempts - 1) {
logger_1.logger.error('Authentication failed after all retry attempts');
return false;
}
const delay = initialDelayMs * Math.pow(2, attempt);
logger_1.logger.debug(`Retrying authentication in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
logger_1.logger.error('Unable to authenticate with supplied credentials after all retry attempts');
return false;
})();
try {
return await this._loginPromise;
}
finally {
this._loginPromise = null;
}
}
/**
* Test if update interval has been exceeded
*/
deviceTimeCheck() {
return (this._lastUpdateTs === null ||
(Date.now() - this._lastUpdateTs) / 1000 > this.updateInterval);
}
/**
* Check if a device should be excluded based on configuration
*/
shouldExcludeDevice(device) {
var _a, _b, _c, _d, _e;
if (!this._excludeConfig) {
return false;
}
const exclude = this._excludeConfig;
// Check device type
if ((_a = exclude.type) === null || _a === void 0 ? void 0 : _a.includes(device.deviceType.toLowerCase())) {
logger_1.logger.debug(`Excluding device ${device.deviceName} by type: ${device.deviceType}`);
return true;
}
// Check device model
if ((_b = exclude.model) === null || _b === void 0 ? void 0 : _b.some(model => device.deviceType.toUpperCase().includes(model.toUpperCase()))) {
logger_1.logger.debug(`Excluding device ${device.deviceName} by model: ${device.deviceType}`);
return true;
}
// Check exact name match
if ((_c = exclude.name) === null || _c === void 0 ? void 0 : _c.includes(device.deviceName.trim())) {
logger_1.logger.debug(`Excluding device ${device.deviceName} by exact name match`);
return true;
}
// Check name patterns
if (exclude.namePattern) {
for (const pattern of exclude.namePattern) {
try {
const regex = new RegExp(pattern);
if (regex.test(device.deviceName.trim())) {
logger_1.logger.debug(`Excluding device ${device.deviceName} by name pattern: ${pattern}`);
return true;
}
}
catch (error) {
logger_1.logger.warn(`Invalid regex pattern in exclude config: ${pattern}`);
}
}
}
// Check device ID (cid or uuid)
if (((_d = exclude.id) === null || _d === void 0 ? void 0 : _d.includes(device.cid)) || ((_e = exclude.id) === null || _e === void 0 ? void 0 : _e.includes(device.uuid))) {
logger_1.logger.debug(`Excluding device ${device.deviceName} by ID: ${device.cid}/${device.uuid}`);
return true;
}
return false;
}
/**
* Update device list and details
*/
async update() {
if (this.deviceTimeCheck()) {
if (!this.enabled) {
logger_1.logger.error('Not logged in to VeSync');
return;
}
await this.getDevices();
logger_1.logger.debug('Start updating the device details one by one');
for (const deviceList of Object.values(this._devList)) {
for (const device of deviceList) {
try {
if (!this.shouldExcludeDevice(device)) {
await device.getDetails();
}
else {
logger_1.logger.debug(`Skipping details update for excluded device: ${device.deviceName}`);
}
}
catch (error) {
logger_1.logger.error(`Error updating ${device.deviceName}:`, error);
}
}
}
this._lastUpdateTs = Date.now();
}
}
/**
* Create device instance from details
*/
createDevice(details) {
const deviceType = details.deviceType;
const deviceClass = vesyncFanImpl_1.fanModules[deviceType];
if (deviceClass) {
return new deviceClass(details, this);
}
return null;
}
/**
* Call API with authentication
*/
async callApi(endpoint, method, data = null, headers = {}) {
return await helpers_1.Helpers.callApi(endpoint, method, data, headers, this);
}
}
exports.VeSync = VeSync;