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/README.md CHANGED
@@ -208,40 +208,21 @@ await manager.login();
208
208
 
209
209
  #### Supported Country Codes
210
210
 
211
- The library supports all country codes. Common ones include:
212
-
213
- **Americas:**
214
- - `US` - United States (default)
215
- - `CA` - Canada
216
- - `MX` - Mexico
217
-
218
- **Europe:** (uses EU endpoint)
219
- - `GB` - United Kingdom
220
- - `DE` - Germany
221
- - `FR` - France
222
- - `IT` - Italy
223
- - `ES` - Spain
224
- - `NL` - Netherlands
225
- - `SE` - Sweden
226
- - `NO` - Norway
227
- - `DK` - Denmark
228
- - `FI` - Finland
229
- - `PL` - Poland
230
- - And all other European countries
231
-
232
- **Asia-Pacific:** (uses US endpoint)
233
- - `AU` - Australia
234
- - `NZ` - New Zealand
235
- - `JP` - Japan
236
- - `SG` - Singapore
237
- - `CN` - China
238
- - `KR` - South Korea
239
-
240
- **Important Notes:**
241
- - If you get an authentication error about "country code mismatch" or "cross-region error", you need to set your country code
242
- - The country code should match the country where your VeSync account was created
243
- - European countries automatically use the EU endpoint (`smartapi.vesync.eu`)
244
- - All other countries use the US endpoint (`smartapi.vesync.com`)
211
+ The library supports all ISO 3166-1 alpha-2 country codes (e.g. `US`, `DE`, `AU`).
212
+
213
+ ##### pyvesync parity notes (regions + device list)
214
+
215
+ VeSync uses two API base URLs:
216
+ - US: `https://smartapi.vesync.com`
217
+ - EU: `https://smartapi.vesync.eu`
218
+
219
+ This library intentionally mirrors **pyvesync** behavior for the initial region selection:
220
+ - `US` region: `US`, `CA`, `MX`, `JP`
221
+ - `EU` region: everything else
222
+
223
+ Some accounts may still “live” in the opposite region for historical/app reasons; in that case the new auth flow returns a cross‑region response and we automatically retry Step 2 using the server-provided `currentRegion`/`countryCode` + `bizToken` (pyvesync-style).
224
+
225
+ Device discovery parity note: the device list call uses `/cloud/v1/deviceManaged/devices` with bypass headers only; `token` and `accountID` are supplied in the request body (matching pyvesync’s request model).
245
226
 
246
227
  ### Working with Devices
247
228
 
@@ -3,6 +3,7 @@
3
3
  */
4
4
  export declare const CREDENTIAL_ERROR_CODES: number[];
5
5
  export declare const CROSS_REGION_ERROR_CODES: number[];
6
+ export declare const TOKEN_ERROR_CODES: number[];
6
7
  export declare const APP_VERSION_ERROR_CODES: number[];
7
8
  /**
8
9
  * Check if an error code indicates bad credentials
@@ -12,3 +13,7 @@ export declare function isCredentialError(code: number): boolean;
12
13
  * Check if an error code indicates cross-region issue
13
14
  */
14
15
  export declare function isCrossRegionError(code: number): boolean;
16
+ /**
17
+ * Check if an error code indicates the token is invalid/expired.
18
+ */
19
+ export declare function isTokenError(code: number): boolean;
@@ -3,9 +3,10 @@
3
3
  * VeSync API Error Codes
4
4
  */
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.APP_VERSION_ERROR_CODES = exports.CROSS_REGION_ERROR_CODES = exports.CREDENTIAL_ERROR_CODES = void 0;
6
+ exports.APP_VERSION_ERROR_CODES = exports.TOKEN_ERROR_CODES = exports.CROSS_REGION_ERROR_CODES = exports.CREDENTIAL_ERROR_CODES = void 0;
7
7
  exports.isCredentialError = isCredentialError;
8
8
  exports.isCrossRegionError = isCrossRegionError;
9
+ exports.isTokenError = isTokenError;
9
10
  // Authentication error codes that indicate bad credentials (no point retrying)
10
11
  exports.CREDENTIAL_ERROR_CODES = [
11
12
  -11201129, // account or password incorrect
@@ -17,6 +18,11 @@ exports.CROSS_REGION_ERROR_CODES = [
17
18
  -11260022, // cross region error
18
19
  -11261022, // access region conflict error
19
20
  ];
21
+ // Token/auth invalidation error codes
22
+ // pyvesync parity: `-11001000` maps to TOKEN_EXPIRED (invalid token)
23
+ exports.TOKEN_ERROR_CODES = [
24
+ -11001000, // token expired/invalid
25
+ ];
20
26
  // Other error codes
21
27
  exports.APP_VERSION_ERROR_CODES = [
22
28
  -11012022, // app version is too low
@@ -33,3 +39,9 @@ function isCredentialError(code) {
33
39
  function isCrossRegionError(code) {
34
40
  return exports.CROSS_REGION_ERROR_CODES.includes(code);
35
41
  }
42
+ /**
43
+ * Check if an error code indicates the token is invalid/expired.
44
+ */
45
+ function isTokenError(code) {
46
+ return exports.TOKEN_ERROR_CODES.includes(code);
47
+ }
@@ -6,7 +6,8 @@ export declare function getApiBaseUrl(): string;
6
6
  export declare function setApiBaseUrl(url: string): void;
7
7
  export declare function getCurrentRegion(): string;
8
8
  export declare function setCurrentRegion(region: string): void;
9
- export declare function getRegionFromCountryCode(countryCode: string): string;
9
+ export declare const NON_EU_COUNTRY_CODES: string[];
10
+ export declare function getRegionFromCountryCode(countryCode: string): 'US' | 'EU';
10
11
  export declare const API_RATE_LIMIT = 30;
11
12
  export declare const API_TIMEOUT = 15000;
12
13
  export declare const APP_VERSION = "5.7.16";
@@ -141,11 +142,11 @@ export declare class Helpers {
141
142
  /**
142
143
  * Build request body for initial authentication step
143
144
  */
144
- static reqBodyAuthStep1(manager: VeSync, appId: string, terminalId: string, region?: string): Record<string, any>;
145
+ static reqBodyAuthStep1(manager: VeSync, appId: string, terminalId: string, userCountryCode: string): Record<string, any>;
145
146
  /**
146
147
  * Build request body for second authentication step (login with authorize code)
147
148
  */
148
- static reqBodyAuthStep2(authorizeCode: string, bizToken: string | null, appId: string, terminalId: string, userCountryCode?: string, regionChange?: string): Record<string, any>;
149
+ static reqBodyAuthStep2(authorizeCode: string, bizToken: string | null, _appId: string, terminalId: string, userCountryCode?: string, regionChange?: string): Record<string, any>;
149
150
  /**
150
151
  * Builder for body of api requests
151
152
  */
@@ -6,7 +6,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
6
6
  return (mod && mod.__esModule) ? mod : { "default": mod };
7
7
  };
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.Helpers = exports.AUTH_ERROR_CODES = exports.APP_VERSION_TOO_LOW_ERROR_CODE = exports.REGION_ENDPOINTS = exports.CLIENT_VERSION = exports.BYPASS_HEADER_UA = exports.MOBILE_ID = exports.DEFAULT_REGION = exports.DEFAULT_TZ = exports.USER_TYPE = exports.CLIENT_INFO = exports.PHONE_OS = exports.PHONE_BRAND = exports.APP_VERSION = exports.API_TIMEOUT = exports.API_RATE_LIMIT = void 0;
9
+ exports.Helpers = exports.AUTH_ERROR_CODES = exports.APP_VERSION_TOO_LOW_ERROR_CODE = exports.REGION_ENDPOINTS = exports.CLIENT_VERSION = exports.BYPASS_HEADER_UA = exports.MOBILE_ID = exports.DEFAULT_REGION = exports.DEFAULT_TZ = exports.USER_TYPE = exports.CLIENT_INFO = exports.PHONE_OS = exports.PHONE_BRAND = exports.APP_VERSION = exports.API_TIMEOUT = exports.API_RATE_LIMIT = exports.NON_EU_COUNTRY_CODES = void 0;
10
10
  exports.getApiBaseUrl = getApiBaseUrl;
11
11
  exports.setApiBaseUrl = setApiBaseUrl;
12
12
  exports.getCurrentRegion = getCurrentRegion;
@@ -41,19 +41,27 @@ function setCurrentRegion(region) {
41
41
  _apiBaseUrl = exports.REGION_ENDPOINTS[region];
42
42
  }
43
43
  }
44
+ // pyvesync parity:
45
+ // VeSync only exposes two API base URLs (US + EU). pyvesync intentionally uses a
46
+ // *deny-list* mapping here:
47
+ // - `US` region: US/CA/MX/JP
48
+ // - `EU` region: everything else
49
+ //
50
+ // This is counter-intuitive if you read it as "EU countries", but it's really
51
+ // "the EU *endpoint*". In practice, many accounts outside Europe (e.g. AU/NZ/SG)
52
+ // can be hosted on either endpoint depending on how/when the account was created.
53
+ //
54
+ // The correct/robust behavior comes from the Step 2 cross-region flow: when the
55
+ // region guess is wrong, the API returns `currentRegion`/`countryCode` plus a
56
+ // `bizToken`, and we retry Step 2 using the server-provided values (pyvesync-style).
57
+ exports.NON_EU_COUNTRY_CODES = ['US', 'CA', 'MX', 'JP'];
44
58
  // Determine region from country code
45
59
  function getRegionFromCountryCode(countryCode) {
46
- const euCountries = [
47
- 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
48
- 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
49
- 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB', 'IS', 'LI',
50
- 'NO', 'CH'
51
- ];
52
- if (euCountries.includes(countryCode)) {
53
- return 'EU';
60
+ const normalized = countryCode.trim().toUpperCase();
61
+ if (exports.NON_EU_COUNTRY_CODES.includes(normalized)) {
62
+ return 'US';
54
63
  }
55
- // Default to US for other countries
56
- return 'US';
64
+ return 'EU';
57
65
  }
58
66
  exports.API_RATE_LIMIT = 30;
59
67
  exports.API_TIMEOUT = 15000;
@@ -97,25 +105,8 @@ function getUSEndpointCountryCodes() {
97
105
  * Get the appropriate API endpoint based on country code
98
106
  */
99
107
  function getEndpointForCountryCode(countryCode) {
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
- // Check if country code is in EU list
114
- if (euCountryCodes.includes(countryCode)) {
115
- return 'EU';
116
- }
117
- // Everything else uses US endpoint (including AU, NZ, JP, CA, MX, etc.)
118
- return 'US';
108
+ // Alias for clarity: this is the region-to-endpoint mapping used by pyvesync.
109
+ return getRegionFromCountryCode(countryCode);
119
110
  }
120
111
  /**
121
112
  * Detect user's home region from email domain or country hints
@@ -166,18 +157,21 @@ function generateAppId() {
166
157
  }
167
158
  // Generate unique terminal ID for authentication
168
159
  function generateTerminalId() {
169
- const chars = 'abcdef0123456789';
170
- let result = '';
171
- for (let i = 0; i < 16; i++) {
172
- result += chars.charAt(Math.floor(Math.random() * chars.length));
173
- }
174
- return result;
160
+ // pyvesync parity: terminalId is a UUID-like identifier prefixed with "2".
161
+ return `2${crypto_1.default.randomUUID().replace(/-/g, '')}`;
175
162
  }
176
163
  function generateTraceId() {
177
164
  const timestamp = Math.floor(Date.now() / 1000);
178
165
  const randomPart = Math.floor(Math.random() * 100000).toString().padStart(5, '0');
179
166
  return `APP${exports.MOBILE_ID.slice(-5, -1)}${timestamp}-${randomPart}`;
180
167
  }
168
+ let _authTraceCallNumber = 0;
169
+ function generateAuthTraceId(terminalId) {
170
+ _authTraceCallNumber += 1;
171
+ const suffix = terminalId.slice(-5, -1);
172
+ const timestamp = Math.floor(Date.now() / 1000);
173
+ return `APP${suffix}${timestamp}-${String(_authTraceCallNumber).padStart(5, '0')}`;
174
+ }
181
175
  class Helpers {
182
176
  static generateTraceId() {
183
177
  return generateTraceId();
@@ -306,16 +300,12 @@ class Helpers {
306
300
  /**
307
301
  * Build request body for initial authentication step
308
302
  */
309
- static reqBodyAuthStep1(manager, appId, terminalId, region = 'US') {
310
- // Detect user's home region and country code
311
- const userRegion = detectUserRegion(manager.username);
312
- const userCountryCode = getCountryCodeFromRegion(region === 'EU' ? 'EU' : userRegion, manager.username);
313
- logger_1.logger.debug('Authentication Step 1 - Region detection:', {
314
- email: manager.username,
315
- detectedRegion: userRegion,
316
- requestRegion: region,
317
- userCountryCode: userCountryCode
318
- });
303
+ static reqBodyAuthStep1(manager, appId, terminalId, userCountryCode) {
304
+ // pyvesync parity:
305
+ // - Mirrors `pyvesync.models.vesync_models.RequestGetTokenModel`
306
+ // - `userCountryCode` defaults to `DEFAULT_REGION` ("US") unless the caller overrides it
307
+ // - `timeZone` defaults to `DEFAULT_TZ` ("America/New_York") during auth; the API later updates the
308
+ // effective timezone used for device operations.
319
309
  return {
320
310
  'email': manager.username,
321
311
  'method': 'authByPWDOrOTM',
@@ -323,29 +313,42 @@ class Helpers {
323
313
  'acceptLanguage': 'en',
324
314
  'accountID': '',
325
315
  'authProtocolType': 'generic',
326
- 'clientInfo': exports.CLIENT_INFO,
316
+ 'clientInfo': exports.PHONE_BRAND,
327
317
  'clientType': 'vesyncApp',
328
318
  'clientVersion': exports.CLIENT_VERSION,
329
319
  'debugMode': false,
330
320
  'osInfo': exports.PHONE_OS,
331
321
  'terminalId': terminalId,
332
- 'timeZone': manager.timeZone || exports.DEFAULT_TZ,
322
+ 'timeZone': exports.DEFAULT_TZ,
333
323
  'token': '',
334
- 'userCountryCode': userCountryCode,
324
+ 'userCountryCode': userCountryCode.trim().toUpperCase(),
335
325
  'appID': appId,
336
326
  'sourceAppID': appId,
337
- 'traceId': `APP${appId}${Math.floor(Date.now() / 1000)}`
327
+ 'traceId': generateAuthTraceId(terminalId)
338
328
  };
339
329
  }
340
330
  /**
341
331
  * Build request body for second authentication step (login with authorize code)
342
332
  */
343
- static reqBodyAuthStep2(authorizeCode, bizToken, appId, terminalId, userCountryCode, regionChange) {
333
+ static reqBodyAuthStep2(authorizeCode, bizToken, _appId, terminalId, userCountryCode, regionChange) {
334
+ // pyvesync parity:
335
+ // - Mirrors `pyvesync.models.vesync_models.RequestLoginTokenModel`
336
+ // - `timeZone` remains `DEFAULT_TZ` during auth (device timezone is handled elsewhere)
337
+ // - Step 2 intentionally does NOT include `appID` / `sourceAppID`. Those are only sent in Step 1
338
+ // (`RequestGetTokenModel`). pyvesync's Step 2 request model has no appID fields.
339
+ // If you see older diagnostics including appID/sourceAppID in Step 2, treat it as a superset payload
340
+ // rather than a requirement — our goal here is to match the known-working pyvesync request shape.
341
+ // - IMPORTANT: `bizToken` here is the *region-change token* returned by VeSync on Step 2 CROSS_REGION
342
+ // errors. It is not part of the normal Step 1 -> Step 2 flow. pyvesync does not send a Step 1
343
+ // bizToken in the initial Step 2 request (Step 1 result is just `{ accountID, authorizeCode }`);
344
+ // it only includes `bizToken` + `regionChange='lastRegion'`
345
+ // when retrying Step 2 after a cross-region response. See `pyvesync/auth.py:_exchange_authorization_code`.
344
346
  const body = {
345
347
  'method': 'loginByAuthorizeCode4Vesync',
346
348
  'authorizeCode': authorizeCode,
347
349
  'acceptLanguage': 'en',
348
- 'clientInfo': exports.CLIENT_INFO,
350
+ 'accountID': '',
351
+ 'clientInfo': exports.PHONE_BRAND,
349
352
  'clientType': 'vesyncApp',
350
353
  'clientVersion': exports.CLIENT_VERSION,
351
354
  'debugMode': false,
@@ -353,8 +356,9 @@ class Helpers {
353
356
  'osInfo': exports.PHONE_OS,
354
357
  'terminalId': terminalId,
355
358
  'timeZone': exports.DEFAULT_TZ,
356
- 'userCountryCode': userCountryCode || 'US',
357
- 'traceId': `APP${appId}${Math.floor(Date.now() / 1000)}`
359
+ 'token': '',
360
+ 'userCountryCode': (userCountryCode || exports.DEFAULT_REGION).trim().toUpperCase(),
361
+ 'traceId': generateAuthTraceId(terminalId)
358
362
  };
359
363
  // Only include bizToken if it's not null
360
364
  if (bizToken) {
@@ -433,13 +437,18 @@ class Helpers {
433
437
  * Call VeSync API
434
438
  */
435
439
  static async callApi(endpoint, method, data = null, headers = {}, manager) {
440
+ let url = '';
436
441
  try {
442
+ // Prefer manager-scoped base URL (pyvesync resolves per-manager from current region).
443
+ // Keep the module-level base URL as a fallback for older callers.
444
+ let baseUrl = manager.apiBaseUrl || _apiBaseUrl;
437
445
  // Ensure API base URL is properly set
438
- if (!_apiBaseUrl || _apiBaseUrl === 'undefined') {
439
- logger_1.logger.error('API base URL is not properly configured. Setting to default US endpoint...');
440
- setApiBaseUrl('https://smartapi.vesync.com');
446
+ if (!baseUrl || baseUrl === 'undefined') {
447
+ logger_1.logger.error('API base URL is not properly configured. Falling back to US endpoint...');
448
+ baseUrl = 'https://smartapi.vesync.com';
449
+ manager.apiBaseUrl = baseUrl;
441
450
  }
442
- const url = _apiBaseUrl + endpoint;
451
+ url = baseUrl + endpoint;
443
452
  logger_1.logger.debug(`Making API call to: ${url}`);
444
453
  const response = await (0, axios_1.default)({
445
454
  method,
@@ -473,14 +482,14 @@ class Helpers {
473
482
  status: error.response.status,
474
483
  code: responseData === null || responseData === void 0 ? void 0 : responseData.code,
475
484
  message: responseData === null || responseData === void 0 ? void 0 : responseData.msg,
476
- url: _apiBaseUrl + endpoint
485
+ url
477
486
  });
478
487
  return [responseData, error.response.status];
479
488
  }
480
489
  logger_1.logger.error('API call failed:', {
481
490
  code: error.code,
482
491
  message: error.message,
483
- url: _apiBaseUrl + endpoint
492
+ url: url || (manager.apiBaseUrl || _apiBaseUrl || '') + endpoint
484
493
  });
485
494
  return [null, 0];
486
495
  }
@@ -549,26 +558,36 @@ class Helpers {
549
558
  * Perform new two-step authentication flow
550
559
  */
551
560
  static async authNewFlow(manager, appId, region = 'US', countryCodeOverride) {
561
+ var _a, _b, _c;
552
562
  try {
553
563
  // Generate terminal ID to be used in both steps
554
564
  const terminalId = generateTerminalId();
565
+ // pyvesync parity:
566
+ // - `userCountryCode` defaults to `DEFAULT_REGION` ("US"), even if we first try the EU base URL.
567
+ // - If the account truly belongs to another region, the Step 2 response includes the correct
568
+ // `countryCode`/`currentRegion` and a `bizToken` to retry Step 2 (handled below).
569
+ // Avoid "guessing" a country code from `region` or email heuristics here; it diverges from pyvesync.
570
+ const userCountryCode = (countryCodeOverride || exports.DEFAULT_REGION).trim().toUpperCase();
555
571
  // Step 1: Get authorization code
556
- const step1Body = this.reqBodyAuthStep1(manager, appId, terminalId, region);
572
+ const step1Body = this.reqBodyAuthStep1(manager, appId, terminalId, userCountryCode);
557
573
  const step1Headers = this.reqHeaderAuth();
558
574
  // Set the correct regional endpoint
559
- const originalUrl = getApiBaseUrl();
560
- if (region in exports.REGION_ENDPOINTS) {
561
- setApiBaseUrl(exports.REGION_ENDPOINTS[region]);
575
+ const originalUrl = manager.apiBaseUrl || getApiBaseUrl();
576
+ const knownBaseUrls = new Set(Object.values(exports.REGION_ENDPOINTS));
577
+ const hasCustomBaseUrlOverride = !!manager.apiUrlOverride || !knownBaseUrls.has(originalUrl);
578
+ if (!hasCustomBaseUrlOverride && region in exports.REGION_ENDPOINTS) {
579
+ const nextUrl = exports.REGION_ENDPOINTS[region];
580
+ manager.apiBaseUrl = nextUrl;
562
581
  }
563
582
  logger_1.logger.debug('Step 1: Getting authorization code...', {
564
583
  region,
565
- endpoint: getApiBaseUrl(),
584
+ endpoint: manager.apiBaseUrl || getApiBaseUrl(),
566
585
  body: { ...step1Body, password: '[REDACTED]' }
567
586
  });
568
587
  const [authResponse, authStatus] = await this.callApi('/globalPlatform/api/accountAuth/v1/authByPWDOrOTM', 'post', step1Body, step1Headers, manager);
569
588
  if (!authResponse || authStatus !== 200) {
570
589
  logger_1.logger.error('Step 1 failed:', authResponse);
571
- setApiBaseUrl(originalUrl); // Restore original URL
590
+ manager.apiBaseUrl = originalUrl;
572
591
  return [false, null, null, null];
573
592
  }
574
593
  if (authResponse.code !== 0) {
@@ -576,30 +595,43 @@ class Helpers {
576
595
  // Check if it's a credential error (no point retrying)
577
596
  if ((0, constants_1.isCredentialError)(authResponse.code)) {
578
597
  logger_1.logger.error('Credential error detected - invalid username or password');
579
- setApiBaseUrl(originalUrl); // Restore original URL
598
+ manager.apiBaseUrl = originalUrl;
580
599
  return [false, null, null, 'credential_error'];
581
600
  }
582
601
  // Handle cross-region error (multiple error codes possible)
583
602
  if ((0, constants_1.isCrossRegionError)(authResponse.code)) {
584
603
  logger_1.logger.debug('Cross-region error detected:', authResponse.code, 'will try different region');
585
- setApiBaseUrl(originalUrl); // Restore original URL
604
+ manager.apiBaseUrl = originalUrl;
586
605
  return [false, null, null, 'cross_region'];
587
606
  }
588
- setApiBaseUrl(originalUrl); // Restore original URL
607
+ manager.apiBaseUrl = originalUrl;
589
608
  return [false, null, null, null];
590
609
  }
591
- const { authorizeCode, bizToken } = authResponse.result || {};
610
+ // pyvesync parity note:
611
+ // - Step 1 "authByPWDOrOTM" returns a result shaped like RespGetTokenResultModel:
612
+ // `{ accountID, authorizeCode }`. There is no Step 1 bizToken to forward.
613
+ // See `pyvesync/models/vesync_models.py:RespGetTokenResultModel`.
614
+ const { authorizeCode } = authResponse.result || {};
592
615
  if (!authorizeCode) {
593
616
  logger_1.logger.error('Missing authorization code in step 1 response:', authResponse.result);
594
- setApiBaseUrl(originalUrl); // Restore original URL
617
+ manager.apiBaseUrl = originalUrl;
595
618
  return [false, null, null, null];
596
619
  }
597
620
  logger_1.logger.debug('Step 1 successful, got authorization code');
598
621
  // Step 2: Login with authorization code
599
622
  // Use the override if provided, otherwise default to 'US'
600
623
  // Users should specify their actual country code in the configuration
601
- const countryCodeForStep2 = countryCodeOverride || 'US';
602
- const step2Body = this.reqBodyAuthStep2(authorizeCode, bizToken, appId, terminalId, countryCodeForStep2);
624
+ const countryCodeForStep2 = userCountryCode;
625
+ // pyvesync parity note:
626
+ // - Step 1 returns `authorizeCode` (and an `accountID`) but does not require/provide a bizToken for
627
+ // the initial Step 2 request.
628
+ // - Step 2 initial request omits `bizToken`.
629
+ // - Only if Step 2 returns a CROSS_REGION error does VeSync provide `result.bizToken`, which is then
630
+ // echoed back as `bizToken` along with `regionChange='lastRegion'` for a Step 2 retry.
631
+ // See `pyvesync/auth.py:_exchange_authorization_code` for the canonical behavior.
632
+ // - Step 2 also intentionally omits appID/sourceAppID (pyvesync parity); we still pass `appId` through
633
+ // to keep the function signature stable/historical.
634
+ const step2Body = this.reqBodyAuthStep2(authorizeCode, null, appId, terminalId, countryCodeForStep2);
603
635
  logger_1.logger.debug('Step 2: Logging in with authorization code...');
604
636
  const [loginResponse, loginStatus] = await this.callApi('/user/api/accountManage/v1/loginByAuthorizeCode4Vesync', 'post', step2Body, step1Headers, manager);
605
637
  logger_1.logger.debug('Step 2 response:', {
@@ -610,38 +642,60 @@ class Helpers {
610
642
  });
611
643
  if (!loginResponse || loginStatus !== 200) {
612
644
  logger_1.logger.error('Step 2 failed:', loginResponse);
613
- setApiBaseUrl(originalUrl); // Restore original URL on failure
645
+ manager.apiBaseUrl = originalUrl;
614
646
  return [false, null, null, null];
615
647
  }
616
648
  if (loginResponse.code !== 0) {
617
649
  logger_1.logger.debug('Step 2 error, code:', loginResponse.code, 'checking if cross-region...');
618
- // Handle cross-region error - for EU accounts, we need to switch regions entirely
619
- if (constants_1.CROSS_REGION_ERROR_CODES.includes(loginResponse.code)) {
620
- // Log helpful message about country code mismatch
621
- logger_1.logger.warn('═══════════════════════════════════════════════════════════════');
622
- logger_1.logger.warn('COUNTRY CODE MISMATCH DETECTED');
623
- logger_1.logger.warn(`Your account expects a different country code than '${countryCodeForStep2}'`);
624
- logger_1.logger.warn('This usually means:');
625
- logger_1.logger.warn(' 1. You need to set the correct country code in your config');
626
- logger_1.logger.warn(' 2. Your account was created in a different country/region');
627
- logger_1.logger.warn('');
628
- logger_1.logger.warn('To fix this:');
629
- logger_1.logger.warn(' - In Homebridge UI: Select your correct country from the dropdown');
630
- logger_1.logger.warn(' - In config.json: Add "countryCode": "YOUR_COUNTRY_CODE"');
631
- logger_1.logger.warn('');
632
- logger_1.logger.warn('Common country codes: US, CA, GB, DE, FR, AU, NZ, JP');
633
- logger_1.logger.warn('═══════════════════════════════════════════════════════════════');
634
- logger_1.logger.debug('Cross-region error detected in Step 2 - will try different endpoint');
635
- // For cross-region errors, we need to retry the entire flow with a different region
636
- // The authorization code from Step 1 is tied to the endpoint it was obtained from
637
- setApiBaseUrl(originalUrl); // Restore original URL
650
+ // Align with pyvesync: on cross-region errors, retry Step 2 using the server-provided
651
+ // country/region and bizToken + regionChange.
652
+ if ((0, constants_1.isCrossRegionError)(loginResponse.code)) {
653
+ const serverCountryCode = (_a = loginResponse.result) === null || _a === void 0 ? void 0 : _a.countryCode;
654
+ const serverRegion = (_b = loginResponse.result) === null || _b === void 0 ? void 0 : _b.currentRegion;
655
+ const regionChangeToken = (_c = loginResponse.result) === null || _c === void 0 ? void 0 : _c.bizToken;
656
+ logger_1.logger.debug('Cross-region error detected in Step 2; attempting pyvesync-style retry', {
657
+ requestedRegion: region,
658
+ requestedCountryCode: countryCodeForStep2,
659
+ serverCountryCode,
660
+ serverRegion,
661
+ hasRegionChangeToken: !!regionChangeToken,
662
+ });
663
+ // If VeSync tells us which region to use, switch to it for subsequent requests.
664
+ if (!hasCustomBaseUrlOverride && serverRegion && serverRegion in exports.REGION_ENDPOINTS) {
665
+ manager.region = serverRegion;
666
+ logger_1.logger.debug(`Updated region to ${serverRegion} for Step 2 retry`);
667
+ }
668
+ // If VeSync gives us a region-change token, retry Step 2 without redoing Step 1.
669
+ if (regionChangeToken) {
670
+ const retryCountryCode = serverCountryCode || countryCodeForStep2;
671
+ const retryBody = this.reqBodyAuthStep2(authorizeCode, regionChangeToken, appId, terminalId, retryCountryCode, 'lastRegion');
672
+ const [retryResponse, retryStatus] = await this.callApi('/user/api/accountManage/v1/loginByAuthorizeCode4Vesync', 'post', retryBody, step1Headers, manager);
673
+ if (retryResponse && retryStatus === 200 && retryResponse.code === 0) {
674
+ const { token, accountID, countryCode } = retryResponse.result || {};
675
+ if (token && accountID) {
676
+ logger_1.logger.debug('Step 2 retry successful, got token and accountID');
677
+ return [true, token, accountID, countryCode || retryCountryCode];
678
+ }
679
+ logger_1.logger.error('Missing required fields in Step 2 retry response:', retryResponse.result);
680
+ manager.apiBaseUrl = originalUrl;
681
+ return [false, null, null, null];
682
+ }
683
+ logger_1.logger.error('Step 2 retry failed:', {
684
+ status: retryStatus,
685
+ code: retryResponse === null || retryResponse === void 0 ? void 0 : retryResponse.code,
686
+ msg: retryResponse === null || retryResponse === void 0 ? void 0 : retryResponse.msg,
687
+ });
688
+ manager.apiBaseUrl = originalUrl;
689
+ return [false, null, null, 'cross_region_retry'];
690
+ }
691
+ logger_1.logger.error('Cross-region error returned no region-change token; cannot retry Step 2');
692
+ manager.apiBaseUrl = originalUrl;
638
693
  return [false, null, null, 'cross_region_retry'];
639
694
  }
640
695
  logger_1.logger.error('Step 2 error code:', loginResponse.code, 'message:', loginResponse.msg);
641
- setApiBaseUrl(originalUrl); // Restore original URL on error
696
+ manager.apiBaseUrl = originalUrl;
642
697
  return [false, null, null, null];
643
698
  }
644
- setApiBaseUrl(originalUrl); // Restore original URL on success
645
699
  const { token, accountID, countryCode } = loginResponse.result || {};
646
700
  if (!token || !accountID) {
647
701
  logger_1.logger.error('Missing required fields in step 2 response:', loginResponse.result);
@@ -662,7 +716,7 @@ class Helpers {
662
716
  try {
663
717
  const body = this.reqBody(manager, 'login');
664
718
  logger_1.logger.debug('Legacy login attempt...', {
665
- endpoint: getApiBaseUrl(),
719
+ endpoint: manager.apiBaseUrl || getApiBaseUrl(),
666
720
  body: { ...body, password: '[REDACTED]' }
667
721
  });
668
722
  const [response, status] = await this.callApi('/cloud/v1/user/login', 'post', body, {}, manager);
@@ -29,6 +29,7 @@ export declare class VeSync {
29
29
  private _appId;
30
30
  private _region;
31
31
  private _countryCodeOverride;
32
+ private _apiUrlOverride;
32
33
  private _sessionStore?;
33
34
  private _onTokenChange?;
34
35
  private _loginPromise;
@@ -99,6 +100,10 @@ export declare class VeSync {
99
100
  * Get current region
100
101
  */
101
102
  get region(): string;
103
+ /**
104
+ * Return the configured API URL override, if any.
105
+ */
106
+ get apiUrlOverride(): string | null;
102
107
  /**
103
108
  * Set region and update API endpoint
104
109
  */