tsvesync 1.4.2 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/vesync.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * VeSync API Device Library
3
3
  */
4
4
 
5
- import { Helpers, API_RATE_LIMIT, DEFAULT_TZ, setApiBaseUrl, getApiBaseUrl, generateAppId, getRegionFromCountryCode, setCurrentRegion, getCurrentRegion, REGION_ENDPOINTS, getCountryCodeFromRegion, getEndpointForCountryCode } from './helpers';
5
+ import { Helpers, API_RATE_LIMIT, DEFAULT_TZ, generateAppId, getRegionFromCountryCode, REGION_ENDPOINTS, getEndpointForCountryCode } from './helpers';
6
6
  import { Session, SessionStore, decodeJwtTimestamps } from './session';
7
7
  import { VeSyncBaseDevice } from './vesyncBaseDevice';
8
8
  import { fanModules } from './vesyncFanImpl';
@@ -10,9 +10,23 @@ import { outletModules } from './vesyncOutletImpl';
10
10
  import { switchModules } from './vesyncSwitchImpl';
11
11
  import { bulbModules } from './vesyncBulbImpl';
12
12
  import { logger, setLogger, Logger } from './logger';
13
+ import { isCrossRegionError, isTokenError } from './constants';
13
14
 
14
15
  const DEFAULT_ENERGY_UPDATE_INTERVAL = 21600;
15
16
 
17
+ function normalizeApiBaseUrl(apiBaseUrl: string): string {
18
+ return apiBaseUrl.trim().replace(/\/+$/, '');
19
+ }
20
+
21
+ const KNOWN_API_BASE_URLS = new Set(Object.values(REGION_ENDPOINTS).map(normalizeApiBaseUrl));
22
+
23
+ function inferRegionFromApiBaseUrl(apiBaseUrl: string): 'US' | 'EU' | null {
24
+ const normalized = normalizeApiBaseUrl(apiBaseUrl).toLowerCase();
25
+ if (normalized.includes('vesync.eu')) return 'EU';
26
+ if (normalized.includes('vesync.com')) return 'US';
27
+ return null;
28
+ }
29
+
16
30
  /**
17
31
  * Device exclusion configuration
18
32
  */
@@ -163,6 +177,7 @@ export class VeSync {
163
177
  private _appId: string;
164
178
  private _region: string;
165
179
  private _countryCodeOverride: string | null;
180
+ private _apiUrlOverride: string | null;
166
181
  private _sessionStore?: SessionStore;
167
182
  private _onTokenChange?: (session: Session) => void;
168
183
  private _loginPromise: Promise<boolean> | null = null;
@@ -239,6 +254,7 @@ export class VeSync {
239
254
  this._region = 'US';
240
255
  this._countryCodeOverride = null;
241
256
  }
257
+ this._apiUrlOverride = apiUrl || null;
242
258
 
243
259
  this._energyUpdateInterval = DEFAULT_ENERGY_UPDATE_INTERVAL;
244
260
  this._energyCheck = true;
@@ -284,7 +300,7 @@ export class VeSync {
284
300
 
285
301
  // Set custom API URL if provided, otherwise use region-based endpoint
286
302
  if (apiUrl) {
287
- setApiBaseUrl(apiUrl);
303
+ this.apiBaseUrl = apiUrl;
288
304
  } else {
289
305
  // If country code is provided, use it to determine the endpoint
290
306
  if (this._countryCodeOverride) {
@@ -294,16 +310,8 @@ export class VeSync {
294
310
  }
295
311
 
296
312
  // Set endpoint based on region
297
- const regionEndpoints: Record<string, string> = {
298
- 'US': 'https://smartapi.vesync.com',
299
- 'EU': 'https://smartapi.vesync.eu',
300
- 'CA': 'https://smartapi.vesync.com',
301
- 'MX': 'https://smartapi.vesync.com',
302
- 'JP': 'https://smartapi.vesync.com'
303
- };
304
- const endpoint = regionEndpoints[this._region] || 'https://smartapi.vesync.com';
305
- setApiBaseUrl(endpoint);
306
- setCurrentRegion(this._region);
313
+ const endpoint = REGION_ENDPOINTS[this._region as keyof typeof REGION_ENDPOINTS] || REGION_ENDPOINTS.US;
314
+ this.apiBaseUrl = endpoint;
307
315
  }
308
316
 
309
317
  // Set custom logger if provided
@@ -319,17 +327,59 @@ export class VeSync {
319
327
  */
320
328
  hydrateSession(session: Session): void {
321
329
  try {
322
- if (session.apiBaseUrl) {
323
- setApiBaseUrl(session.apiBaseUrl);
324
- this.apiBaseUrl = session.apiBaseUrl;
325
- }
326
- if (session.region) {
327
- this._region = session.region;
328
- setCurrentRegion(session.region);
330
+ if (!session?.token || !session?.accountId) {
331
+ throw new Error('Session missing token/accountId');
329
332
  }
333
+
334
+ // Always hydrate core credentials first.
330
335
  this.token = session.token;
331
336
  this.accountId = session.accountId;
332
- this.countryCode = session.countryCode || null;
337
+ this.countryCode = session.countryCode ?? null;
338
+ this.authFlowUsed = session.authFlowUsed;
339
+
340
+ // pyvesync parity:
341
+ // pyvesync persists `current_region` and derives the API base URL per-request from that region.
342
+ // It does NOT treat a stored base URL as authoritative. For backwards compatibility with older
343
+ // sessions, we normalize the fields here:
344
+ // - Prefer stored `region` when present
345
+ // - Otherwise derive region from stored `countryCode` (same mapping as pyvesync)
346
+ // - Otherwise infer region from stored apiBaseUrl (best-effort)
347
+ const sessionBaseUrl = session.apiBaseUrl ? normalizeApiBaseUrl(session.apiBaseUrl) : null;
348
+ const sessionBaseUrlIsKnown = !!sessionBaseUrl && KNOWN_API_BASE_URLS.has(sessionBaseUrl);
349
+
350
+ // If the session contains a custom base URL (not a known regional URL) and the caller did not
351
+ // explicitly set an API override, treat it as an override so region switching doesn't clobber it.
352
+ if (!this._apiUrlOverride && sessionBaseUrl && !sessionBaseUrlIsKnown) {
353
+ this._apiUrlOverride = sessionBaseUrl;
354
+ }
355
+
356
+ // Apply base URL override if present (pyvesync API_BASE_URL-style override).
357
+ if (this._apiUrlOverride) {
358
+ this.apiBaseUrl = this._apiUrlOverride;
359
+ }
360
+
361
+ let hydratedRegion: string | null = null;
362
+ if (typeof session.region === 'string' && session.region in REGION_ENDPOINTS) {
363
+ hydratedRegion = session.region;
364
+ } else if (typeof session.countryCode === 'string' && session.countryCode.trim()) {
365
+ hydratedRegion = getRegionFromCountryCode(session.countryCode);
366
+ } else if (sessionBaseUrl) {
367
+ hydratedRegion = inferRegionFromApiBaseUrl(sessionBaseUrl);
368
+ }
369
+
370
+ if (hydratedRegion && hydratedRegion in REGION_ENDPOINTS) {
371
+ this._region = hydratedRegion;
372
+ }
373
+
374
+ // Derive the API base URL from region unless explicitly overridden.
375
+ if (!this._apiUrlOverride) {
376
+ const endpoint = REGION_ENDPOINTS[this._region as keyof typeof REGION_ENDPOINTS] || REGION_ENDPOINTS.US;
377
+ this.apiBaseUrl = endpoint;
378
+ } else if (!this.apiBaseUrl && sessionBaseUrl) {
379
+ // Fallback: keep the persisted base URL if we have no other source.
380
+ this.apiBaseUrl = sessionBaseUrl;
381
+ }
382
+
333
383
  this.enabled = true;
334
384
  } catch (e) {
335
385
  logger.warn('Failed to hydrate session; will require fresh login');
@@ -405,13 +455,22 @@ export class VeSync {
405
455
  return this._region;
406
456
  }
407
457
 
458
+ /**
459
+ * Return the configured API URL override, if any.
460
+ */
461
+ get apiUrlOverride(): string | null {
462
+ return this._apiUrlOverride;
463
+ }
464
+
408
465
  /**
409
466
  * Set region and update API endpoint
410
467
  */
411
468
  set region(region: string) {
412
469
  if (region in REGION_ENDPOINTS) {
413
470
  this._region = region;
414
- setCurrentRegion(region);
471
+ if (!this._apiUrlOverride) {
472
+ this.apiBaseUrl = REGION_ENDPOINTS[region as keyof typeof REGION_ENDPOINTS];
473
+ }
415
474
  }
416
475
  }
417
476
 
@@ -546,21 +605,113 @@ export class VeSync {
546
605
  let success = false;
547
606
 
548
607
  try {
549
- const [response] = await Helpers.callApi(
550
- '/cloud/v2/deviceManaged/devices',
551
- 'post',
552
- Helpers.reqBody(this, 'devicelist'),
553
- Helpers.reqHeaders(this),
554
- this
555
- );
608
+ const fetchDeviceList = async (): Promise<[any, number]> => {
609
+ return await Helpers.callApi(
610
+ '/cloud/v1/deviceManaged/devices',
611
+ 'post',
612
+ Helpers.reqBody(this, 'devicelist'),
613
+ Helpers.reqHeaderBypass(),
614
+ this
615
+ );
616
+ };
617
+
618
+ const originalRegion = this._region;
619
+ let didRetryToken = false;
620
+ let didRetryCrossRegion = false;
621
+
622
+ let response: any;
623
+ let status: number;
624
+
625
+ [response, status] = await fetchDeviceList();
626
+
627
+ // Handle VeSync API codes similarly to pyvesync:
628
+ // - TOKEN_ERROR triggers a reauth + retry
629
+ // - CROSS_REGION triggers a region correction + retry
630
+ //
631
+ // pyvesync reference:
632
+ // - Token error retry: `pyvesync/vesync.py:async_call_api` (ErrorTypes.TOKEN_ERROR)
633
+ // - Region correction: `pyvesync/auth.py:_exchange_authorization_code` (ErrorTypes.CROSS_REGION)
634
+ while (true) {
635
+ if (!response || status !== 200) {
636
+ break;
637
+ }
638
+
639
+ const code = response?.code;
640
+ if (typeof code === 'number' && code !== 0) {
641
+ if (!didRetryToken && isTokenError(code)) {
642
+ didRetryToken = true;
643
+ logger.warn('Device list request returned token error; re-authenticating and retrying (pyvesync parity)', {
644
+ code,
645
+ msg: response?.msg,
646
+ region: this._region,
647
+ endpoint: this.apiBaseUrl
648
+ });
649
+ const reloginOk = await this.login();
650
+ if (!reloginOk) {
651
+ this.enabled = false;
652
+ return false;
653
+ }
654
+ [response, status] = await fetchDeviceList();
655
+ continue;
656
+ }
657
+
658
+ if (!didRetryCrossRegion && isCrossRegionError(code) && !this.apiUrlOverride) {
659
+ didRetryCrossRegion = true;
660
+
661
+ const serverRegion: string | undefined = response?.result?.currentRegion;
662
+ const normalizedServerRegion = typeof serverRegion === 'string' ? serverRegion.trim().toUpperCase() : null;
663
+ const nextRegion =
664
+ normalizedServerRegion && normalizedServerRegion in REGION_ENDPOINTS
665
+ ? normalizedServerRegion
666
+ : (this._region === 'US' ? 'EU' : 'US');
667
+
668
+ logger.warn('Device list request returned cross-region error; retrying against corrected endpoint (pyvesync-style)', {
669
+ code,
670
+ msg: response?.msg,
671
+ region: this._region,
672
+ endpoint: this.apiBaseUrl,
673
+ serverRegion: normalizedServerRegion,
674
+ nextRegion
675
+ });
676
+
677
+ this.region = nextRegion;
678
+ [response, status] = await fetchDeviceList();
679
+
680
+ // If this was a best-effort alternate-region guess (no serverRegion provided) and it
681
+ // didn't help, revert so we don't leave the manager stuck on the wrong endpoint.
682
+ if (!normalizedServerRegion && (!response || response.code !== 0)) {
683
+ this.region = originalRegion;
684
+ }
685
+ continue;
686
+ }
687
+ }
688
+ break;
689
+ }
556
690
 
557
691
  if (!response) {
558
692
  logger.error('No response received from VeSync API');
559
693
  return false;
560
694
  }
561
695
 
562
- if (response.error) {
563
- logger.error('API error:', response.msg || 'Unknown error');
696
+ if (status !== 200) {
697
+ logger.error('Device list request failed with non-200 status:', {
698
+ status,
699
+ code: response?.code,
700
+ msg: response?.msg,
701
+ region: this._region,
702
+ endpoint: this.apiBaseUrl
703
+ });
704
+ return false;
705
+ }
706
+
707
+ if (typeof response.code === 'number' && response.code !== 0) {
708
+ logger.error('Device list request returned error code:', {
709
+ status,
710
+ code: response.code,
711
+ msg: response?.msg,
712
+ region: this._region,
713
+ endpoint: this.apiBaseUrl
714
+ });
564
715
  return false;
565
716
  }
566
717
 
@@ -642,8 +793,17 @@ export class VeSync {
642
793
  try {
643
794
  logger.debug(`Authentication attempt ${attempt + 1} of ${retryAttempts}`);
644
795
 
645
- // Try new authentication flow first with detected region
646
- let [success, token, accountId, countryCode] = await Helpers.authNewFlow(this, this._appId, this._region, this._countryCodeOverride || undefined);
796
+ // Try new authentication flow first with detected region.
797
+ // NOTE: authNewFlow may temporarily change `this.region` during pyvesync-style
798
+ // cross-region Step 2 retry. Capture the attempted region so our fallback logic
799
+ // (try opposite endpoint) doesn't get confused by that internal mutation.
800
+ const attemptedRegion = this._region;
801
+ let [success, token, accountId, countryCode] = await Helpers.authNewFlow(
802
+ this,
803
+ this._appId,
804
+ attemptedRegion,
805
+ this._countryCodeOverride || undefined
806
+ );
647
807
 
648
808
  // Track if new flow succeeded
649
809
  if (success) {
@@ -659,11 +819,12 @@ export class VeSync {
659
819
 
660
820
  // Check if we need to try a different region
661
821
  if (countryCode === 'cross_region' || countryCode === 'cross_region_retry') {
662
- // Mark current region as tried
663
- triedRegions.add(this._region);
822
+ // Mark attempted region as tried (do not rely on `this._region`, which may have been
823
+ // mutated by authNewFlow during pyvesync-style cross-region handling).
824
+ triedRegions.add(attemptedRegion);
664
825
 
665
826
  logger.debug('Cross-region error detected, trying opposite region...');
666
- const alternateRegion = this._region === 'US' ? 'EU' : 'US';
827
+ const alternateRegion = attemptedRegion === 'US' ? 'EU' : 'US';
667
828
 
668
829
  // Check if we've already tried this region to prevent infinite loops
669
830
  if (triedRegions.has(alternateRegion)) {
@@ -692,20 +853,9 @@ export class VeSync {
692
853
  return false;
693
854
  }
694
855
 
695
- logger.debug(`Switching from ${this._region} to ${alternateRegion} region`);
696
-
697
- this._region = alternateRegion;
698
- setCurrentRegion(alternateRegion);
699
-
700
- // Update the API endpoint for the new region
701
- const regionEndpoints: Record<string, string> = {
702
- 'US': 'https://smartapi.vesync.com',
703
- 'EU': 'https://smartapi.vesync.eu'
704
- };
705
- if (alternateRegion in regionEndpoints) {
706
- setApiBaseUrl(regionEndpoints[alternateRegion]);
707
- logger.debug(`API endpoint set to ${regionEndpoints[alternateRegion]}`);
708
- }
856
+ logger.debug(`Switching from ${attemptedRegion} to ${alternateRegion} region`);
857
+ this.region = alternateRegion;
858
+ logger.debug(`API endpoint set to ${this.apiBaseUrl}`);
709
859
 
710
860
  // Retry with the alternate region
711
861
  [success, token, accountId, countryCode] = await Helpers.authNewFlow(this, this._appId, alternateRegion, this._countryCodeOverride || undefined);
@@ -741,7 +891,7 @@ export class VeSync {
741
891
  this.accountId = accountId;
742
892
  this.countryCode = countryCode;
743
893
  this.enabled = true;
744
- this.apiBaseUrl = getApiBaseUrl(); // Track the API endpoint being used
894
+ this.apiBaseUrl = this.apiBaseUrl || 'https://smartapi.vesync.com'; // Track the API endpoint being used
745
895
 
746
896
  // Persist session details for reuse across restarts
747
897
  this.emitTokenChange();
@@ -765,7 +915,7 @@ export class VeSync {
765
915
  authFlow: this.authFlowUsed,
766
916
  region: this._region,
767
917
  countryCode: countryCode,
768
- endpoint: getApiBaseUrl()
918
+ endpoint: this.apiBaseUrl
769
919
  });
770
920
  return true;
771
921
  }