tsvesync 1.4.1 → 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.
@@ -11,7 +11,20 @@ const vesyncOutletImpl_1 = require("./vesyncOutletImpl");
11
11
  const vesyncSwitchImpl_1 = require("./vesyncSwitchImpl");
12
12
  const vesyncBulbImpl_1 = require("./vesyncBulbImpl");
13
13
  const logger_1 = require("./logger");
14
+ const constants_1 = require("./constants");
14
15
  const DEFAULT_ENERGY_UPDATE_INTERVAL = 21600;
16
+ function normalizeApiBaseUrl(apiBaseUrl) {
17
+ return apiBaseUrl.trim().replace(/\/+$/, '');
18
+ }
19
+ const KNOWN_API_BASE_URLS = new Set(Object.values(helpers_1.REGION_ENDPOINTS).map(normalizeApiBaseUrl));
20
+ function inferRegionFromApiBaseUrl(apiBaseUrl) {
21
+ const normalized = normalizeApiBaseUrl(apiBaseUrl).toLowerCase();
22
+ if (normalized.includes('vesync.eu'))
23
+ return 'EU';
24
+ if (normalized.includes('vesync.com'))
25
+ return 'US';
26
+ return null;
27
+ }
15
28
  /**
16
29
  * Create device instance based on type
17
30
  */
@@ -157,6 +170,7 @@ class VeSync {
157
170
  this._region = 'US';
158
171
  this._countryCodeOverride = null;
159
172
  }
173
+ this._apiUrlOverride = apiUrl || null;
160
174
  this._energyUpdateInterval = DEFAULT_ENERGY_UPDATE_INTERVAL;
161
175
  this._energyCheck = true;
162
176
  this._lastUpdateTs = null;
@@ -198,7 +212,7 @@ class VeSync {
198
212
  }
199
213
  // Set custom API URL if provided, otherwise use region-based endpoint
200
214
  if (apiUrl) {
201
- (0, helpers_1.setApiBaseUrl)(apiUrl);
215
+ this.apiBaseUrl = apiUrl;
202
216
  }
203
217
  else {
204
218
  // If country code is provided, use it to determine the endpoint
@@ -208,16 +222,8 @@ class VeSync {
208
222
  logger_1.logger.debug(`Country code ${this._countryCodeOverride} maps to ${endpointRegion} endpoint`);
209
223
  }
210
224
  // Set endpoint based on region
211
- const regionEndpoints = {
212
- 'US': 'https://smartapi.vesync.com',
213
- 'EU': 'https://smartapi.vesync.eu',
214
- 'CA': 'https://smartapi.vesync.com',
215
- 'MX': 'https://smartapi.vesync.com',
216
- 'JP': 'https://smartapi.vesync.com'
217
- };
218
- const endpoint = regionEndpoints[this._region] || 'https://smartapi.vesync.com';
219
- (0, helpers_1.setApiBaseUrl)(endpoint);
220
- (0, helpers_1.setCurrentRegion)(this._region);
225
+ const endpoint = helpers_1.REGION_ENDPOINTS[this._region] || helpers_1.REGION_ENDPOINTS.US;
226
+ this.apiBaseUrl = endpoint;
221
227
  }
222
228
  // Set custom logger if provided
223
229
  if (customLogger) {
@@ -229,18 +235,56 @@ class VeSync {
229
235
  * Hydrate manager from a previously persisted session
230
236
  */
231
237
  hydrateSession(session) {
238
+ var _a;
232
239
  try {
233
- if (session.apiBaseUrl) {
234
- (0, helpers_1.setApiBaseUrl)(session.apiBaseUrl);
235
- this.apiBaseUrl = session.apiBaseUrl;
236
- }
237
- if (session.region) {
238
- this._region = session.region;
239
- (0, helpers_1.setCurrentRegion)(session.region);
240
+ if (!(session === null || session === void 0 ? void 0 : session.token) || !(session === null || session === void 0 ? void 0 : session.accountId)) {
241
+ throw new Error('Session missing token/accountId');
240
242
  }
243
+ // Always hydrate core credentials first.
241
244
  this.token = session.token;
242
245
  this.accountId = session.accountId;
243
- this.countryCode = session.countryCode || null;
246
+ this.countryCode = (_a = session.countryCode) !== null && _a !== void 0 ? _a : null;
247
+ this.authFlowUsed = session.authFlowUsed;
248
+ // pyvesync parity:
249
+ // pyvesync persists `current_region` and derives the API base URL per-request from that region.
250
+ // It does NOT treat a stored base URL as authoritative. For backwards compatibility with older
251
+ // sessions, we normalize the fields here:
252
+ // - Prefer stored `region` when present
253
+ // - Otherwise derive region from stored `countryCode` (same mapping as pyvesync)
254
+ // - Otherwise infer region from stored apiBaseUrl (best-effort)
255
+ const sessionBaseUrl = session.apiBaseUrl ? normalizeApiBaseUrl(session.apiBaseUrl) : null;
256
+ const sessionBaseUrlIsKnown = !!sessionBaseUrl && KNOWN_API_BASE_URLS.has(sessionBaseUrl);
257
+ // If the session contains a custom base URL (not a known regional URL) and the caller did not
258
+ // explicitly set an API override, treat it as an override so region switching doesn't clobber it.
259
+ if (!this._apiUrlOverride && sessionBaseUrl && !sessionBaseUrlIsKnown) {
260
+ this._apiUrlOverride = sessionBaseUrl;
261
+ }
262
+ // Apply base URL override if present (pyvesync API_BASE_URL-style override).
263
+ if (this._apiUrlOverride) {
264
+ this.apiBaseUrl = this._apiUrlOverride;
265
+ }
266
+ let hydratedRegion = null;
267
+ if (typeof session.region === 'string' && session.region in helpers_1.REGION_ENDPOINTS) {
268
+ hydratedRegion = session.region;
269
+ }
270
+ else if (typeof session.countryCode === 'string' && session.countryCode.trim()) {
271
+ hydratedRegion = (0, helpers_1.getRegionFromCountryCode)(session.countryCode);
272
+ }
273
+ else if (sessionBaseUrl) {
274
+ hydratedRegion = inferRegionFromApiBaseUrl(sessionBaseUrl);
275
+ }
276
+ if (hydratedRegion && hydratedRegion in helpers_1.REGION_ENDPOINTS) {
277
+ this._region = hydratedRegion;
278
+ }
279
+ // Derive the API base URL from region unless explicitly overridden.
280
+ if (!this._apiUrlOverride) {
281
+ const endpoint = helpers_1.REGION_ENDPOINTS[this._region] || helpers_1.REGION_ENDPOINTS.US;
282
+ this.apiBaseUrl = endpoint;
283
+ }
284
+ else if (!this.apiBaseUrl && sessionBaseUrl) {
285
+ // Fallback: keep the persisted base URL if we have no other source.
286
+ this.apiBaseUrl = sessionBaseUrl;
287
+ }
244
288
  this.enabled = true;
245
289
  }
246
290
  catch (e) {
@@ -312,13 +356,21 @@ class VeSync {
312
356
  get region() {
313
357
  return this._region;
314
358
  }
359
+ /**
360
+ * Return the configured API URL override, if any.
361
+ */
362
+ get apiUrlOverride() {
363
+ return this._apiUrlOverride;
364
+ }
315
365
  /**
316
366
  * Set region and update API endpoint
317
367
  */
318
368
  set region(region) {
319
369
  if (region in helpers_1.REGION_ENDPOINTS) {
320
370
  this._region = region;
321
- (0, helpers_1.setCurrentRegion)(region);
371
+ if (!this._apiUrlOverride) {
372
+ this.apiBaseUrl = helpers_1.REGION_ENDPOINTS[region];
373
+ }
322
374
  }
323
375
  }
324
376
  /**
@@ -437,7 +489,7 @@ class VeSync {
437
489
  * Get list of VeSync devices
438
490
  */
439
491
  async getDevices() {
440
- var _a, _b;
492
+ var _a, _b, _c;
441
493
  if (!this.enabled) {
442
494
  logger_1.logger.error('Not logged in to VeSync');
443
495
  return false;
@@ -445,16 +497,96 @@ class VeSync {
445
497
  this._inProcess = true;
446
498
  let success = false;
447
499
  try {
448
- const [response] = await helpers_1.Helpers.callApi('/cloud/v2/deviceManaged/devices', 'post', helpers_1.Helpers.reqBody(this, 'devicelist'), helpers_1.Helpers.reqHeaders(this), this);
500
+ const fetchDeviceList = async () => {
501
+ return await helpers_1.Helpers.callApi('/cloud/v1/deviceManaged/devices', 'post', helpers_1.Helpers.reqBody(this, 'devicelist'), helpers_1.Helpers.reqHeaderBypass(), this);
502
+ };
503
+ const originalRegion = this._region;
504
+ let didRetryToken = false;
505
+ let didRetryCrossRegion = false;
506
+ let response;
507
+ let status;
508
+ [response, status] = await fetchDeviceList();
509
+ // Handle VeSync API codes similarly to pyvesync:
510
+ // - TOKEN_ERROR triggers a reauth + retry
511
+ // - CROSS_REGION triggers a region correction + retry
512
+ //
513
+ // pyvesync reference:
514
+ // - Token error retry: `pyvesync/vesync.py:async_call_api` (ErrorTypes.TOKEN_ERROR)
515
+ // - Region correction: `pyvesync/auth.py:_exchange_authorization_code` (ErrorTypes.CROSS_REGION)
516
+ while (true) {
517
+ if (!response || status !== 200) {
518
+ break;
519
+ }
520
+ const code = response === null || response === void 0 ? void 0 : response.code;
521
+ if (typeof code === 'number' && code !== 0) {
522
+ if (!didRetryToken && (0, constants_1.isTokenError)(code)) {
523
+ didRetryToken = true;
524
+ logger_1.logger.warn('Device list request returned token error; re-authenticating and retrying (pyvesync parity)', {
525
+ code,
526
+ msg: response === null || response === void 0 ? void 0 : response.msg,
527
+ region: this._region,
528
+ endpoint: this.apiBaseUrl
529
+ });
530
+ const reloginOk = await this.login();
531
+ if (!reloginOk) {
532
+ this.enabled = false;
533
+ return false;
534
+ }
535
+ [response, status] = await fetchDeviceList();
536
+ continue;
537
+ }
538
+ if (!didRetryCrossRegion && (0, constants_1.isCrossRegionError)(code) && !this.apiUrlOverride) {
539
+ didRetryCrossRegion = true;
540
+ const serverRegion = (_a = response === null || response === void 0 ? void 0 : response.result) === null || _a === void 0 ? void 0 : _a.currentRegion;
541
+ const normalizedServerRegion = typeof serverRegion === 'string' ? serverRegion.trim().toUpperCase() : null;
542
+ const nextRegion = normalizedServerRegion && normalizedServerRegion in helpers_1.REGION_ENDPOINTS
543
+ ? normalizedServerRegion
544
+ : (this._region === 'US' ? 'EU' : 'US');
545
+ logger_1.logger.warn('Device list request returned cross-region error; retrying against corrected endpoint (pyvesync-style)', {
546
+ code,
547
+ msg: response === null || response === void 0 ? void 0 : response.msg,
548
+ region: this._region,
549
+ endpoint: this.apiBaseUrl,
550
+ serverRegion: normalizedServerRegion,
551
+ nextRegion
552
+ });
553
+ this.region = nextRegion;
554
+ [response, status] = await fetchDeviceList();
555
+ // If this was a best-effort alternate-region guess (no serverRegion provided) and it
556
+ // didn't help, revert so we don't leave the manager stuck on the wrong endpoint.
557
+ if (!normalizedServerRegion && (!response || response.code !== 0)) {
558
+ this.region = originalRegion;
559
+ }
560
+ continue;
561
+ }
562
+ }
563
+ break;
564
+ }
449
565
  if (!response) {
450
566
  logger_1.logger.error('No response received from VeSync API');
451
567
  return false;
452
568
  }
453
- if (response.error) {
454
- logger_1.logger.error('API error:', response.msg || 'Unknown error');
569
+ if (status !== 200) {
570
+ logger_1.logger.error('Device list request failed with non-200 status:', {
571
+ status,
572
+ code: response === null || response === void 0 ? void 0 : response.code,
573
+ msg: response === null || response === void 0 ? void 0 : response.msg,
574
+ region: this._region,
575
+ endpoint: this.apiBaseUrl
576
+ });
577
+ return false;
578
+ }
579
+ if (typeof response.code === 'number' && response.code !== 0) {
580
+ logger_1.logger.error('Device list request returned error code:', {
581
+ status,
582
+ code: response.code,
583
+ msg: response === null || response === void 0 ? void 0 : response.msg,
584
+ region: this._region,
585
+ endpoint: this.apiBaseUrl
586
+ });
455
587
  return false;
456
588
  }
457
- if (!((_a = response.result) === null || _a === void 0 ? void 0 : _a.list)) {
589
+ if (!((_b = response.result) === null || _b === void 0 ? void 0 : _b.list)) {
458
590
  logger_1.logger.error('No device list found in response');
459
591
  return false;
460
592
  }
@@ -484,7 +616,7 @@ class VeSync {
484
616
  // Log summary statistics
485
617
  logger_1.logger.debug('\nSummary Statistics:');
486
618
  logger_1.logger.debug('-------------------');
487
- logger_1.logger.debug(`Total Devices: ${((_b = this.devices) === null || _b === void 0 ? void 0 : _b.length) || 0}`);
619
+ logger_1.logger.debug(`Total Devices: ${((_c = this.devices) === null || _c === void 0 ? void 0 : _c.length) || 0}`);
488
620
  for (const [category, devices] of Object.entries(this._devList)) {
489
621
  logger_1.logger.debug(`${category}: ${devices.length} devices`);
490
622
  }
@@ -524,8 +656,12 @@ class VeSync {
524
656
  for (let attempt = 0; attempt < retryAttempts; attempt++) {
525
657
  try {
526
658
  logger_1.logger.debug(`Authentication attempt ${attempt + 1} of ${retryAttempts}`);
527
- // Try new authentication flow first with detected region
528
- let [success, token, accountId, countryCode] = await helpers_1.Helpers.authNewFlow(this, this._appId, this._region, this._countryCodeOverride || undefined);
659
+ // Try new authentication flow first with detected region.
660
+ // NOTE: authNewFlow may temporarily change `this.region` during pyvesync-style
661
+ // cross-region Step 2 retry. Capture the attempted region so our fallback logic
662
+ // (try opposite endpoint) doesn't get confused by that internal mutation.
663
+ const attemptedRegion = this._region;
664
+ let [success, token, accountId, countryCode] = await helpers_1.Helpers.authNewFlow(this, this._appId, attemptedRegion, this._countryCodeOverride || undefined);
529
665
  // Track if new flow succeeded
530
666
  if (success) {
531
667
  this.authFlowUsed = 'new';
@@ -538,10 +674,11 @@ class VeSync {
538
674
  }
539
675
  // Check if we need to try a different region
540
676
  if (countryCode === 'cross_region' || countryCode === 'cross_region_retry') {
541
- // Mark current region as tried
542
- triedRegions.add(this._region);
677
+ // Mark attempted region as tried (do not rely on `this._region`, which may have been
678
+ // mutated by authNewFlow during pyvesync-style cross-region handling).
679
+ triedRegions.add(attemptedRegion);
543
680
  logger_1.logger.debug('Cross-region error detected, trying opposite region...');
544
- const alternateRegion = this._region === 'US' ? 'EU' : 'US';
681
+ const alternateRegion = attemptedRegion === 'US' ? 'EU' : 'US';
545
682
  // Check if we've already tried this region to prevent infinite loops
546
683
  if (triedRegions.has(alternateRegion)) {
547
684
  logger_1.logger.error('═══════════════════════════════════════════════════════════════');
@@ -568,18 +705,9 @@ class VeSync {
568
705
  logger_1.logger.error('═══════════════════════════════════════════════════════════════');
569
706
  return false;
570
707
  }
571
- logger_1.logger.debug(`Switching from ${this._region} to ${alternateRegion} region`);
572
- this._region = alternateRegion;
573
- (0, helpers_1.setCurrentRegion)(alternateRegion);
574
- // Update the API endpoint for the new region
575
- const regionEndpoints = {
576
- 'US': 'https://smartapi.vesync.com',
577
- 'EU': 'https://smartapi.vesync.eu'
578
- };
579
- if (alternateRegion in regionEndpoints) {
580
- (0, helpers_1.setApiBaseUrl)(regionEndpoints[alternateRegion]);
581
- logger_1.logger.debug(`API endpoint set to ${regionEndpoints[alternateRegion]}`);
582
- }
708
+ logger_1.logger.debug(`Switching from ${attemptedRegion} to ${alternateRegion} region`);
709
+ this.region = alternateRegion;
710
+ logger_1.logger.debug(`API endpoint set to ${this.apiBaseUrl}`);
583
711
  // Retry with the alternate region
584
712
  [success, token, accountId, countryCode] = await helpers_1.Helpers.authNewFlow(this, this._appId, alternateRegion, this._countryCodeOverride || undefined);
585
713
  if (success) {
@@ -614,7 +742,7 @@ class VeSync {
614
742
  this.accountId = accountId;
615
743
  this.countryCode = countryCode;
616
744
  this.enabled = true;
617
- this.apiBaseUrl = (0, helpers_1.getApiBaseUrl)(); // Track the API endpoint being used
745
+ this.apiBaseUrl = this.apiBaseUrl || 'https://smartapi.vesync.com'; // Track the API endpoint being used
618
746
  // Persist session details for reuse across restarts
619
747
  this.emitTokenChange();
620
748
  // DO NOT change the region/endpoint after successful authentication!
@@ -634,7 +762,7 @@ class VeSync {
634
762
  authFlow: this.authFlowUsed,
635
763
  region: this._region,
636
764
  countryCode: countryCode,
637
- endpoint: (0, helpers_1.getApiBaseUrl)()
765
+ endpoint: this.apiBaseUrl
638
766
  });
639
767
  return true;
640
768
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsvesync",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "A TypeScript library for interacting with VeSync smart home devices",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -15,6 +15,12 @@ export const CROSS_REGION_ERROR_CODES = [
15
15
  -11261022, // access region conflict error
16
16
  ];
17
17
 
18
+ // Token/auth invalidation error codes
19
+ // pyvesync parity: `-11001000` maps to TOKEN_EXPIRED (invalid token)
20
+ export const TOKEN_ERROR_CODES = [
21
+ -11001000, // token expired/invalid
22
+ ];
23
+
18
24
  // Other error codes
19
25
  export const APP_VERSION_ERROR_CODES = [
20
26
  -11012022, // app version is too low
@@ -32,4 +38,11 @@ export function isCredentialError(code: number): boolean {
32
38
  */
33
39
  export function isCrossRegionError(code: number): boolean {
34
40
  return CROSS_REGION_ERROR_CODES.includes(code);
35
- }
41
+ }
42
+
43
+ /**
44
+ * Check if an error code indicates the token is invalid/expired.
45
+ */
46
+ export function isTokenError(code: number): boolean {
47
+ return TOKEN_ERROR_CODES.includes(code);
48
+ }