tsvesync 1.1.2 → 1.2.2

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/dist/index.d.ts CHANGED
@@ -6,3 +6,4 @@ export { VeSyncWallSwitch } from './lib/vesyncSwitchImpl';
6
6
  export { VeSyncBaseDevice } from './lib/vesyncBaseDevice';
7
7
  export { Helpers, setApiBaseUrl } from './lib/helpers';
8
8
  export { CREDENTIAL_ERROR_CODES, CROSS_REGION_ERROR_CODES, isCredentialError, isCrossRegionError } from './lib/constants';
9
+ export { Session, SessionStore, decodeJwtTimestamps } from './lib/session';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isCrossRegionError = exports.isCredentialError = exports.CROSS_REGION_ERROR_CODES = exports.CREDENTIAL_ERROR_CODES = exports.setApiBaseUrl = exports.Helpers = exports.VeSyncBaseDevice = exports.VeSyncWallSwitch = exports.VeSyncBulb = exports.VeSyncOutdoorPlug = exports.VeSyncOutlet15A = exports.VeSyncOutlet10A = exports.VeSyncOutlet7A = exports.VeSyncAirBypass = exports.VeSync = void 0;
3
+ exports.decodeJwtTimestamps = exports.isCrossRegionError = exports.isCredentialError = exports.CROSS_REGION_ERROR_CODES = exports.CREDENTIAL_ERROR_CODES = exports.setApiBaseUrl = exports.Helpers = exports.VeSyncBaseDevice = exports.VeSyncWallSwitch = exports.VeSyncBulb = exports.VeSyncOutdoorPlug = exports.VeSyncOutlet15A = exports.VeSyncOutlet10A = exports.VeSyncOutlet7A = exports.VeSyncAirBypass = exports.VeSync = void 0;
4
4
  // Export main VeSync manager class
5
5
  var vesync_1 = require("./lib/vesync");
6
6
  Object.defineProperty(exports, "VeSync", { enumerable: true, get: function () { return vesync_1.VeSync; } });
@@ -29,3 +29,6 @@ Object.defineProperty(exports, "CREDENTIAL_ERROR_CODES", { enumerable: true, get
29
29
  Object.defineProperty(exports, "CROSS_REGION_ERROR_CODES", { enumerable: true, get: function () { return constants_1.CROSS_REGION_ERROR_CODES; } });
30
30
  Object.defineProperty(exports, "isCredentialError", { enumerable: true, get: function () { return constants_1.isCredentialError; } });
31
31
  Object.defineProperty(exports, "isCrossRegionError", { enumerable: true, get: function () { return constants_1.isCrossRegionError; } });
32
+ // Export session types and utilities
33
+ var session_1 = require("./lib/session");
34
+ Object.defineProperty(exports, "decodeJwtTimestamps", { enumerable: true, get: function () { return session_1.decodeJwtTimestamps; } });
@@ -400,8 +400,14 @@ class Helpers {
400
400
  catch (error) {
401
401
  if (error.response) {
402
402
  const responseData = error.response.data;
403
- // Check for token expiration
404
- if ((responseData === null || responseData === void 0 ? void 0 : responseData.code) === 4001004 || (responseData === null || responseData === void 0 ? void 0 : responseData.msg) === "token expired") {
403
+ // Check for token expiration or auth invalidation
404
+ const httpStatus = error.response.status;
405
+ const msg = responseData === null || responseData === void 0 ? void 0 : responseData.msg;
406
+ if ((responseData === null || responseData === void 0 ? void 0 : responseData.code) === 4001004 ||
407
+ msg === 'token expired' ||
408
+ httpStatus === 401 ||
409
+ httpStatus === 419 ||
410
+ (typeof msg === 'string' && /token|login/i.test(msg))) {
405
411
  logger_1.logger.debug('Token expired, attempting to re-login...');
406
412
  // Re-login
407
413
  if (await manager.login()) {
@@ -0,0 +1,24 @@
1
+ export interface Session {
2
+ token: string;
3
+ accountId: string;
4
+ countryCode?: string | null;
5
+ region: string;
6
+ apiBaseUrl: string;
7
+ authFlowUsed?: 'legacy' | 'new';
8
+ issuedAt?: number | null;
9
+ expiresAt?: number | null;
10
+ lastValidatedAt?: number | null;
11
+ libraryVersion?: string;
12
+ }
13
+ export interface SessionStore {
14
+ load(): Promise<Session | null>;
15
+ save(session: Session): Promise<void>;
16
+ clear(): Promise<void>;
17
+ }
18
+ /**
19
+ * Decode a JWT token payload to extract iat/exp without verifying the signature.
20
+ */
21
+ export declare function decodeJwtTimestamps(token: string): {
22
+ iat?: number;
23
+ exp?: number;
24
+ } | null;
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.decodeJwtTimestamps = decodeJwtTimestamps;
4
+ /**
5
+ * Decode a JWT token payload to extract iat/exp without verifying the signature.
6
+ */
7
+ function decodeJwtTimestamps(token) {
8
+ try {
9
+ const parts = token.split('.');
10
+ if (parts.length !== 3)
11
+ return null;
12
+ const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
13
+ const iat = typeof payload.iat === 'number' ? payload.iat : undefined;
14
+ const exp = typeof payload.exp === 'number' ? payload.exp : undefined;
15
+ return { iat, exp };
16
+ }
17
+ catch (_a) {
18
+ return null;
19
+ }
20
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * VeSync API Device Library
3
3
  */
4
+ import { Session, SessionStore } from './session';
4
5
  import { VeSyncBaseDevice } from './vesyncBaseDevice';
5
6
  import { Logger } from './logger';
6
7
  /**
@@ -28,6 +29,9 @@ export declare class VeSync {
28
29
  private _appId;
29
30
  private _region;
30
31
  private _countryCodeOverride;
32
+ private _sessionStore?;
33
+ private _onTokenChange?;
34
+ private _loginPromise;
31
35
  username: string;
32
36
  password: string;
33
37
  token: string | null;
@@ -64,7 +68,14 @@ export declare class VeSync {
64
68
  region?: string;
65
69
  countryCode?: string;
66
70
  debugMode?: boolean;
71
+ sessionStore?: SessionStore;
72
+ onTokenChange?: (session: Session) => void;
67
73
  }, redact?: boolean, apiUrl?: string, customLogger?: Logger, excludeConfig?: ExcludeConfig);
74
+ /**
75
+ * Hydrate manager from a previously persisted session
76
+ */
77
+ hydrateSession(session: Session): void;
78
+ private emitTokenChange;
68
79
  /**
69
80
  * Get/Set debug mode
70
81
  */
@@ -5,6 +5,7 @@
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.VeSync = void 0;
7
7
  const helpers_1 = require("./helpers");
8
+ const session_1 = require("./session");
8
9
  const vesyncFanImpl_1 = require("./vesyncFanImpl");
9
10
  const vesyncOutletImpl_1 = require("./vesyncOutletImpl");
10
11
  const vesyncSwitchImpl_1 = require("./vesyncSwitchImpl");
@@ -132,6 +133,7 @@ class VeSync {
132
133
  * @param excludeConfig - Optional device exclusion configuration (used when optionsOrDebug is boolean)
133
134
  */
134
135
  constructor(username, password, timeZone = helpers_1.DEFAULT_TZ, optionsOrDebug = false, redact = true, apiUrl, customLogger, excludeConfig) {
136
+ this._loginPromise = null;
135
137
  // Handle options object or backward compatible parameters
136
138
  let options = {};
137
139
  if (typeof optionsOrDebug === 'object' && optionsOrDebug !== null) {
@@ -144,6 +146,8 @@ class VeSync {
144
146
  this._countryCodeOverride = options.countryCode || null;
145
147
  customLogger = options.customLogger;
146
148
  apiUrl = options.apiUrl;
149
+ this._sessionStore = options.sessionStore;
150
+ this._onTokenChange = options.onTokenChange;
147
151
  }
148
152
  else {
149
153
  // Using old parameter pattern for backward compatibility
@@ -221,6 +225,51 @@ class VeSync {
221
225
  }
222
226
  // Debug and redact are already set above in the options handling
223
227
  }
228
+ /**
229
+ * Hydrate manager from a previously persisted session
230
+ */
231
+ hydrateSession(session) {
232
+ 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
+ }
241
+ this.token = session.token;
242
+ this.accountId = session.accountId;
243
+ this.countryCode = session.countryCode || null;
244
+ this.enabled = true;
245
+ }
246
+ catch (e) {
247
+ logger_1.logger.warn('Failed to hydrate session; will require fresh login');
248
+ }
249
+ }
250
+ emitTokenChange() {
251
+ var _a, _b, _c, _d;
252
+ if (!this.token || !this.accountId || !this.apiBaseUrl)
253
+ return;
254
+ const timestamps = (0, session_1.decodeJwtTimestamps)(this.token);
255
+ const session = {
256
+ token: this.token,
257
+ accountId: this.accountId,
258
+ countryCode: this.countryCode,
259
+ region: this._region,
260
+ apiBaseUrl: this.apiBaseUrl,
261
+ authFlowUsed: this.authFlowUsed,
262
+ issuedAt: (_a = timestamps === null || timestamps === void 0 ? void 0 : timestamps.iat) !== null && _a !== void 0 ? _a : null,
263
+ expiresAt: (_b = timestamps === null || timestamps === void 0 ? void 0 : timestamps.exp) !== null && _b !== void 0 ? _b : null,
264
+ lastValidatedAt: Date.now(),
265
+ };
266
+ // Best-effort persist and callback; errors are non-fatal
267
+ Promise.resolve((_c = this._sessionStore) === null || _c === void 0 ? void 0 : _c.save(session)).catch(() => { });
268
+ try {
269
+ (_d = this._onTokenChange) === null || _d === void 0 ? void 0 : _d.call(this, session);
270
+ }
271
+ catch ( /* noop */_e) { /* noop */ }
272
+ }
224
273
  /**
225
274
  * Get/Set debug mode
226
275
  */
@@ -461,148 +510,159 @@ class VeSync {
461
510
  * Login to VeSync server with new authentication flow and backwards compatibility
462
511
  */
463
512
  async login(retryAttempts = 3, initialDelayMs = 1000) {
464
- logger_1.logger.debug('Starting VeSync authentication...', {
465
- username: this.username,
466
- appId: this._appId,
467
- region: this._region
468
- });
469
- // Track which regions have been attempted to prevent infinite loops
470
- const triedRegions = new Set();
471
- for (let attempt = 0; attempt < retryAttempts; attempt++) {
472
- try {
473
- logger_1.logger.debug(`Authentication attempt ${attempt + 1} of ${retryAttempts}`);
474
- // Skip email-based region detection for now since we'll handle cross-region errors
475
- // The cross-region error handling below will automatically switch to the correct region
476
- // Try new authentication flow first with detected region
477
- let [success, token, accountId, countryCode] = await helpers_1.Helpers.authNewFlow(this, this._appId, this._region, this._countryCodeOverride || undefined);
478
- // Track if new flow succeeded
479
- if (success) {
480
- this.authFlowUsed = 'new';
481
- }
482
- if (!success) {
483
- // Check if it's a credential error (no point retrying)
484
- if (countryCode === 'credential_error') {
485
- logger_1.logger.error('Authentication failed due to invalid credentials');
486
- return false; // Exit immediately, don't retry
513
+ if (this._loginPromise) {
514
+ return this._loginPromise;
515
+ }
516
+ this._loginPromise = (async () => {
517
+ logger_1.logger.debug('Starting VeSync authentication...', {
518
+ username: this.username,
519
+ appId: this._appId,
520
+ region: this._region
521
+ });
522
+ // Track which regions have been attempted to prevent infinite loops
523
+ const triedRegions = new Set();
524
+ for (let attempt = 0; attempt < retryAttempts; attempt++) {
525
+ try {
526
+ 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);
529
+ // Track if new flow succeeded
530
+ if (success) {
531
+ this.authFlowUsed = 'new';
487
532
  }
488
- // Check if we need to try a different region
489
- if (countryCode === 'cross_region' || countryCode === 'cross_region_retry') {
490
- // Mark current region as tried
491
- triedRegions.add(this._region);
492
- logger_1.logger.debug('Cross-region error detected, trying opposite region...');
493
- const alternateRegion = this._region === 'US' ? 'EU' : 'US';
494
- // Check if we've already tried this region to prevent infinite loops
495
- if (triedRegions.has(alternateRegion)) {
496
- logger_1.logger.error('═══════════════════════════════════════════════════════════════');
497
- logger_1.logger.error('AUTHENTICATION FAILED: COUNTRY CODE REQUIRED');
498
- logger_1.logger.error('');
499
- logger_1.logger.error('Both US and EU endpoints rejected your account.');
500
- logger_1.logger.error('This means you MUST specify your country code.');
501
- logger_1.logger.error('');
502
- logger_1.logger.error('SOLUTION:');
503
- logger_1.logger.error('1. In Homebridge UI:');
504
- logger_1.logger.error(' - Go to plugin settings');
505
- logger_1.logger.error(' - Select your country from the "Country Code" dropdown');
506
- logger_1.logger.error('');
507
- logger_1.logger.error('2. In config.json:');
508
- logger_1.logger.error(' Add: "countryCode": "YOUR_COUNTRY_CODE"');
509
- logger_1.logger.error('');
510
- logger_1.logger.error('Common non-US/EU country codes:');
511
- logger_1.logger.error(' 🇦🇺 Australia: "AU"');
512
- logger_1.logger.error(' 🇳🇿 New Zealand: "NZ"');
513
- logger_1.logger.error(' 🇯🇵 Japan: "JP"');
514
- logger_1.logger.error(' 🇨🇦 Canada: "CA"');
515
- logger_1.logger.error(' 🇸🇬 Singapore: "SG"');
516
- logger_1.logger.error(' 🇲🇽 Mexico: "MX"');
517
- logger_1.logger.error('═══════════════════════════════════════════════════════════════');
518
- return false;
519
- }
520
- logger_1.logger.debug(`Switching from ${this._region} to ${alternateRegion} region`);
521
- this._region = alternateRegion;
522
- (0, helpers_1.setCurrentRegion)(alternateRegion);
523
- // Update the API endpoint for the new region
524
- const regionEndpoints = {
525
- 'US': 'https://smartapi.vesync.com',
526
- 'EU': 'https://smartapi.vesync.eu'
527
- };
528
- if (alternateRegion in regionEndpoints) {
529
- (0, helpers_1.setApiBaseUrl)(regionEndpoints[alternateRegion]);
530
- logger_1.logger.debug(`API endpoint set to ${regionEndpoints[alternateRegion]}`);
533
+ if (!success) {
534
+ // Check if it's a credential error (no point retrying)
535
+ if (countryCode === 'credential_error') {
536
+ logger_1.logger.error('Authentication failed due to invalid credentials');
537
+ return false; // Exit immediately, don't retry
531
538
  }
532
- // Retry with the alternate region
533
- [success, token, accountId, countryCode] = await helpers_1.Helpers.authNewFlow(this, this._appId, alternateRegion, this._countryCodeOverride || undefined);
534
- if (success) {
535
- this.authFlowUsed = 'new';
536
- logger_1.logger.debug(`Authentication successful with ${alternateRegion} region`);
539
+ // Check if we need to try a different region
540
+ if (countryCode === 'cross_region' || countryCode === 'cross_region_retry') {
541
+ // Mark current region as tried
542
+ triedRegions.add(this._region);
543
+ logger_1.logger.debug('Cross-region error detected, trying opposite region...');
544
+ const alternateRegion = this._region === 'US' ? 'EU' : 'US';
545
+ // Check if we've already tried this region to prevent infinite loops
546
+ if (triedRegions.has(alternateRegion)) {
547
+ logger_1.logger.error('═══════════════════════════════════════════════════════════════');
548
+ logger_1.logger.error('AUTHENTICATION FAILED: COUNTRY CODE REQUIRED');
549
+ logger_1.logger.error('');
550
+ logger_1.logger.error('Both US and EU endpoints rejected your account.');
551
+ logger_1.logger.error('This means you MUST specify your country code.');
552
+ logger_1.logger.error('');
553
+ logger_1.logger.error('SOLUTION:');
554
+ logger_1.logger.error('1. In Homebridge UI:');
555
+ logger_1.logger.error(' - Go to plugin settings');
556
+ logger_1.logger.error(' - Select your country from the "Country Code" dropdown');
557
+ logger_1.logger.error('');
558
+ logger_1.logger.error('2. In config.json:');
559
+ logger_1.logger.error(' Add: "countryCode": "YOUR_COUNTRY_CODE"');
560
+ logger_1.logger.error('');
561
+ logger_1.logger.error('Common non-US/EU country codes:');
562
+ logger_1.logger.error(' 🇦🇺 Australia: "AU"');
563
+ logger_1.logger.error(' 🇳🇿 New Zealand: "NZ"');
564
+ logger_1.logger.error(' 🇯🇵 Japan: "JP"');
565
+ logger_1.logger.error(' 🇨🇦 Canada: "CA"');
566
+ logger_1.logger.error(' 🇸🇬 Singapore: "SG"');
567
+ logger_1.logger.error(' 🇲🇽 Mexico: "MX"');
568
+ logger_1.logger.error('═══════════════════════════════════════════════════════════════');
569
+ return false;
570
+ }
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
+ }
583
+ // Retry with the alternate region
584
+ [success, token, accountId, countryCode] = await helpers_1.Helpers.authNewFlow(this, this._appId, alternateRegion, this._countryCodeOverride || undefined);
585
+ if (success) {
586
+ this.authFlowUsed = 'new';
587
+ logger_1.logger.debug(`Authentication successful with ${alternateRegion} region`);
588
+ }
589
+ else if (countryCode === 'cross_region' || countryCode === 'cross_region_retry') {
590
+ // Both regions failed with cross-region error
591
+ logger_1.logger.error('Authentication failed: Account rejected by both US and EU regions');
592
+ logger_1.logger.error('This may indicate your account was created in a region not yet supported');
593
+ logger_1.logger.error('Please contact VeSync support to determine your account region');
594
+ // Don't retry further attempts as we've exhausted both regions
595
+ return false;
596
+ }
537
597
  }
538
- else if (countryCode === 'cross_region' || countryCode === 'cross_region_retry') {
539
- // Both regions failed with cross-region error
540
- logger_1.logger.error('Authentication failed: Account rejected by both US and EU regions');
541
- logger_1.logger.error('This may indicate your account was created in a region not yet supported');
542
- logger_1.logger.error('Please contact VeSync support to determine your account region');
543
- // Don't retry further attempts as we've exhausted both regions
544
- return false;
598
+ // If new flow still fails, try legacy authentication as fallback
599
+ if (!success) {
600
+ logger_1.logger.debug('New authentication flow failed, trying legacy flow...');
601
+ [success, token, accountId, countryCode] = await helpers_1.Helpers.authLegacyFlow(this);
602
+ if (success) {
603
+ this.authFlowUsed = 'legacy';
604
+ }
605
+ else if (countryCode === 'credential_error') {
606
+ // Legacy auth also detected bad credentials
607
+ logger_1.logger.error('Both authentication methods report invalid credentials');
608
+ return false; // Exit immediately
609
+ }
545
610
  }
546
611
  }
547
- // If new flow still fails, try legacy authentication as fallback
548
- if (!success) {
549
- logger_1.logger.debug('New authentication flow failed, trying legacy flow...');
550
- [success, token, accountId, countryCode] = await helpers_1.Helpers.authLegacyFlow(this);
551
- if (success) {
552
- this.authFlowUsed = 'legacy';
553
- }
554
- else if (countryCode === 'credential_error') {
555
- // Legacy auth also detected bad credentials
556
- logger_1.logger.error('Both authentication methods report invalid credentials');
557
- return false; // Exit immediately
612
+ if (success && token && accountId) {
613
+ this.token = token;
614
+ this.accountId = accountId;
615
+ this.countryCode = countryCode;
616
+ this.enabled = true;
617
+ this.apiBaseUrl = (0, helpers_1.getApiBaseUrl)(); // Track the API endpoint being used
618
+ // Persist session details for reuse across restarts
619
+ this.emitTokenChange();
620
+ // DO NOT change the region/endpoint after successful authentication!
621
+ // The endpoint that successfully authenticated is the one we must continue using.
622
+ // Tokens are endpoint-specific and won't work if we switch endpoints.
623
+ // This is especially important for AU/NZ users who may authenticate via EU endpoint
624
+ // but have AU/NZ country codes.
625
+ // Log a warning if the country code doesn't match the successful region
626
+ if (countryCode) {
627
+ const expectedRegion = (0, helpers_1.getRegionFromCountryCode)(countryCode);
628
+ if (expectedRegion !== this._region) {
629
+ logger_1.logger.warn(`Note: Account authenticated with ${this._region} endpoint despite country code ${countryCode} typically using ${expectedRegion}`);
630
+ logger_1.logger.warn(`This is normal for accounts created through different regional apps`);
631
+ }
558
632
  }
633
+ logger_1.logger.debug('Authentication successful!', {
634
+ authFlow: this.authFlowUsed,
635
+ region: this._region,
636
+ countryCode: countryCode,
637
+ endpoint: (0, helpers_1.getApiBaseUrl)()
638
+ });
639
+ return true;
559
640
  }
641
+ // If we reach here, authentication failed
642
+ const delay = initialDelayMs * Math.pow(2, attempt);
643
+ logger_1.logger.debug(`Authentication attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
644
+ await new Promise(resolve => setTimeout(resolve, delay));
560
645
  }
561
- if (success && token && accountId) {
562
- this.token = token;
563
- this.accountId = accountId;
564
- this.countryCode = countryCode;
565
- this.enabled = true;
566
- this.apiBaseUrl = (0, helpers_1.getApiBaseUrl)(); // Track the API endpoint being used
567
- // DO NOT change the region/endpoint after successful authentication!
568
- // The endpoint that successfully authenticated is the one we must continue using.
569
- // Tokens are endpoint-specific and won't work if we switch endpoints.
570
- // This is especially important for AU/NZ users who may authenticate via EU endpoint
571
- // but have AU/NZ country codes.
572
- // Log a warning if the country code doesn't match the successful region
573
- if (countryCode) {
574
- const expectedRegion = (0, helpers_1.getRegionFromCountryCode)(countryCode);
575
- if (expectedRegion !== this._region) {
576
- logger_1.logger.warn(`Note: Account authenticated with ${this._region} endpoint despite country code ${countryCode} typically using ${expectedRegion}`);
577
- logger_1.logger.warn(`This is normal for accounts created through different regional apps`);
578
- }
646
+ catch (error) {
647
+ logger_1.logger.error(`Authentication attempt ${attempt + 1} error:`, error);
648
+ if (attempt === retryAttempts - 1) {
649
+ logger_1.logger.error('Authentication failed after all retry attempts');
650
+ return false;
579
651
  }
580
- logger_1.logger.debug('Authentication successful!', {
581
- authFlow: this.authFlowUsed,
582
- region: this._region,
583
- countryCode: countryCode,
584
- endpoint: (0, helpers_1.getApiBaseUrl)()
585
- });
586
- return true;
587
- }
588
- // If we reach here, authentication failed
589
- const delay = initialDelayMs * Math.pow(2, attempt);
590
- logger_1.logger.debug(`Authentication attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
591
- await new Promise(resolve => setTimeout(resolve, delay));
592
- }
593
- catch (error) {
594
- logger_1.logger.error(`Authentication attempt ${attempt + 1} error:`, error);
595
- if (attempt === retryAttempts - 1) {
596
- logger_1.logger.error('Authentication failed after all retry attempts');
597
- return false;
652
+ const delay = initialDelayMs * Math.pow(2, attempt);
653
+ logger_1.logger.debug(`Retrying authentication in ${delay}ms...`);
654
+ await new Promise(resolve => setTimeout(resolve, delay));
598
655
  }
599
- const delay = initialDelayMs * Math.pow(2, attempt);
600
- logger_1.logger.debug(`Retrying authentication in ${delay}ms...`);
601
- await new Promise(resolve => setTimeout(resolve, delay));
602
656
  }
657
+ logger_1.logger.error('Unable to authenticate with supplied credentials after all retry attempts');
658
+ return false;
659
+ })();
660
+ try {
661
+ return await this._loginPromise;
662
+ }
663
+ finally {
664
+ this._loginPromise = null;
603
665
  }
604
- logger_1.logger.error('Unable to authenticate with supplied credentials after all retry attempts');
605
- return false;
606
666
  }
607
667
  /**
608
668
  * Test if update interval has been exceeded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsvesync",
3
- "version": "1.1.2",
3
+ "version": "1.2.2",
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",
package/src/index.ts CHANGED
@@ -19,4 +19,7 @@ export {
19
19
  CROSS_REGION_ERROR_CODES,
20
20
  isCredentialError,
21
21
  isCrossRegionError
22
- } from './lib/constants';
22
+ } from './lib/constants';
23
+
24
+ // Export session types and utilities
25
+ export { Session, SessionStore, decodeJwtTimestamps } from './lib/session';
@@ -491,8 +491,16 @@ export class Helpers {
491
491
  if (error.response) {
492
492
  const responseData = error.response.data;
493
493
 
494
- // Check for token expiration
495
- if (responseData?.code === 4001004 || responseData?.msg === "token expired") {
494
+ // Check for token expiration or auth invalidation
495
+ const httpStatus = error.response.status;
496
+ const msg: string | undefined = responseData?.msg;
497
+ if (
498
+ responseData?.code === 4001004 ||
499
+ msg === 'token expired' ||
500
+ httpStatus === 401 ||
501
+ httpStatus === 419 ||
502
+ (typeof msg === 'string' && /token|login/i.test(msg))
503
+ ) {
496
504
  logger.debug('Token expired, attempting to re-login...');
497
505
 
498
506
  // Re-login
@@ -789,4 +797,4 @@ export class Helpers {
789
797
  return [false, null, null, null];
790
798
  }
791
799
  }
792
- }
800
+ }
@@ -0,0 +1,35 @@
1
+ export interface Session {
2
+ token: string;
3
+ accountId: string;
4
+ countryCode?: string | null;
5
+ region: string;
6
+ apiBaseUrl: string;
7
+ authFlowUsed?: 'legacy' | 'new';
8
+ issuedAt?: number | null; // epoch seconds
9
+ expiresAt?: number | null; // epoch seconds
10
+ lastValidatedAt?: number | null; // epoch ms
11
+ libraryVersion?: string;
12
+ }
13
+
14
+ export interface SessionStore {
15
+ load(): Promise<Session | null>;
16
+ save(session: Session): Promise<void>;
17
+ clear(): Promise<void>;
18
+ }
19
+
20
+ /**
21
+ * Decode a JWT token payload to extract iat/exp without verifying the signature.
22
+ */
23
+ export function decodeJwtTimestamps(token: string): { iat?: number; exp?: number } | null {
24
+ try {
25
+ const parts = token.split('.');
26
+ if (parts.length !== 3) return null;
27
+ const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
28
+ const iat = typeof payload.iat === 'number' ? payload.iat : undefined;
29
+ const exp = typeof payload.exp === 'number' ? payload.exp : undefined;
30
+ return { iat, exp };
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
package/src/lib/vesync.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { Helpers, API_RATE_LIMIT, DEFAULT_TZ, setApiBaseUrl, getApiBaseUrl, generateAppId, getRegionFromCountryCode, setCurrentRegion, getCurrentRegion, REGION_ENDPOINTS, getCountryCodeFromRegion, getEndpointForCountryCode } from './helpers';
6
+ import { Session, SessionStore, decodeJwtTimestamps } from './session';
6
7
  import { VeSyncBaseDevice } from './vesyncBaseDevice';
7
8
  import { fanModules } from './vesyncFanImpl';
8
9
  import { outletModules } from './vesyncOutletImpl';
@@ -162,6 +163,9 @@ export class VeSync {
162
163
  private _appId: string;
163
164
  private _region: string;
164
165
  private _countryCodeOverride: string | null;
166
+ private _sessionStore?: SessionStore;
167
+ private _onTokenChange?: (session: Session) => void;
168
+ private _loginPromise: Promise<boolean> | null = null;
165
169
 
166
170
  username: string;
167
171
  password: string;
@@ -205,6 +209,8 @@ export class VeSync {
205
209
  region?: string;
206
210
  countryCode?: string; // Override country code for Step 2 authentication
207
211
  debugMode?: boolean; // Alias for debug
212
+ sessionStore?: SessionStore;
213
+ onTokenChange?: (session: Session) => void;
208
214
  } = false,
209
215
  redact = true,
210
216
  apiUrl?: string,
@@ -223,6 +229,8 @@ export class VeSync {
223
229
  this._countryCodeOverride = options.countryCode || null;
224
230
  customLogger = options.customLogger;
225
231
  apiUrl = options.apiUrl;
232
+ this._sessionStore = options.sessionStore;
233
+ this._onTokenChange = options.onTokenChange;
226
234
  } else {
227
235
  // Using old parameter pattern for backward compatibility
228
236
  this._debug = optionsOrDebug as boolean;
@@ -306,6 +314,47 @@ export class VeSync {
306
314
  // Debug and redact are already set above in the options handling
307
315
  }
308
316
 
317
+ /**
318
+ * Hydrate manager from a previously persisted session
319
+ */
320
+ hydrateSession(session: Session): void {
321
+ 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);
329
+ }
330
+ this.token = session.token;
331
+ this.accountId = session.accountId;
332
+ this.countryCode = session.countryCode || null;
333
+ this.enabled = true;
334
+ } catch (e) {
335
+ logger.warn('Failed to hydrate session; will require fresh login');
336
+ }
337
+ }
338
+
339
+ private emitTokenChange(): void {
340
+ if (!this.token || !this.accountId || !this.apiBaseUrl) return;
341
+ const timestamps = decodeJwtTimestamps(this.token);
342
+ const session: Session = {
343
+ token: this.token,
344
+ accountId: this.accountId,
345
+ countryCode: this.countryCode,
346
+ region: this._region,
347
+ apiBaseUrl: this.apiBaseUrl,
348
+ authFlowUsed: this.authFlowUsed,
349
+ issuedAt: timestamps?.iat ?? null,
350
+ expiresAt: timestamps?.exp ?? null,
351
+ lastValidatedAt: Date.now(),
352
+ };
353
+ // Best-effort persist and callback; errors are non-fatal
354
+ Promise.resolve(this._sessionStore?.save(session)).catch(() => {});
355
+ try { this._onTokenChange?.(session); } catch { /* noop */ }
356
+ }
357
+
309
358
  /**
310
359
  * Get/Set debug mode
311
360
  */
@@ -576,168 +625,179 @@ export class VeSync {
576
625
  * Login to VeSync server with new authentication flow and backwards compatibility
577
626
  */
578
627
  async login(retryAttempts: number = 3, initialDelayMs: number = 1000): Promise<boolean> {
579
- logger.debug('Starting VeSync authentication...', {
580
- username: this.username,
581
- appId: this._appId,
582
- region: this._region
583
- });
584
-
585
- // Track which regions have been attempted to prevent infinite loops
586
- const triedRegions = new Set<string>();
587
-
588
- for (let attempt = 0; attempt < retryAttempts; attempt++) {
589
- try {
590
- logger.debug(`Authentication attempt ${attempt + 1} of ${retryAttempts}`);
628
+ if (this._loginPromise) {
629
+ return this._loginPromise;
630
+ }
631
+ this._loginPromise = (async () => {
632
+ logger.debug('Starting VeSync authentication...', {
633
+ username: this.username,
634
+ appId: this._appId,
635
+ region: this._region
636
+ });
591
637
 
592
- // Skip email-based region detection for now since we'll handle cross-region errors
593
- // The cross-region error handling below will automatically switch to the correct region
638
+ // Track which regions have been attempted to prevent infinite loops
639
+ const triedRegions = new Set<string>();
594
640
 
595
- // Try new authentication flow first with detected region
596
- let [success, token, accountId, countryCode] = await Helpers.authNewFlow(this, this._appId, this._region, this._countryCodeOverride || undefined);
597
-
598
- // Track if new flow succeeded
599
- if (success) {
600
- this.authFlowUsed = 'new';
601
- }
641
+ for (let attempt = 0; attempt < retryAttempts; attempt++) {
642
+ try {
643
+ logger.debug(`Authentication attempt ${attempt + 1} of ${retryAttempts}`);
602
644
 
603
- if (!success) {
604
- // Check if it's a credential error (no point retrying)
605
- if (countryCode === 'credential_error') {
606
- logger.error('Authentication failed due to invalid credentials');
607
- return false; // Exit immediately, don't retry
608
- }
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);
609
647
 
610
- // Check if we need to try a different region
611
- if (countryCode === 'cross_region' || countryCode === 'cross_region_retry') {
612
- // Mark current region as tried
613
- triedRegions.add(this._region);
614
-
615
- logger.debug('Cross-region error detected, trying opposite region...');
616
- const alternateRegion = this._region === 'US' ? 'EU' : 'US';
617
-
618
- // Check if we've already tried this region to prevent infinite loops
619
- if (triedRegions.has(alternateRegion)) {
620
- logger.error('═══════════════════════════════════════════════════════════════');
621
- logger.error('AUTHENTICATION FAILED: COUNTRY CODE REQUIRED');
622
- logger.error('');
623
- logger.error('Both US and EU endpoints rejected your account.');
624
- logger.error('This means you MUST specify your country code.');
625
- logger.error('');
626
- logger.error('SOLUTION:');
627
- logger.error('1. In Homebridge UI:');
628
- logger.error(' - Go to plugin settings');
629
- logger.error(' - Select your country from the "Country Code" dropdown');
630
- logger.error('');
631
- logger.error('2. In config.json:');
632
- logger.error(' Add: "countryCode": "YOUR_COUNTRY_CODE"');
633
- logger.error('');
634
- logger.error('Common non-US/EU country codes:');
635
- logger.error(' 🇦🇺 Australia: "AU"');
636
- logger.error(' 🇳🇿 New Zealand: "NZ"');
637
- logger.error(' 🇯🇵 Japan: "JP"');
638
- logger.error(' 🇨🇦 Canada: "CA"');
639
- logger.error(' 🇸🇬 Singapore: "SG"');
640
- logger.error(' 🇲🇽 Mexico: "MX"');
641
- logger.error('═══════════════════════════════════════════════════════════════');
642
- return false;
648
+ // Track if new flow succeeded
649
+ if (success) {
650
+ this.authFlowUsed = 'new';
651
+ }
652
+
653
+ if (!success) {
654
+ // Check if it's a credential error (no point retrying)
655
+ if (countryCode === 'credential_error') {
656
+ logger.error('Authentication failed due to invalid credentials');
657
+ return false; // Exit immediately, don't retry
643
658
  }
644
659
 
645
- logger.debug(`Switching from ${this._region} to ${alternateRegion} region`);
646
-
647
- this._region = alternateRegion;
648
- setCurrentRegion(alternateRegion);
649
-
650
- // Update the API endpoint for the new region
651
- const regionEndpoints: Record<string, string> = {
652
- 'US': 'https://smartapi.vesync.com',
653
- 'EU': 'https://smartapi.vesync.eu'
654
- };
655
- if (alternateRegion in regionEndpoints) {
656
- setApiBaseUrl(regionEndpoints[alternateRegion]);
657
- logger.debug(`API endpoint set to ${regionEndpoints[alternateRegion]}`);
660
+ // Check if we need to try a different region
661
+ if (countryCode === 'cross_region' || countryCode === 'cross_region_retry') {
662
+ // Mark current region as tried
663
+ triedRegions.add(this._region);
664
+
665
+ logger.debug('Cross-region error detected, trying opposite region...');
666
+ const alternateRegion = this._region === 'US' ? 'EU' : 'US';
667
+
668
+ // Check if we've already tried this region to prevent infinite loops
669
+ if (triedRegions.has(alternateRegion)) {
670
+ logger.error('═══════════════════════════════════════════════════════════════');
671
+ logger.error('AUTHENTICATION FAILED: COUNTRY CODE REQUIRED');
672
+ logger.error('');
673
+ logger.error('Both US and EU endpoints rejected your account.');
674
+ logger.error('This means you MUST specify your country code.');
675
+ logger.error('');
676
+ logger.error('SOLUTION:');
677
+ logger.error('1. In Homebridge UI:');
678
+ logger.error(' - Go to plugin settings');
679
+ logger.error(' - Select your country from the "Country Code" dropdown');
680
+ logger.error('');
681
+ logger.error('2. In config.json:');
682
+ logger.error(' Add: "countryCode": "YOUR_COUNTRY_CODE"');
683
+ logger.error('');
684
+ logger.error('Common non-US/EU country codes:');
685
+ logger.error(' 🇦🇺 Australia: "AU"');
686
+ logger.error(' 🇳🇿 New Zealand: "NZ"');
687
+ logger.error(' 🇯🇵 Japan: "JP"');
688
+ logger.error(' 🇨🇦 Canada: "CA"');
689
+ logger.error(' 🇸🇬 Singapore: "SG"');
690
+ logger.error(' 🇲🇽 Mexico: "MX"');
691
+ logger.error('═══════════════════════════════════════════════════════════════');
692
+ return false;
693
+ }
694
+
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
+ }
709
+
710
+ // Retry with the alternate region
711
+ [success, token, accountId, countryCode] = await Helpers.authNewFlow(this, this._appId, alternateRegion, this._countryCodeOverride || undefined);
712
+ if (success) {
713
+ this.authFlowUsed = 'new';
714
+ logger.debug(`Authentication successful with ${alternateRegion} region`);
715
+ } else if (countryCode === 'cross_region' || countryCode === 'cross_region_retry') {
716
+ // Both regions failed with cross-region error
717
+ logger.error('Authentication failed: Account rejected by both US and EU regions');
718
+ logger.error('This may indicate your account was created in a region not yet supported');
719
+ logger.error('Please contact VeSync support to determine your account region');
720
+ // Don't retry further attempts as we've exhausted both regions
721
+ return false;
722
+ }
658
723
  }
659
-
660
- // Retry with the alternate region
661
- [success, token, accountId, countryCode] = await Helpers.authNewFlow(this, this._appId, alternateRegion, this._countryCodeOverride || undefined);
662
- if (success) {
663
- this.authFlowUsed = 'new';
664
- logger.debug(`Authentication successful with ${alternateRegion} region`);
665
- } else if (countryCode === 'cross_region' || countryCode === 'cross_region_retry') {
666
- // Both regions failed with cross-region error
667
- logger.error('Authentication failed: Account rejected by both US and EU regions');
668
- logger.error('This may indicate your account was created in a region not yet supported');
669
- logger.error('Please contact VeSync support to determine your account region');
670
- // Don't retry further attempts as we've exhausted both regions
671
- return false;
724
+
725
+ // If new flow still fails, try legacy authentication as fallback
726
+ if (!success) {
727
+ logger.debug('New authentication flow failed, trying legacy flow...');
728
+ [success, token, accountId, countryCode] = await Helpers.authLegacyFlow(this);
729
+ if (success) {
730
+ this.authFlowUsed = 'legacy';
731
+ } else if (countryCode === 'credential_error') {
732
+ // Legacy auth also detected bad credentials
733
+ logger.error('Both authentication methods report invalid credentials');
734
+ return false; // Exit immediately
735
+ }
672
736
  }
673
737
  }
674
738
 
675
- // If new flow still fails, try legacy authentication as fallback
676
- if (!success) {
677
- logger.debug('New authentication flow failed, trying legacy flow...');
678
- [success, token, accountId, countryCode] = await Helpers.authLegacyFlow(this);
679
- if (success) {
680
- this.authFlowUsed = 'legacy';
681
- } else if (countryCode === 'credential_error') {
682
- // Legacy auth also detected bad credentials
683
- logger.error('Both authentication methods report invalid credentials');
684
- return false; // Exit immediately
739
+ if (success && token && accountId) {
740
+ this.token = token;
741
+ this.accountId = accountId;
742
+ this.countryCode = countryCode;
743
+ this.enabled = true;
744
+ this.apiBaseUrl = getApiBaseUrl(); // Track the API endpoint being used
745
+
746
+ // Persist session details for reuse across restarts
747
+ this.emitTokenChange();
748
+
749
+ // DO NOT change the region/endpoint after successful authentication!
750
+ // The endpoint that successfully authenticated is the one we must continue using.
751
+ // Tokens are endpoint-specific and won't work if we switch endpoints.
752
+ // This is especially important for AU/NZ users who may authenticate via EU endpoint
753
+ // but have AU/NZ country codes.
754
+
755
+ // Log a warning if the country code doesn't match the successful region
756
+ if (countryCode) {
757
+ const expectedRegion = getRegionFromCountryCode(countryCode);
758
+ if (expectedRegion !== this._region) {
759
+ logger.warn(`Note: Account authenticated with ${this._region} endpoint despite country code ${countryCode} typically using ${expectedRegion}`);
760
+ logger.warn(`This is normal for accounts created through different regional apps`);
761
+ }
685
762
  }
763
+
764
+ logger.debug('Authentication successful!', {
765
+ authFlow: this.authFlowUsed,
766
+ region: this._region,
767
+ countryCode: countryCode,
768
+ endpoint: getApiBaseUrl()
769
+ });
770
+ return true;
686
771
  }
687
- }
688
772
 
689
- if (success && token && accountId) {
690
- this.token = token;
691
- this.accountId = accountId;
692
- this.countryCode = countryCode;
693
- this.enabled = true;
694
- this.apiBaseUrl = getApiBaseUrl(); // Track the API endpoint being used
695
-
696
- // DO NOT change the region/endpoint after successful authentication!
697
- // The endpoint that successfully authenticated is the one we must continue using.
698
- // Tokens are endpoint-specific and won't work if we switch endpoints.
699
- // This is especially important for AU/NZ users who may authenticate via EU endpoint
700
- // but have AU/NZ country codes.
773
+ // If we reach here, authentication failed
774
+ const delay = initialDelayMs * Math.pow(2, attempt);
775
+ logger.debug(`Authentication attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
776
+ await new Promise(resolve => setTimeout(resolve, delay));
777
+
778
+ } catch (error) {
779
+ logger.error(`Authentication attempt ${attempt + 1} error:`, error);
701
780
 
702
- // Log a warning if the country code doesn't match the successful region
703
- if (countryCode) {
704
- const expectedRegion = getRegionFromCountryCode(countryCode);
705
- if (expectedRegion !== this._region) {
706
- logger.warn(`Note: Account authenticated with ${this._region} endpoint despite country code ${countryCode} typically using ${expectedRegion}`);
707
- logger.warn(`This is normal for accounts created through different regional apps`);
708
- }
781
+ if (attempt === retryAttempts - 1) {
782
+ logger.error('Authentication failed after all retry attempts');
783
+ return false;
709
784
  }
710
785
 
711
- logger.debug('Authentication successful!', {
712
- authFlow: this.authFlowUsed,
713
- region: this._region,
714
- countryCode: countryCode,
715
- endpoint: getApiBaseUrl()
716
- });
717
- return true;
786
+ const delay = initialDelayMs * Math.pow(2, attempt);
787
+ logger.debug(`Retrying authentication in ${delay}ms...`);
788
+ await new Promise(resolve => setTimeout(resolve, delay));
718
789
  }
790
+ }
719
791
 
720
- // If we reach here, authentication failed
721
- const delay = initialDelayMs * Math.pow(2, attempt);
722
- logger.debug(`Authentication attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
723
- await new Promise(resolve => setTimeout(resolve, delay));
724
-
725
- } catch (error) {
726
- logger.error(`Authentication attempt ${attempt + 1} error:`, error);
727
-
728
- if (attempt === retryAttempts - 1) {
729
- logger.error('Authentication failed after all retry attempts');
730
- return false;
731
- }
792
+ logger.error('Unable to authenticate with supplied credentials after all retry attempts');
793
+ return false;
794
+ })();
732
795
 
733
- const delay = initialDelayMs * Math.pow(2, attempt);
734
- logger.debug(`Retrying authentication in ${delay}ms...`);
735
- await new Promise(resolve => setTimeout(resolve, delay));
736
- }
796
+ try {
797
+ return await this._loginPromise;
798
+ } finally {
799
+ this._loginPromise = null;
737
800
  }
738
-
739
- logger.error('Unable to authenticate with supplied credentials after all retry attempts');
740
- return false;
741
801
  }
742
802
 
743
803
  /**
@@ -858,4 +918,4 @@ export class VeSync {
858
918
  ): Promise<[any, number]> {
859
919
  return await Helpers.callApi(endpoint, method, data, headers, this);
860
920
  }
861
- }
921
+ }