tsvesync 1.4.11 → 1.5.1

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.
@@ -36,6 +36,14 @@ export declare const AUTH_ERROR_CODES: {
36
36
  };
37
37
  type CallApiOptions = {
38
38
  didRetryAuth?: boolean;
39
+ /**
40
+ * Set on requests that are themselves part of the authentication flow.
41
+ *
42
+ * Those must never trigger the re-login retry in `callApi`: `VeSync.login()` de-duplicates on an
43
+ * in-flight promise, so calling it from inside the auth flow makes that promise await itself and it
44
+ * never settles (a silent hang rather than an error).
45
+ */
46
+ skipAuthRetry?: boolean;
39
47
  };
40
48
  /**
41
49
  * Get list of country codes that use the US endpoint
@@ -96,6 +96,40 @@ exports.AUTH_ERROR_CODES = {
96
96
  function isTokenExpiredMessage(msg) {
97
97
  return typeof msg === 'string' && /token\s*(?:has\s*)?expired/i.test(msg);
98
98
  }
99
+ /**
100
+ * Auth material is snapshotted into the request body (`reqBodyAuth`) and headers (`reqHeaders`) at the
101
+ * moment they are built. A request retried after a re-login therefore still carries the *old* token, so it
102
+ * fails with the very same token error that triggered the retry. Refresh those fields from the manager
103
+ * before retrying.
104
+ *
105
+ * Only keys that are already present with a non-empty string value are replaced. That leaves auth-flow
106
+ * payloads — which deliberately send `token: ''` / `accountID: ''` — untouched.
107
+ */
108
+ function refreshAuthMaterial(data, headers, manager) {
109
+ const token = manager.token;
110
+ const accountId = manager.accountId;
111
+ if (!token || !accountId) {
112
+ return [data, headers];
113
+ }
114
+ const replace = (target, key, value) => {
115
+ if (typeof target[key] === 'string' && target[key].length > 0) {
116
+ target[key] = value;
117
+ }
118
+ };
119
+ let nextData = data;
120
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
121
+ nextData = { ...data };
122
+ replace(nextData, 'token', token);
123
+ replace(nextData, 'accountID', accountId);
124
+ replace(nextData, 'accountId', accountId);
125
+ }
126
+ const nextHeaders = { ...headers };
127
+ replace(nextHeaders, 'tk', token);
128
+ replace(nextHeaders, 'token', token);
129
+ replace(nextHeaders, 'accountId', accountId);
130
+ replace(nextHeaders, 'accountID', accountId);
131
+ return [nextData, nextHeaders];
132
+ }
99
133
  /**
100
134
  * Get list of country codes that use the US endpoint
101
135
  * These are tried when we get a cross-region error
@@ -467,14 +501,21 @@ class Helpers {
467
501
  const responseCode = (_a = response.data) === null || _a === void 0 ? void 0 : _a.code;
468
502
  const responseMsg = (_b = response.data) === null || _b === void 0 ? void 0 : _b.msg;
469
503
  if (!didRetryAuth &&
504
+ !options.skipAuthRetry &&
470
505
  response.status === 200 &&
471
506
  ((typeof responseCode === 'number' && (0, constants_1.isTokenError)(responseCode)) ||
472
507
  isTokenExpiredMessage(responseMsg))) {
473
- logger_1.logger.debug('Token expired, attempting to re-login...');
508
+ logger_1.logger.debug('VeSync rejected the session token; re-authenticating before retrying', {
509
+ endpoint,
510
+ code: responseCode
511
+ });
474
512
  if (await manager.login()) {
475
- logger_1.logger.debug('Re-login successful, retrying original request...');
476
- return await this.callApi(endpoint, method, data, headers, manager, { ...options, didRetryAuth: true });
513
+ // Rebuild the auth fields; the original body/headers still hold the rejected token.
514
+ const [retryData, retryHeaders] = refreshAuthMaterial(data, headers, manager);
515
+ logger_1.logger.debug('Re-authentication succeeded, retrying request with the refreshed token');
516
+ return await this.callApi(endpoint, method, retryData, retryHeaders, manager, { ...options, didRetryAuth: true });
477
517
  }
518
+ logger_1.logger.debug('Re-authentication failed; returning the original token error to the caller');
478
519
  }
479
520
  return [response.data, response.status];
480
521
  }
@@ -485,18 +526,25 @@ class Helpers {
485
526
  const httpStatus = error.response.status;
486
527
  const msg = responseData === null || responseData === void 0 ? void 0 : responseData.msg;
487
528
  const didRetryAuth = options.didRetryAuth === true;
488
- if (!didRetryAuth && (httpStatus === 401 ||
529
+ if (!didRetryAuth && !options.skipAuthRetry && (httpStatus === 401 ||
489
530
  httpStatus === 419 ||
490
531
  (responseData === null || responseData === void 0 ? void 0 : responseData.code) === 4001004 ||
491
532
  (typeof (responseData === null || responseData === void 0 ? void 0 : responseData.code) === 'number' && (0, constants_1.isTokenError)(responseData.code)) ||
492
533
  isTokenExpiredMessage(msg))) {
493
- logger_1.logger.debug('Token expired, attempting to re-login...');
534
+ logger_1.logger.debug('VeSync rejected the session token; re-authenticating before retrying', {
535
+ endpoint,
536
+ status: httpStatus,
537
+ code: responseData === null || responseData === void 0 ? void 0 : responseData.code
538
+ });
494
539
  // Re-login
495
540
  if (await manager.login()) {
496
- // Retry the original request
497
- logger_1.logger.debug('Re-login successful, retrying original request...');
498
- return await this.callApi(endpoint, method, data, headers, manager, { ...options, didRetryAuth: true });
541
+ // Retry the original request with refreshed auth fields; the original body/headers
542
+ // still hold the rejected token.
543
+ const [retryData, retryHeaders] = refreshAuthMaterial(data, headers, manager);
544
+ logger_1.logger.debug('Re-authentication succeeded, retrying request with the refreshed token');
545
+ return await this.callApi(endpoint, method, retryData, retryHeaders, manager, { ...options, didRetryAuth: true });
499
546
  }
547
+ logger_1.logger.debug('Re-authentication failed; returning the original token error to the caller');
500
548
  }
501
549
  // Log specific error details for debugging
502
550
  logger_1.logger.error('API call failed with response:', {
@@ -582,7 +630,9 @@ class Helpers {
582
630
  var _a, _b, _c;
583
631
  try {
584
632
  // Generate terminal ID to be used in both steps
585
- const terminalId = generateTerminalId();
633
+ // Reuse the manager's stable terminal id. VeSync binds issued tokens to it (the JWT carries a
634
+ // `terminalId` claim), and a fresh id on every login makes each login look like a new device.
635
+ const terminalId = manager.terminalId || generateTerminalId();
586
636
  // pyvesync parity:
587
637
  // - `userCountryCode` defaults to `DEFAULT_REGION` ("US"), even if we first try the EU base URL.
588
638
  // - If the account truly belongs to another region, the Step 2 response includes the correct
@@ -605,7 +655,7 @@ class Helpers {
605
655
  endpoint: manager.apiBaseUrl || getApiBaseUrl(),
606
656
  body: { ...step1Body, password: '[REDACTED]' }
607
657
  });
608
- const [authResponse, authStatus] = await this.callApi('/globalPlatform/api/accountAuth/v1/authByPWDOrOTM', 'post', step1Body, step1Headers, manager);
658
+ const [authResponse, authStatus] = await this.callApi('/globalPlatform/api/accountAuth/v1/authByPWDOrOTM', 'post', step1Body, step1Headers, manager, { skipAuthRetry: true });
609
659
  if (!authResponse || authStatus !== 200) {
610
660
  logger_1.logger.error('Step 1 failed:', authResponse);
611
661
  manager.apiBaseUrl = originalUrl;
@@ -654,7 +704,7 @@ class Helpers {
654
704
  // to keep the function signature stable/historical.
655
705
  const step2Body = this.reqBodyAuthStep2(authorizeCode, null, appId, terminalId, countryCodeForStep2);
656
706
  logger_1.logger.debug('Step 2: Logging in with authorization code...');
657
- const [loginResponse, loginStatus] = await this.callApi('/user/api/accountManage/v1/loginByAuthorizeCode4Vesync', 'post', step2Body, step1Headers, manager);
707
+ const [loginResponse, loginStatus] = await this.callApi('/user/api/accountManage/v1/loginByAuthorizeCode4Vesync', 'post', step2Body, step1Headers, manager, { skipAuthRetry: true });
658
708
  logger_1.logger.debug('Step 2 response:', {
659
709
  status: loginStatus,
660
710
  code: loginResponse === null || loginResponse === void 0 ? void 0 : loginResponse.code,
@@ -690,7 +740,7 @@ class Helpers {
690
740
  if (regionChangeToken) {
691
741
  const retryCountryCode = serverCountryCode || countryCodeForStep2;
692
742
  const retryBody = this.reqBodyAuthStep2(authorizeCode, regionChangeToken, appId, terminalId, retryCountryCode, 'lastRegion');
693
- const [retryResponse, retryStatus] = await this.callApi('/user/api/accountManage/v1/loginByAuthorizeCode4Vesync', 'post', retryBody, step1Headers, manager);
743
+ const [retryResponse, retryStatus] = await this.callApi('/user/api/accountManage/v1/loginByAuthorizeCode4Vesync', 'post', retryBody, step1Headers, manager, { skipAuthRetry: true });
694
744
  if (retryResponse && retryStatus === 200 && retryResponse.code === 0) {
695
745
  const { token, accountID, countryCode } = retryResponse.result || {};
696
746
  if (token && accountID) {
@@ -740,7 +790,7 @@ class Helpers {
740
790
  endpoint: manager.apiBaseUrl || getApiBaseUrl(),
741
791
  body: { ...body, password: '[REDACTED]' }
742
792
  });
743
- const [response, status] = await this.callApi('/cloud/v1/user/login', 'post', body, {}, manager);
793
+ const [response, status] = await this.callApi('/cloud/v1/user/login', 'post', body, {}, manager, { skipAuthRetry: true });
744
794
  if (!response || status !== 200) {
745
795
  logger_1.logger.error('Legacy login failed:', response);
746
796
  return [false, null, null, null];
@@ -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;
9
17
  expiresAt?: number | null;
10
18
  lastValidatedAt?: number | null;
@@ -27,6 +27,7 @@ export declare class VeSync {
27
27
  private _inProcess;
28
28
  private _excludeConfig;
29
29
  private _appId;
30
+ private _terminalId;
30
31
  private _region;
31
32
  private _countryCodeOverride;
32
33
  private _apiUrlOverride;
@@ -72,6 +73,16 @@ export declare class VeSync {
72
73
  sessionStore?: SessionStore;
73
74
  onTokenChange?: (session: Session) => void;
74
75
  }, redact?: boolean, apiUrl?: string, customLogger?: Logger, excludeConfig?: ExcludeConfig);
76
+ /**
77
+ * Adopt a previously persisted client identity without restoring credentials.
78
+ *
79
+ * Callers that decide not to reuse a stored token (because it has expired, say) should still keep the
80
+ * terminal/app id from that session, so the fresh login presents the same client to VeSync.
81
+ */
82
+ restoreClientIdentity(identity: {
83
+ terminalId?: string | null;
84
+ appId?: string | null;
85
+ }): void;
75
86
  /**
76
87
  * Hydrate manager from a previously persisted session
77
88
  */
@@ -96,6 +107,11 @@ export declare class VeSync {
96
107
  * Get current App ID
97
108
  */
98
109
  get appId(): string;
110
+ /**
111
+ * Terminal id presented to VeSync during authentication. Issued tokens carry it as a JWT claim, so it
112
+ * is persisted with the session and reused across logins and restarts.
113
+ */
114
+ get terminalId(): string;
99
115
  /**
100
116
  * Get current region
101
117
  */
@@ -136,6 +152,13 @@ export declare class VeSync {
136
152
  * Login to VeSync server with new authentication flow and backwards compatibility
137
153
  */
138
154
  login(retryAttempts?: number, initialDelayMs?: number): Promise<boolean>;
155
+ /**
156
+ * True when the current token carries an `exp` claim that has already passed.
157
+ *
158
+ * Deliberately conservative: a token that cannot be decoded, or that has no `exp`, is treated as
159
+ * usable and left to the API to accept or reject.
160
+ */
161
+ isTokenExpired(skewSeconds?: number): boolean;
139
162
  /**
140
163
  * Test if update interval has been exceeded
141
164
  */
@@ -176,6 +176,9 @@ class VeSync {
176
176
  this._lastUpdateTs = null;
177
177
  this._inProcess = false;
178
178
  this._appId = (0, helpers_1.generateAppId)();
179
+ // Stable per-installation identity. Overwritten by `hydrateSession` when a previous session is
180
+ // restored, so logins keep presenting the same terminal to VeSync.
181
+ this._terminalId = (0, helpers_1.generateTerminalId)();
179
182
  this.username = username;
180
183
  this.password = password;
181
184
  this.token = null;
@@ -231,6 +234,20 @@ class VeSync {
231
234
  }
232
235
  // Debug and redact are already set above in the options handling
233
236
  }
237
+ /**
238
+ * Adopt a previously persisted client identity without restoring credentials.
239
+ *
240
+ * Callers that decide not to reuse a stored token (because it has expired, say) should still keep the
241
+ * terminal/app id from that session, so the fresh login presents the same client to VeSync.
242
+ */
243
+ restoreClientIdentity(identity) {
244
+ if (typeof (identity === null || identity === void 0 ? void 0 : identity.terminalId) === 'string' && identity.terminalId) {
245
+ this._terminalId = identity.terminalId;
246
+ }
247
+ if (typeof (identity === null || identity === void 0 ? void 0 : identity.appId) === 'string' && identity.appId) {
248
+ this._appId = identity.appId;
249
+ }
250
+ }
234
251
  /**
235
252
  * Hydrate manager from a previously persisted session
236
253
  */
@@ -245,6 +262,13 @@ class VeSync {
245
262
  this.accountId = session.accountId;
246
263
  this.countryCode = (_a = session.countryCode) !== null && _a !== void 0 ? _a : null;
247
264
  this.authFlowUsed = session.authFlowUsed;
265
+ // Keep the persisted client identity so re-logins present the same terminal to VeSync.
266
+ if (typeof session.terminalId === 'string' && session.terminalId) {
267
+ this._terminalId = session.terminalId;
268
+ }
269
+ if (typeof session.appId === 'string' && session.appId) {
270
+ this._appId = session.appId;
271
+ }
248
272
  // pyvesync parity:
249
273
  // pyvesync persists `current_region` and derives the API base URL per-request from that region.
250
274
  // It does NOT treat a stored base URL as authoritative. For backwards compatibility with older
@@ -303,6 +327,8 @@ class VeSync {
303
327
  region: this._region,
304
328
  apiBaseUrl: this.apiBaseUrl,
305
329
  authFlowUsed: this.authFlowUsed,
330
+ terminalId: this._terminalId,
331
+ appId: this._appId,
306
332
  issuedAt: (_a = timestamps === null || timestamps === void 0 ? void 0 : timestamps.iat) !== null && _a !== void 0 ? _a : null,
307
333
  expiresAt: (_b = timestamps === null || timestamps === void 0 ? void 0 : timestamps.exp) !== null && _b !== void 0 ? _b : null,
308
334
  lastValidatedAt: Date.now(),
@@ -350,6 +376,13 @@ class VeSync {
350
376
  get appId() {
351
377
  return this._appId;
352
378
  }
379
+ /**
380
+ * Terminal id presented to VeSync during authentication. Issued tokens carry it as a JWT claim, so it
381
+ * is persisted with the session and reused across logins and restarts.
382
+ */
383
+ get terminalId() {
384
+ return this._terminalId;
385
+ }
353
386
  /**
354
387
  * Get current region
355
388
  */
@@ -497,6 +530,16 @@ class VeSync {
497
530
  this._inProcess = true;
498
531
  let success = false;
499
532
  try {
533
+ // A token whose `exp` has already passed can only come back as a token error, so refresh it
534
+ // up front instead of spending a request to be told so.
535
+ if (this.isTokenExpired()) {
536
+ logger_1.logger.debug('Stored VeSync token is past its expiry; authenticating before requesting the device list');
537
+ if (!await this.login()) {
538
+ logger_1.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).');
539
+ this.enabled = false;
540
+ return false;
541
+ }
542
+ }
500
543
  const fetchDeviceList = async () => {
501
544
  return await helpers_1.Helpers.callApi('/cloud/v1/deviceManaged/devices', 'post', helpers_1.Helpers.reqBody(this, 'devicelist'), helpers_1.Helpers.reqHeaderBypass(), this);
502
545
  };
@@ -521,7 +564,9 @@ class VeSync {
521
564
  if (typeof code === 'number' && code !== 0) {
522
565
  if (!didRetryToken && (0, constants_1.isTokenError)(code)) {
523
566
  didRetryToken = true;
524
- logger_1.logger.warn('Device list request returned token error; re-authenticating and retrying (pyvesync parity)', {
567
+ // Recoverable: re-authenticate and retry. Logged at debug because the retry below
568
+ // normally succeeds; the outcome is reported after the loop.
569
+ logger_1.logger.debug('Device list request returned a token error; re-authenticating and retrying', {
525
570
  code,
526
571
  msg: response === null || response === void 0 ? void 0 : response.msg,
527
572
  region: this._region,
@@ -529,6 +574,7 @@ class VeSync {
529
574
  });
530
575
  const reloginOk = await this.login();
531
576
  if (!reloginOk) {
577
+ logger_1.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).');
532
578
  this.enabled = false;
533
579
  return false;
534
580
  }
@@ -562,6 +608,19 @@ class VeSync {
562
608
  }
563
609
  break;
564
610
  }
611
+ if (didRetryToken) {
612
+ if (response && status === 200 && response.code === 0) {
613
+ logger_1.logger.info('VeSync session had expired; re-authenticated and recovered automatically.');
614
+ }
615
+ else {
616
+ logger_1.logger.warn('VeSync session had expired and the request still failed after re-authenticating.', {
617
+ code: response === null || response === void 0 ? void 0 : response.code,
618
+ msg: response === null || response === void 0 ? void 0 : response.msg,
619
+ region: this._region,
620
+ endpoint: this.apiBaseUrl
621
+ });
622
+ }
623
+ }
565
624
  if (!response) {
566
625
  logger_1.logger.error('No response received from VeSync API');
567
626
  return false;
@@ -792,6 +851,21 @@ class VeSync {
792
851
  this._loginPromise = null;
793
852
  }
794
853
  }
854
+ /**
855
+ * True when the current token carries an `exp` claim that has already passed.
856
+ *
857
+ * Deliberately conservative: a token that cannot be decoded, or that has no `exp`, is treated as
858
+ * usable and left to the API to accept or reject.
859
+ */
860
+ isTokenExpired(skewSeconds = 60) {
861
+ var _a;
862
+ if (!this.token)
863
+ return false;
864
+ const exp = (_a = (0, session_1.decodeJwtTimestamps)(this.token)) === null || _a === void 0 ? void 0 : _a.exp;
865
+ if (!exp)
866
+ return false;
867
+ return exp * 1000 <= Date.now() + skewSeconds * 1000;
868
+ }
795
869
  /**
796
870
  * Test if update interval has been exceeded
797
871
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsvesync",
3
- "version": "1.4.11",
3
+ "version": "1.5.1",
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",
@@ -10,7 +10,8 @@
10
10
  "test:everest": "ts-node test/everest-air-parity.test.ts",
11
11
  "prepare": "npm run build",
12
12
  "version": "git add -A src",
13
- "postversion": "git push && git push --tags"
13
+ "postversion": "git push && git push --tags",
14
+ "test:token-refresh": "ts-node test/token-refresh-retry.test.ts"
14
15
  },
15
16
  "keywords": [
16
17
  "vesync",
@@ -97,8 +97,59 @@ function isTokenExpiredMessage(msg: unknown): boolean {
97
97
 
98
98
  type CallApiOptions = {
99
99
  didRetryAuth?: boolean;
100
+ /**
101
+ * Set on requests that are themselves part of the authentication flow.
102
+ *
103
+ * Those must never trigger the re-login retry in `callApi`: `VeSync.login()` de-duplicates on an
104
+ * in-flight promise, so calling it from inside the auth flow makes that promise await itself and it
105
+ * never settles (a silent hang rather than an error).
106
+ */
107
+ skipAuthRetry?: boolean;
100
108
  };
101
109
 
110
+ /**
111
+ * Auth material is snapshotted into the request body (`reqBodyAuth`) and headers (`reqHeaders`) at the
112
+ * moment they are built. A request retried after a re-login therefore still carries the *old* token, so it
113
+ * fails with the very same token error that triggered the retry. Refresh those fields from the manager
114
+ * before retrying.
115
+ *
116
+ * Only keys that are already present with a non-empty string value are replaced. That leaves auth-flow
117
+ * payloads — which deliberately send `token: ''` / `accountID: ''` — untouched.
118
+ */
119
+ function refreshAuthMaterial(
120
+ data: any,
121
+ headers: Record<string, string>,
122
+ manager: VeSync
123
+ ): [any, Record<string, string>] {
124
+ const token = manager.token;
125
+ const accountId = manager.accountId;
126
+ if (!token || !accountId) {
127
+ return [data, headers];
128
+ }
129
+
130
+ const replace = (target: Record<string, any>, key: string, value: string): void => {
131
+ if (typeof target[key] === 'string' && target[key].length > 0) {
132
+ target[key] = value;
133
+ }
134
+ };
135
+
136
+ let nextData = data;
137
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
138
+ nextData = { ...data };
139
+ replace(nextData, 'token', token);
140
+ replace(nextData, 'accountID', accountId);
141
+ replace(nextData, 'accountId', accountId);
142
+ }
143
+
144
+ const nextHeaders = { ...headers };
145
+ replace(nextHeaders, 'tk', token);
146
+ replace(nextHeaders, 'token', token);
147
+ replace(nextHeaders, 'accountId', accountId);
148
+ replace(nextHeaders, 'accountID', accountId);
149
+
150
+ return [nextData, nextHeaders];
151
+ }
152
+
102
153
  /**
103
154
  * Get list of country codes that use the US endpoint
104
155
  * These are tried when we get a cross-region error
@@ -564,18 +615,26 @@ export class Helpers {
564
615
  const responseMsg = response.data?.msg;
565
616
  if (
566
617
  !didRetryAuth &&
618
+ !options.skipAuthRetry &&
567
619
  response.status === 200 &&
568
620
  (
569
621
  (typeof responseCode === 'number' && isTokenError(responseCode)) ||
570
622
  isTokenExpiredMessage(responseMsg)
571
623
  )
572
624
  ) {
573
- logger.debug('Token expired, attempting to re-login...');
625
+ logger.debug('VeSync rejected the session token; re-authenticating before retrying', {
626
+ endpoint,
627
+ code: responseCode
628
+ });
574
629
 
575
630
  if (await manager.login()) {
576
- logger.debug('Re-login successful, retrying original request...');
577
- return await this.callApi(endpoint, method, data, headers, manager, { ...options, didRetryAuth: true });
631
+ // Rebuild the auth fields; the original body/headers still hold the rejected token.
632
+ const [retryData, retryHeaders] = refreshAuthMaterial(data, headers, manager);
633
+ logger.debug('Re-authentication succeeded, retrying request with the refreshed token');
634
+ return await this.callApi(endpoint, method, retryData, retryHeaders, manager, { ...options, didRetryAuth: true });
578
635
  }
636
+
637
+ logger.debug('Re-authentication failed; returning the original token error to the caller');
579
638
  }
580
639
 
581
640
  return [response.data, response.status];
@@ -587,21 +646,29 @@ export class Helpers {
587
646
  const httpStatus = error.response.status;
588
647
  const msg: string | undefined = responseData?.msg;
589
648
  const didRetryAuth = options.didRetryAuth === true;
590
- if (!didRetryAuth && (
649
+ if (!didRetryAuth && !options.skipAuthRetry && (
591
650
  httpStatus === 401 ||
592
651
  httpStatus === 419 ||
593
652
  responseData?.code === 4001004 ||
594
653
  (typeof responseData?.code === 'number' && isTokenError(responseData.code)) ||
595
654
  isTokenExpiredMessage(msg)
596
655
  )) {
597
- logger.debug('Token expired, attempting to re-login...');
598
-
656
+ logger.debug('VeSync rejected the session token; re-authenticating before retrying', {
657
+ endpoint,
658
+ status: httpStatus,
659
+ code: responseData?.code
660
+ });
661
+
599
662
  // Re-login
600
663
  if (await manager.login()) {
601
- // Retry the original request
602
- logger.debug('Re-login successful, retrying original request...');
603
- return await this.callApi(endpoint, method, data, headers, manager, { ...options, didRetryAuth: true });
664
+ // Retry the original request with refreshed auth fields; the original body/headers
665
+ // still hold the rejected token.
666
+ const [retryData, retryHeaders] = refreshAuthMaterial(data, headers, manager);
667
+ logger.debug('Re-authentication succeeded, retrying request with the refreshed token');
668
+ return await this.callApi(endpoint, method, retryData, retryHeaders, manager, { ...options, didRetryAuth: true });
604
669
  }
670
+
671
+ logger.debug('Re-authentication failed; returning the original token error to the caller');
605
672
  }
606
673
 
607
674
  // Log specific error details for debugging
@@ -697,7 +764,9 @@ export class Helpers {
697
764
  static async authNewFlow(manager: VeSync, appId: string, region: string = 'US', countryCodeOverride?: string): Promise<[boolean, string | null, string | null, string | null]> {
698
765
  try {
699
766
  // Generate terminal ID to be used in both steps
700
- const terminalId = generateTerminalId();
767
+ // Reuse the manager's stable terminal id. VeSync binds issued tokens to it (the JWT carries a
768
+ // `terminalId` claim), and a fresh id on every login makes each login look like a new device.
769
+ const terminalId = manager.terminalId || generateTerminalId();
701
770
  // pyvesync parity:
702
771
  // - `userCountryCode` defaults to `DEFAULT_REGION` ("US"), even if we first try the EU base URL.
703
772
  // - If the account truly belongs to another region, the Step 2 response includes the correct
@@ -729,7 +798,8 @@ export class Helpers {
729
798
  'post',
730
799
  step1Body,
731
800
  step1Headers,
732
- manager
801
+ manager,
802
+ { skipAuthRetry: true }
733
803
  );
734
804
 
735
805
  if (!authResponse || authStatus !== 200) {
@@ -795,7 +865,8 @@ export class Helpers {
795
865
  'post',
796
866
  step2Body,
797
867
  step1Headers,
798
- manager
868
+ manager,
869
+ { skipAuthRetry: true }
799
870
  );
800
871
 
801
872
  logger.debug('Step 2 response:', {
@@ -852,7 +923,8 @@ export class Helpers {
852
923
  'post',
853
924
  retryBody,
854
925
  step1Headers,
855
- manager
926
+ manager,
927
+ { skipAuthRetry: true }
856
928
  );
857
929
 
858
930
  if (retryResponse && retryStatus === 200 && retryResponse.code === 0) {
@@ -919,7 +991,8 @@ export class Helpers {
919
991
  'post',
920
992
  body,
921
993
  {},
922
- manager
994
+ manager,
995
+ { skipAuthRetry: true }
923
996
  );
924
997
 
925
998
  if (!response || status !== 200) {
@@ -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
  */