tsvesync 1.4.2 → 1.4.4

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.
@@ -6,7 +6,7 @@ import axios from 'axios';
6
6
  import crypto from 'crypto';
7
7
  import { VeSync } from './vesync';
8
8
  import { logger } from './logger';
9
- import { isCredentialError, isCrossRegionError, CROSS_REGION_ERROR_CODES } from './constants';
9
+ import { isCredentialError, isCrossRegionError } from './constants';
10
10
 
11
11
  // API configuration - Support regional endpoints
12
12
  let _apiBaseUrl = 'https://smartapi.vesync.com';
@@ -31,21 +31,28 @@ export function setCurrentRegion(region: string): void {
31
31
  }
32
32
  }
33
33
 
34
+ // pyvesync parity:
35
+ // VeSync only exposes two API base URLs (US + EU). pyvesync intentionally uses a
36
+ // *deny-list* mapping here:
37
+ // - `US` region: US/CA/MX/JP
38
+ // - `EU` region: everything else
39
+ //
40
+ // This is counter-intuitive if you read it as "EU countries", but it's really
41
+ // "the EU *endpoint*". In practice, many accounts outside Europe (e.g. AU/NZ/SG)
42
+ // can be hosted on either endpoint depending on how/when the account was created.
43
+ //
44
+ // The correct/robust behavior comes from the Step 2 cross-region flow: when the
45
+ // region guess is wrong, the API returns `currentRegion`/`countryCode` plus a
46
+ // `bizToken`, and we retry Step 2 using the server-provided values (pyvesync-style).
47
+ export const NON_EU_COUNTRY_CODES = ['US', 'CA', 'MX', 'JP'];
48
+
34
49
  // Determine region from country code
35
- export function getRegionFromCountryCode(countryCode: string): string {
36
- const euCountries = [
37
- 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
38
- 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
39
- 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB', 'IS', 'LI',
40
- 'NO', 'CH'
41
- ];
42
-
43
- if (euCountries.includes(countryCode)) {
44
- return 'EU';
50
+ export function getRegionFromCountryCode(countryCode: string): 'US' | 'EU' {
51
+ const normalized = countryCode.trim().toUpperCase();
52
+ if (NON_EU_COUNTRY_CODES.includes(normalized)) {
53
+ return 'US';
45
54
  }
46
-
47
- // Default to US for other countries
48
- return 'US';
55
+ return 'EU';
49
56
  }
50
57
 
51
58
 
@@ -97,27 +104,8 @@ export function getUSEndpointCountryCodes(): string[] {
97
104
  * Get the appropriate API endpoint based on country code
98
105
  */
99
106
  export function getEndpointForCountryCode(countryCode: string): 'US' | 'EU' {
100
- // European country codes that should use the EU endpoint
101
- const euCountryCodes = [
102
- // Western Europe
103
- 'GB', 'DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'AT', 'CH', 'IE', 'PT', 'LU',
104
- // Nordic countries
105
- 'SE', 'NO', 'DK', 'FI', 'IS',
106
- // Eastern Europe
107
- 'PL', 'CZ', 'HU', 'RO', 'BG', 'HR', 'SI', 'SK', 'LT', 'LV', 'EE',
108
- // Southern Europe
109
- 'GR', 'CY', 'MT',
110
- // Other European
111
- 'AL', 'BA', 'RS', 'ME', 'MK', 'MD', 'UA', 'BY'
112
- ];
113
-
114
- // Check if country code is in EU list
115
- if (euCountryCodes.includes(countryCode)) {
116
- return 'EU';
117
- }
118
-
119
- // Everything else uses US endpoint (including AU, NZ, JP, CA, MX, etc.)
120
- return 'US';
107
+ // Alias for clarity: this is the region-to-endpoint mapping used by pyvesync.
108
+ return getRegionFromCountryCode(countryCode);
121
109
  }
122
110
 
123
111
  /**
@@ -169,12 +157,8 @@ export function generateAppId(): string {
169
157
 
170
158
  // Generate unique terminal ID for authentication
171
159
  export function generateTerminalId(): string {
172
- const chars = 'abcdef0123456789';
173
- let result = '';
174
- for (let i = 0; i < 16; i++) {
175
- result += chars.charAt(Math.floor(Math.random() * chars.length));
176
- }
177
- return result;
160
+ // pyvesync parity: terminalId is a UUID-like identifier prefixed with "2".
161
+ return `2${crypto.randomUUID().replace(/-/g, '')}`;
178
162
  }
179
163
 
180
164
  export function generateTraceId(): string {
@@ -183,6 +167,14 @@ export function generateTraceId(): string {
183
167
  return `APP${MOBILE_ID.slice(-5, -1)}${timestamp}-${randomPart}`;
184
168
  }
185
169
 
170
+ let _authTraceCallNumber = 0;
171
+ function generateAuthTraceId(terminalId: string): string {
172
+ _authTraceCallNumber += 1;
173
+ const suffix = terminalId.slice(-5, -1);
174
+ const timestamp = Math.floor(Date.now() / 1000);
175
+ return `APP${suffix}${timestamp}-${String(_authTraceCallNumber).padStart(5, '0')}`;
176
+ }
177
+
186
178
  export interface RequestBody {
187
179
  acceptLanguage?: string;
188
180
  accountID?: string;
@@ -378,18 +370,12 @@ export class Helpers {
378
370
  /**
379
371
  * Build request body for initial authentication step
380
372
  */
381
- static reqBodyAuthStep1(manager: VeSync, appId: string, terminalId: string, region: string = 'US'): Record<string, any> {
382
- // Detect user's home region and country code
383
- const userRegion = detectUserRegion(manager.username);
384
- const userCountryCode = getCountryCodeFromRegion(region === 'EU' ? 'EU' : userRegion, manager.username);
385
-
386
- logger.debug('Authentication Step 1 - Region detection:', {
387
- email: manager.username,
388
- detectedRegion: userRegion,
389
- requestRegion: region,
390
- userCountryCode: userCountryCode
391
- });
392
-
373
+ static reqBodyAuthStep1(manager: VeSync, appId: string, terminalId: string, userCountryCode: string): Record<string, any> {
374
+ // pyvesync parity:
375
+ // - Mirrors `pyvesync.models.vesync_models.RequestGetTokenModel`
376
+ // - `userCountryCode` defaults to `DEFAULT_REGION` ("US") unless the caller overrides it
377
+ // - `timeZone` defaults to `DEFAULT_TZ` ("America/New_York") during auth; the API later updates the
378
+ // effective timezone used for device operations.
393
379
  return {
394
380
  'email': manager.username,
395
381
  'method': 'authByPWDOrOTM',
@@ -397,30 +383,43 @@ export class Helpers {
397
383
  'acceptLanguage': 'en',
398
384
  'accountID': '',
399
385
  'authProtocolType': 'generic',
400
- 'clientInfo': CLIENT_INFO,
386
+ 'clientInfo': PHONE_BRAND,
401
387
  'clientType': 'vesyncApp',
402
388
  'clientVersion': CLIENT_VERSION,
403
389
  'debugMode': false,
404
390
  'osInfo': PHONE_OS,
405
391
  'terminalId': terminalId,
406
- 'timeZone': manager.timeZone || DEFAULT_TZ,
392
+ 'timeZone': DEFAULT_TZ,
407
393
  'token': '',
408
- 'userCountryCode': userCountryCode,
394
+ 'userCountryCode': userCountryCode.trim().toUpperCase(),
409
395
  'appID': appId,
410
396
  'sourceAppID': appId,
411
- 'traceId': `APP${appId}${Math.floor(Date.now() / 1000)}`
397
+ 'traceId': generateAuthTraceId(terminalId)
412
398
  };
413
399
  }
414
400
 
415
401
  /**
416
402
  * Build request body for second authentication step (login with authorize code)
417
403
  */
418
- static reqBodyAuthStep2(authorizeCode: string, bizToken: string | null, appId: string, terminalId: string, userCountryCode?: string, regionChange?: string): Record<string, any> {
404
+ static reqBodyAuthStep2(authorizeCode: string, bizToken: string | null, _appId: string, terminalId: string, userCountryCode?: string, regionChange?: string): Record<string, any> {
405
+ // pyvesync parity:
406
+ // - Mirrors `pyvesync.models.vesync_models.RequestLoginTokenModel`
407
+ // - `timeZone` remains `DEFAULT_TZ` during auth (device timezone is handled elsewhere)
408
+ // - Step 2 intentionally does NOT include `appID` / `sourceAppID`. Those are only sent in Step 1
409
+ // (`RequestGetTokenModel`). pyvesync's Step 2 request model has no appID fields.
410
+ // If you see older diagnostics including appID/sourceAppID in Step 2, treat it as a superset payload
411
+ // rather than a requirement — our goal here is to match the known-working pyvesync request shape.
412
+ // - IMPORTANT: `bizToken` here is the *region-change token* returned by VeSync on Step 2 CROSS_REGION
413
+ // errors. It is not part of the normal Step 1 -> Step 2 flow. pyvesync does not send a Step 1
414
+ // bizToken in the initial Step 2 request (Step 1 result is just `{ accountID, authorizeCode }`);
415
+ // it only includes `bizToken` + `regionChange='lastRegion'`
416
+ // when retrying Step 2 after a cross-region response. See `pyvesync/auth.py:_exchange_authorization_code`.
419
417
  const body: Record<string, any> = {
420
418
  'method': 'loginByAuthorizeCode4Vesync',
421
419
  'authorizeCode': authorizeCode,
422
420
  'acceptLanguage': 'en',
423
- 'clientInfo': CLIENT_INFO,
421
+ 'accountID': '',
422
+ 'clientInfo': PHONE_BRAND,
424
423
  'clientType': 'vesyncApp',
425
424
  'clientVersion': CLIENT_VERSION,
426
425
  'debugMode': false,
@@ -428,8 +427,9 @@ export class Helpers {
428
427
  'osInfo': PHONE_OS,
429
428
  'terminalId': terminalId,
430
429
  'timeZone': DEFAULT_TZ,
431
- 'userCountryCode': userCountryCode || 'US',
432
- 'traceId': `APP${appId}${Math.floor(Date.now() / 1000)}`
430
+ 'token': '',
431
+ 'userCountryCode': (userCountryCode || DEFAULT_REGION).trim().toUpperCase(),
432
+ 'traceId': generateAuthTraceId(terminalId)
433
433
  };
434
434
 
435
435
  // Only include bizToken if it's not null
@@ -524,14 +524,20 @@ export class Helpers {
524
524
  headers: Record<string, string> = {},
525
525
  manager: VeSync
526
526
  ): Promise<[any, number]> {
527
+ let url = '';
527
528
  try {
529
+ // Prefer manager-scoped base URL (pyvesync resolves per-manager from current region).
530
+ // Keep the module-level base URL as a fallback for older callers.
531
+ let baseUrl = manager.apiBaseUrl || _apiBaseUrl;
532
+
528
533
  // Ensure API base URL is properly set
529
- if (!_apiBaseUrl || _apiBaseUrl === 'undefined') {
530
- logger.error('API base URL is not properly configured. Setting to default US endpoint...');
531
- setApiBaseUrl('https://smartapi.vesync.com');
534
+ if (!baseUrl || baseUrl === 'undefined') {
535
+ logger.error('API base URL is not properly configured. Falling back to US endpoint...');
536
+ baseUrl = 'https://smartapi.vesync.com';
537
+ manager.apiBaseUrl = baseUrl;
532
538
  }
533
-
534
- const url = _apiBaseUrl + endpoint;
539
+
540
+ url = baseUrl + endpoint;
535
541
  logger.debug(`Making API call to: ${url}`);
536
542
 
537
543
  const response = await axios({
@@ -571,7 +577,7 @@ export class Helpers {
571
577
  status: error.response.status,
572
578
  code: responseData?.code,
573
579
  message: responseData?.msg,
574
- url: _apiBaseUrl + endpoint
580
+ url
575
581
  });
576
582
 
577
583
  return [responseData, error.response.status];
@@ -580,7 +586,7 @@ export class Helpers {
580
586
  logger.error('API call failed:', {
581
587
  code: error.code,
582
588
  message: error.message,
583
- url: _apiBaseUrl + endpoint
589
+ url: url || (manager.apiBaseUrl || _apiBaseUrl || '') + endpoint
584
590
  });
585
591
  return [null, 0];
586
592
  }
@@ -660,20 +666,29 @@ export class Helpers {
660
666
  try {
661
667
  // Generate terminal ID to be used in both steps
662
668
  const terminalId = generateTerminalId();
669
+ // pyvesync parity:
670
+ // - `userCountryCode` defaults to `DEFAULT_REGION` ("US"), even if we first try the EU base URL.
671
+ // - If the account truly belongs to another region, the Step 2 response includes the correct
672
+ // `countryCode`/`currentRegion` and a `bizToken` to retry Step 2 (handled below).
673
+ // Avoid "guessing" a country code from `region` or email heuristics here; it diverges from pyvesync.
674
+ const userCountryCode = (countryCodeOverride || DEFAULT_REGION).trim().toUpperCase();
663
675
 
664
676
  // Step 1: Get authorization code
665
- const step1Body = this.reqBodyAuthStep1(manager, appId, terminalId, region);
677
+ const step1Body = this.reqBodyAuthStep1(manager, appId, terminalId, userCountryCode);
666
678
  const step1Headers = this.reqHeaderAuth();
667
679
 
668
680
  // Set the correct regional endpoint
669
- const originalUrl = getApiBaseUrl();
670
- if (region in REGION_ENDPOINTS) {
671
- setApiBaseUrl(REGION_ENDPOINTS[region as keyof typeof REGION_ENDPOINTS]);
681
+ const originalUrl = manager.apiBaseUrl || getApiBaseUrl();
682
+ const knownBaseUrls = new Set(Object.values(REGION_ENDPOINTS));
683
+ const hasCustomBaseUrlOverride = !!manager.apiUrlOverride || !knownBaseUrls.has(originalUrl);
684
+ if (!hasCustomBaseUrlOverride && region in REGION_ENDPOINTS) {
685
+ const nextUrl = REGION_ENDPOINTS[region as keyof typeof REGION_ENDPOINTS];
686
+ manager.apiBaseUrl = nextUrl;
672
687
  }
673
688
 
674
689
  logger.debug('Step 1: Getting authorization code...', {
675
690
  region,
676
- endpoint: getApiBaseUrl(),
691
+ endpoint: manager.apiBaseUrl || getApiBaseUrl(),
677
692
  body: { ...step1Body, password: '[REDACTED]' }
678
693
  });
679
694
 
@@ -687,7 +702,7 @@ export class Helpers {
687
702
 
688
703
  if (!authResponse || authStatus !== 200) {
689
704
  logger.error('Step 1 failed:', authResponse);
690
- setApiBaseUrl(originalUrl); // Restore original URL
705
+ manager.apiBaseUrl = originalUrl;
691
706
  return [false, null, null, null];
692
707
  }
693
708
 
@@ -697,26 +712,30 @@ export class Helpers {
697
712
  // Check if it's a credential error (no point retrying)
698
713
  if (isCredentialError(authResponse.code)) {
699
714
  logger.error('Credential error detected - invalid username or password');
700
- setApiBaseUrl(originalUrl); // Restore original URL
715
+ manager.apiBaseUrl = originalUrl;
701
716
  return [false, null, null, 'credential_error'];
702
717
  }
703
718
 
704
719
  // Handle cross-region error (multiple error codes possible)
705
720
  if (isCrossRegionError(authResponse.code)) {
706
721
  logger.debug('Cross-region error detected:', authResponse.code, 'will try different region');
707
- setApiBaseUrl(originalUrl); // Restore original URL
722
+ manager.apiBaseUrl = originalUrl;
708
723
  return [false, null, null, 'cross_region'];
709
724
  }
710
725
 
711
- setApiBaseUrl(originalUrl); // Restore original URL
726
+ manager.apiBaseUrl = originalUrl;
712
727
  return [false, null, null, null];
713
728
  }
714
729
 
715
- const { authorizeCode, bizToken } = authResponse.result || {};
730
+ // pyvesync parity note:
731
+ // - Step 1 "authByPWDOrOTM" returns a result shaped like RespGetTokenResultModel:
732
+ // `{ accountID, authorizeCode }`. There is no Step 1 bizToken to forward.
733
+ // See `pyvesync/models/vesync_models.py:RespGetTokenResultModel`.
734
+ const { authorizeCode } = authResponse.result || {};
716
735
 
717
736
  if (!authorizeCode) {
718
737
  logger.error('Missing authorization code in step 1 response:', authResponse.result);
719
- setApiBaseUrl(originalUrl); // Restore original URL
738
+ manager.apiBaseUrl = originalUrl;
720
739
  return [false, null, null, null];
721
740
  }
722
741
 
@@ -725,8 +744,17 @@ export class Helpers {
725
744
  // Step 2: Login with authorization code
726
745
  // Use the override if provided, otherwise default to 'US'
727
746
  // Users should specify their actual country code in the configuration
728
- const countryCodeForStep2 = countryCodeOverride || 'US';
729
- const step2Body = this.reqBodyAuthStep2(authorizeCode, bizToken, appId, terminalId, countryCodeForStep2);
747
+ const countryCodeForStep2 = userCountryCode;
748
+ // pyvesync parity note:
749
+ // - Step 1 returns `authorizeCode` (and an `accountID`) but does not require/provide a bizToken for
750
+ // the initial Step 2 request.
751
+ // - Step 2 initial request omits `bizToken`.
752
+ // - Only if Step 2 returns a CROSS_REGION error does VeSync provide `result.bizToken`, which is then
753
+ // echoed back as `bizToken` along with `regionChange='lastRegion'` for a Step 2 retry.
754
+ // See `pyvesync/auth.py:_exchange_authorization_code` for the canonical behavior.
755
+ // - Step 2 also intentionally omits appID/sourceAppID (pyvesync parity); we still pass `appId` through
756
+ // to keep the function signature stable/historical.
757
+ const step2Body = this.reqBodyAuthStep2(authorizeCode, null, appId, terminalId, countryCodeForStep2);
730
758
 
731
759
  logger.debug('Step 2: Logging in with authorization code...');
732
760
 
@@ -747,43 +775,85 @@ export class Helpers {
747
775
 
748
776
  if (!loginResponse || loginStatus !== 200) {
749
777
  logger.error('Step 2 failed:', loginResponse);
750
- setApiBaseUrl(originalUrl); // Restore original URL on failure
778
+ manager.apiBaseUrl = originalUrl;
751
779
  return [false, null, null, null];
752
780
  }
753
781
 
754
782
  if (loginResponse.code !== 0) {
755
783
  logger.debug('Step 2 error, code:', loginResponse.code, 'checking if cross-region...');
756
- // Handle cross-region error - for EU accounts, we need to switch regions entirely
757
- if (CROSS_REGION_ERROR_CODES.includes(loginResponse.code)) {
758
- // Log helpful message about country code mismatch
759
- logger.warn('═══════════════════════════════════════════════════════════════');
760
- logger.warn('COUNTRY CODE MISMATCH DETECTED');
761
- logger.warn(`Your account expects a different country code than '${countryCodeForStep2}'`);
762
- logger.warn('This usually means:');
763
- logger.warn(' 1. You need to set the correct country code in your config');
764
- logger.warn(' 2. Your account was created in a different country/region');
765
- logger.warn('');
766
- logger.warn('To fix this:');
767
- logger.warn(' - In Homebridge UI: Select your correct country from the dropdown');
768
- logger.warn(' - In config.json: Add "countryCode": "YOUR_COUNTRY_CODE"');
769
- logger.warn('');
770
- logger.warn('Common country codes: US, CA, GB, DE, FR, AU, NZ, JP');
771
- logger.warn('═══════════════════════════════════════════════════════════════');
772
-
773
- logger.debug('Cross-region error detected in Step 2 - will try different endpoint');
774
- // For cross-region errors, we need to retry the entire flow with a different region
775
- // The authorization code from Step 1 is tied to the endpoint it was obtained from
776
- setApiBaseUrl(originalUrl); // Restore original URL
784
+
785
+ // Align with pyvesync: on cross-region errors, retry Step 2 using the server-provided
786
+ // country/region and bizToken + regionChange.
787
+ if (isCrossRegionError(loginResponse.code)) {
788
+ const serverCountryCode: string | undefined = loginResponse.result?.countryCode;
789
+ const serverRegion: string | undefined = loginResponse.result?.currentRegion;
790
+ const regionChangeToken: string | undefined = loginResponse.result?.bizToken;
791
+
792
+ logger.debug('Cross-region error detected in Step 2; attempting pyvesync-style retry', {
793
+ requestedRegion: region,
794
+ requestedCountryCode: countryCodeForStep2,
795
+ serverCountryCode,
796
+ serverRegion,
797
+ hasRegionChangeToken: !!regionChangeToken,
798
+ });
799
+
800
+ // If VeSync tells us which region to use, switch to it for subsequent requests.
801
+ if (!hasCustomBaseUrlOverride && serverRegion && serverRegion in REGION_ENDPOINTS) {
802
+ manager.region = serverRegion;
803
+ logger.debug(`Updated region to ${serverRegion} for Step 2 retry`);
804
+ }
805
+
806
+ // If VeSync gives us a region-change token, retry Step 2 without redoing Step 1.
807
+ if (regionChangeToken) {
808
+ const retryCountryCode = serverCountryCode || countryCodeForStep2;
809
+ const retryBody = this.reqBodyAuthStep2(
810
+ authorizeCode,
811
+ regionChangeToken,
812
+ appId,
813
+ terminalId,
814
+ retryCountryCode,
815
+ 'lastRegion'
816
+ );
817
+
818
+ const [retryResponse, retryStatus] = await this.callApi(
819
+ '/user/api/accountManage/v1/loginByAuthorizeCode4Vesync',
820
+ 'post',
821
+ retryBody,
822
+ step1Headers,
823
+ manager
824
+ );
825
+
826
+ if (retryResponse && retryStatus === 200 && retryResponse.code === 0) {
827
+ const { token, accountID, countryCode } = retryResponse.result || {};
828
+ if (token && accountID) {
829
+ logger.debug('Step 2 retry successful, got token and accountID');
830
+ return [true, token, accountID, countryCode || retryCountryCode];
831
+ }
832
+ logger.error('Missing required fields in Step 2 retry response:', retryResponse.result);
833
+ manager.apiBaseUrl = originalUrl;
834
+ return [false, null, null, null];
835
+ }
836
+
837
+ logger.error('Step 2 retry failed:', {
838
+ status: retryStatus,
839
+ code: retryResponse?.code,
840
+ msg: retryResponse?.msg,
841
+ });
842
+
843
+ manager.apiBaseUrl = originalUrl;
844
+ return [false, null, null, 'cross_region_retry'];
845
+ }
846
+
847
+ logger.error('Cross-region error returned no region-change token; cannot retry Step 2');
848
+ manager.apiBaseUrl = originalUrl;
777
849
  return [false, null, null, 'cross_region_retry'];
778
850
  }
779
-
851
+
780
852
  logger.error('Step 2 error code:', loginResponse.code, 'message:', loginResponse.msg);
781
- setApiBaseUrl(originalUrl); // Restore original URL on error
853
+ manager.apiBaseUrl = originalUrl;
782
854
  return [false, null, null, null];
783
855
  }
784
856
 
785
- setApiBaseUrl(originalUrl); // Restore original URL on success
786
-
787
857
  const { token, accountID, countryCode } = loginResponse.result || {};
788
858
 
789
859
  if (!token || !accountID) {
@@ -808,7 +878,7 @@ export class Helpers {
808
878
  const body = this.reqBody(manager, 'login');
809
879
 
810
880
  logger.debug('Legacy login attempt...', {
811
- endpoint: getApiBaseUrl(),
881
+ endpoint: manager.apiBaseUrl || getApiBaseUrl(),
812
882
  body: { ...body, password: '[REDACTED]' }
813
883
  });
814
884