tsvesync 1.5.0 → 1.5.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.
@@ -5,6 +5,14 @@ export interface Session {
5
5
  region: string;
6
6
  apiBaseUrl: string;
7
7
  authFlowUsed?: 'legacy' | 'new';
8
+ /**
9
+ * Identifies this installation to VeSync. Issued tokens are bound to it (the JWT payload carries a
10
+ * `terminalId` claim), so it must stay stable across logins and restarts — a fresh id makes every login
11
+ * look like a brand-new device to VeSync.
12
+ */
13
+ terminalId?: string;
14
+ /** Client app id, kept alongside `terminalId` so the client identity is stable too. */
15
+ appId?: string;
8
16
  issuedAt?: number | null; // epoch seconds
9
17
  expiresAt?: number | null; // epoch seconds
10
18
  lastValidatedAt?: number | null; // epoch ms
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, generateAppId, getRegionFromCountryCode, REGION_ENDPOINTS, getEndpointForCountryCode } from './helpers';
5
+ import { Helpers, API_RATE_LIMIT, DEFAULT_TZ, generateAppId, generateTerminalId, 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';
@@ -175,6 +175,7 @@ export class VeSync {
175
175
  private _inProcess: boolean;
176
176
  private _excludeConfig: ExcludeConfig | null;
177
177
  private _appId: string;
178
+ private _terminalId: string;
178
179
  private _region: string;
179
180
  private _countryCodeOverride: string | null;
180
181
  private _apiUrlOverride: string | null;
@@ -261,6 +262,9 @@ export class VeSync {
261
262
  this._lastUpdateTs = null;
262
263
  this._inProcess = false;
263
264
  this._appId = generateAppId();
265
+ // Stable per-installation identity. Overwritten by `hydrateSession` when a previous session is
266
+ // restored, so logins keep presenting the same terminal to VeSync.
267
+ this._terminalId = generateTerminalId();
264
268
 
265
269
  this.username = username;
266
270
  this.password = password;
@@ -322,6 +326,21 @@ export class VeSync {
322
326
  // Debug and redact are already set above in the options handling
323
327
  }
324
328
 
329
+ /**
330
+ * Adopt a previously persisted client identity without restoring credentials.
331
+ *
332
+ * Callers that decide not to reuse a stored token (because it has expired, say) should still keep the
333
+ * terminal/app id from that session, so the fresh login presents the same client to VeSync.
334
+ */
335
+ restoreClientIdentity(identity: { terminalId?: string | null; appId?: string | null }): void {
336
+ if (typeof identity?.terminalId === 'string' && identity.terminalId) {
337
+ this._terminalId = identity.terminalId;
338
+ }
339
+ if (typeof identity?.appId === 'string' && identity.appId) {
340
+ this._appId = identity.appId;
341
+ }
342
+ }
343
+
325
344
  /**
326
345
  * Hydrate manager from a previously persisted session
327
346
  */
@@ -337,6 +356,14 @@ export class VeSync {
337
356
  this.countryCode = session.countryCode ?? null;
338
357
  this.authFlowUsed = session.authFlowUsed;
339
358
 
359
+ // Keep the persisted client identity so re-logins present the same terminal to VeSync.
360
+ if (typeof session.terminalId === 'string' && session.terminalId) {
361
+ this._terminalId = session.terminalId;
362
+ }
363
+ if (typeof session.appId === 'string' && session.appId) {
364
+ this._appId = session.appId;
365
+ }
366
+
340
367
  // pyvesync parity:
341
368
  // pyvesync persists `current_region` and derives the API base URL per-request from that region.
342
369
  // It does NOT treat a stored base URL as authoritative. For backwards compatibility with older
@@ -396,6 +423,8 @@ export class VeSync {
396
423
  region: this._region,
397
424
  apiBaseUrl: this.apiBaseUrl,
398
425
  authFlowUsed: this.authFlowUsed,
426
+ terminalId: this._terminalId,
427
+ appId: this._appId,
399
428
  issuedAt: timestamps?.iat ?? null,
400
429
  expiresAt: timestamps?.exp ?? null,
401
430
  lastValidatedAt: Date.now(),
@@ -448,6 +477,14 @@ export class VeSync {
448
477
  return this._appId;
449
478
  }
450
479
 
480
+ /**
481
+ * Terminal id presented to VeSync during authentication. Issued tokens carry it as a JWT claim, so it
482
+ * is persisted with the session and reused across logins and restarts.
483
+ */
484
+ get terminalId(): string {
485
+ return this._terminalId;
486
+ }
487
+
451
488
  /**
452
489
  * Get current region
453
490
  */
@@ -605,6 +642,17 @@ export class VeSync {
605
642
  let success = false;
606
643
 
607
644
  try {
645
+ // A token whose `exp` has already passed can only come back as a token error, so refresh it
646
+ // up front instead of spending a request to be told so.
647
+ if (this.isTokenExpired()) {
648
+ logger.debug('Stored VeSync token is past its expiry; authenticating before requesting the device list');
649
+ if (!await this.login()) {
650
+ logger.error('VeSync token had expired and re-authentication failed. Check the configured username/password (and country code if your account is not in the US).');
651
+ this.enabled = false;
652
+ return false;
653
+ }
654
+ }
655
+
608
656
  const fetchDeviceList = async (): Promise<[any, number]> => {
609
657
  return await Helpers.callApi(
610
658
  '/cloud/v1/deviceManaged/devices',
@@ -640,7 +688,9 @@ export class VeSync {
640
688
  if (typeof code === 'number' && code !== 0) {
641
689
  if (!didRetryToken && isTokenError(code)) {
642
690
  didRetryToken = true;
643
- logger.warn('Device list request returned token error; re-authenticating and retrying (pyvesync parity)', {
691
+ // Recoverable: re-authenticate and retry. Logged at debug because the retry below
692
+ // normally succeeds; the outcome is reported after the loop.
693
+ logger.debug('Device list request returned a token error; re-authenticating and retrying', {
644
694
  code,
645
695
  msg: response?.msg,
646
696
  region: this._region,
@@ -648,6 +698,7 @@ export class VeSync {
648
698
  });
649
699
  const reloginOk = await this.login();
650
700
  if (!reloginOk) {
701
+ logger.error('VeSync rejected the stored session and re-authentication failed. Check the configured username/password (and country code if your account is not in the US).');
651
702
  this.enabled = false;
652
703
  return false;
653
704
  }
@@ -688,6 +739,19 @@ export class VeSync {
688
739
  break;
689
740
  }
690
741
 
742
+ if (didRetryToken) {
743
+ if (response && status === 200 && response.code === 0) {
744
+ logger.info('VeSync session had expired; re-authenticated and recovered automatically.');
745
+ } else {
746
+ logger.warn('VeSync session had expired and the request still failed after re-authenticating.', {
747
+ code: response?.code,
748
+ msg: response?.msg,
749
+ region: this._region,
750
+ endpoint: this.apiBaseUrl
751
+ });
752
+ }
753
+ }
754
+
691
755
  if (!response) {
692
756
  logger.error('No response received from VeSync API');
693
757
  return false;
@@ -950,6 +1014,19 @@ export class VeSync {
950
1014
  }
951
1015
  }
952
1016
 
1017
+ /**
1018
+ * True when the current token carries an `exp` claim that has already passed.
1019
+ *
1020
+ * Deliberately conservative: a token that cannot be decoded, or that has no `exp`, is treated as
1021
+ * usable and left to the API to accept or reject.
1022
+ */
1023
+ isTokenExpired(skewSeconds: number = 60): boolean {
1024
+ if (!this.token) return false;
1025
+ const exp = decodeJwtTimestamps(this.token)?.exp;
1026
+ if (!exp) return false;
1027
+ return exp * 1000 <= Date.now() + skewSeconds * 1000;
1028
+ }
1029
+
953
1030
  /**
954
1031
  * Test if update interval has been exceeded
955
1032
  */
@@ -25,13 +25,13 @@ export const fanConfig: FanConfig = {
25
25
  'Core200S': {
26
26
  module: 'VeSyncAirBypass',
27
27
  features: ['display', 'child_lock', 'night_light', 'timer', 'fan_speed', 'sleep_mode', 'filter_life'],
28
- levels: [1, 2, 3, 4], // 4 levels including sleep mode, matching reference plugin
28
+ levels: [1, 2, 3], // Low/Med/High; sleep is a mode, not a level
29
29
  modes: ['sleep', 'manual']
30
30
  },
31
31
  'Core300S': {
32
32
  module: 'VeSyncAirBypass',
33
33
  features: ['display', 'child_lock', 'night_light', 'air_quality', 'timer', 'fan_speed', 'auto_mode', 'sleep_mode', 'filter_life'],
34
- levels: [1, 2, 3, 4],
34
+ levels: [1, 2, 3],
35
35
  modes: ['sleep', 'manual', 'auto'],
36
36
  autoPreferences: ['default', 'efficient', 'quiet']
37
37
  },
@@ -66,21 +66,21 @@ export const fanConfig: FanConfig = {
66
66
  'LAP-C301S-WJP': {
67
67
  module: 'VeSyncAirBypass',
68
68
  features: ['display', 'child_lock', 'night_light', 'air_quality', 'timer', 'fan_speed', 'auto_mode', 'sleep_mode', 'filter_life'],
69
- levels: [1, 2, 3, 4],
69
+ levels: [1, 2, 3],
70
70
  modes: ['sleep', 'manual', 'auto'],
71
71
  autoPreferences: ['default', 'efficient', 'quiet']
72
72
  },
73
73
  'LAP-C302S-WUSB': {
74
74
  module: 'VeSyncAirBypass',
75
75
  features: ['display', 'child_lock', 'night_light', 'air_quality', 'timer', 'fan_speed', 'auto_mode', 'sleep_mode', 'filter_life'],
76
- levels: [1, 2, 3, 4],
76
+ levels: [1, 2, 3],
77
77
  modes: ['sleep', 'manual', 'auto'],
78
78
  autoPreferences: ['default', 'efficient', 'quiet']
79
79
  },
80
80
  'LAP-C301S-WAAA': {
81
81
  module: 'VeSyncAirBypass',
82
82
  features: ['display', 'child_lock', 'night_light', 'air_quality', 'timer', 'fan_speed', 'auto_mode', 'sleep_mode', 'filter_life'],
83
- levels: [1, 2, 3, 4],
83
+ levels: [1, 2, 3],
84
84
  modes: ['sleep', 'manual', 'auto'],
85
85
  autoPreferences: ['default', 'efficient', 'quiet']
86
86
  },