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.
- package/dist/lib/fans/airBypass.js +9 -1
- package/dist/lib/helpers.d.ts +8 -0
- package/dist/lib/helpers.js +63 -13
- package/dist/lib/session.d.ts +8 -0
- package/dist/lib/vesync.d.ts +23 -0
- package/dist/lib/vesync.js +75 -1
- package/dist/lib/vesyncFan.js +5 -5
- package/package.json +7 -3
- package/src/lib/fans/airBypass.ts +8 -1
- package/src/lib/helpers.ts +87 -14
- package/src/lib/session.ts +8 -0
- package/src/lib/vesync.ts +79 -2
- package/src/lib/vesyncFan.ts +5 -5
- package/src/lib/fans/airBypass.ts.bak +0 -890
|
@@ -198,7 +198,15 @@ class VeSyncAirBypass extends vesyncFan_1.VeSyncFan {
|
|
|
198
198
|
}
|
|
199
199
|
}, helpers_1.Helpers.reqHeaderBypass());
|
|
200
200
|
const success = this.checkResponse([response, status], 'changeFanSpeed');
|
|
201
|
-
if (
|
|
201
|
+
if (success) {
|
|
202
|
+
// Reflect the change locally so callers don't read a stale speed
|
|
203
|
+
// until the next getDetails(). setLevel also puts the device into
|
|
204
|
+
// manual mode and turns it on, mirroring pyvesync's set_fan_speed.
|
|
205
|
+
this.details.speed = speed;
|
|
206
|
+
this.details.mode = 'manual';
|
|
207
|
+
this.deviceStatus = 'on';
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
202
210
|
logger_1.logger.error(`Failed to change fan speed to ${speed} for device: ${this.deviceName}`);
|
|
203
211
|
}
|
|
204
212
|
return success;
|
package/dist/lib/helpers.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/helpers.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
476
|
-
|
|
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('
|
|
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
|
-
|
|
498
|
-
|
|
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
|
-
|
|
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];
|
package/dist/lib/session.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/vesync.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/lib/vesync.js
CHANGED
|
@@ -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
|
-
|
|
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/dist/lib/vesyncFan.js
CHANGED
|
@@ -13,13 +13,13 @@ exports.fanConfig = {
|
|
|
13
13
|
'Core200S': {
|
|
14
14
|
module: 'VeSyncAirBypass',
|
|
15
15
|
features: ['display', 'child_lock', 'night_light', 'timer', 'fan_speed', 'sleep_mode', 'filter_life'],
|
|
16
|
-
levels: [1, 2, 3
|
|
16
|
+
levels: [1, 2, 3], // Low/Med/High; sleep is a mode, not a level
|
|
17
17
|
modes: ['sleep', 'manual']
|
|
18
18
|
},
|
|
19
19
|
'Core300S': {
|
|
20
20
|
module: 'VeSyncAirBypass',
|
|
21
21
|
features: ['display', 'child_lock', 'night_light', 'air_quality', 'timer', 'fan_speed', 'auto_mode', 'sleep_mode', 'filter_life'],
|
|
22
|
-
levels: [1, 2, 3
|
|
22
|
+
levels: [1, 2, 3],
|
|
23
23
|
modes: ['sleep', 'manual', 'auto'],
|
|
24
24
|
autoPreferences: ['default', 'efficient', 'quiet']
|
|
25
25
|
},
|
|
@@ -53,21 +53,21 @@ exports.fanConfig = {
|
|
|
53
53
|
'LAP-C301S-WJP': {
|
|
54
54
|
module: 'VeSyncAirBypass',
|
|
55
55
|
features: ['display', 'child_lock', 'night_light', 'air_quality', 'timer', 'fan_speed', 'auto_mode', 'sleep_mode', 'filter_life'],
|
|
56
|
-
levels: [1, 2, 3
|
|
56
|
+
levels: [1, 2, 3],
|
|
57
57
|
modes: ['sleep', 'manual', 'auto'],
|
|
58
58
|
autoPreferences: ['default', 'efficient', 'quiet']
|
|
59
59
|
},
|
|
60
60
|
'LAP-C302S-WUSB': {
|
|
61
61
|
module: 'VeSyncAirBypass',
|
|
62
62
|
features: ['display', 'child_lock', 'night_light', 'air_quality', 'timer', 'fan_speed', 'auto_mode', 'sleep_mode', 'filter_life'],
|
|
63
|
-
levels: [1, 2, 3
|
|
63
|
+
levels: [1, 2, 3],
|
|
64
64
|
modes: ['sleep', 'manual', 'auto'],
|
|
65
65
|
autoPreferences: ['default', 'efficient', 'quiet']
|
|
66
66
|
},
|
|
67
67
|
'LAP-C301S-WAAA': {
|
|
68
68
|
module: 'VeSyncAirBypass',
|
|
69
69
|
features: ['display', 'child_lock', 'night_light', 'air_quality', 'timer', 'fan_speed', 'auto_mode', 'sleep_mode', 'filter_life'],
|
|
70
|
-
levels: [1, 2, 3
|
|
70
|
+
levels: [1, 2, 3],
|
|
71
71
|
modes: ['sleep', 'manual', 'auto'],
|
|
72
72
|
autoPreferences: ['default', 'efficient', 'quiet']
|
|
73
73
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tsvesync",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.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",
|
|
@@ -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",
|
|
@@ -51,6 +52,9 @@
|
|
|
51
52
|
"files": [
|
|
52
53
|
"dist/**/*",
|
|
53
54
|
"src/**/*",
|
|
54
|
-
"api/**/*"
|
|
55
|
+
"api/**/*",
|
|
56
|
+
"!**/*.bak",
|
|
57
|
+
"!**/*.orig",
|
|
58
|
+
"!**/*.rej"
|
|
55
59
|
]
|
|
56
60
|
}
|
|
@@ -233,7 +233,14 @@ export class VeSyncAirBypass extends VeSyncFan {
|
|
|
233
233
|
);
|
|
234
234
|
|
|
235
235
|
const success = this.checkResponse([response, status], 'changeFanSpeed');
|
|
236
|
-
if (
|
|
236
|
+
if (success) {
|
|
237
|
+
// Reflect the change locally so callers don't read a stale speed
|
|
238
|
+
// until the next getDetails(). setLevel also puts the device into
|
|
239
|
+
// manual mode and turns it on, mirroring pyvesync's set_fan_speed.
|
|
240
|
+
this.details.speed = speed;
|
|
241
|
+
this.details.mode = 'manual';
|
|
242
|
+
this.deviceStatus = 'on';
|
|
243
|
+
} else {
|
|
237
244
|
logger.error(`Failed to change fan speed to ${speed} for device: ${this.deviceName}`);
|
|
238
245
|
}
|
|
239
246
|
return success;
|
package/src/lib/helpers.ts
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
577
|
-
|
|
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('
|
|
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
|
-
|
|
603
|
-
|
|
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
|
-
|
|
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) {
|