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 +15 -34
- package/dist/lib/constants.d.ts +5 -0
- package/dist/lib/constants.js +13 -1
- package/dist/lib/helpers.d.ts +4 -3
- package/dist/lib/helpers.js +151 -97
- package/dist/lib/vesync.d.ts +5 -0
- package/dist/lib/vesync.js +173 -45
- package/package.json +1 -1
- package/src/lib/constants.ts +14 -1
- package/src/lib/helpers.ts +178 -108
- package/src/lib/vesync.ts +201 -51
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,
|
|
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
|
-
|
|
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
|
|
298
|
-
|
|
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
|
|
323
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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 (
|
|
563
|
-
logger.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
|
-
|
|
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
|
|
663
|
-
|
|
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 =
|
|
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 ${
|
|
696
|
-
|
|
697
|
-
|
|
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 =
|
|
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:
|
|
918
|
+
endpoint: this.apiBaseUrl
|
|
769
919
|
});
|
|
770
920
|
return true;
|
|
771
921
|
}
|