homebridge-tsvesync 1.1.2 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +299 -0
- package/dist/accessories/air-purifier.accessory.d.ts +1 -0
- package/dist/accessories/air-purifier.accessory.d.ts.map +1 -1
- package/dist/accessories/air-purifier.accessory.js +38 -26
- package/dist/accessories/air-purifier.accessory.js.map +1 -1
- package/dist/platform.d.ts +18 -0
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +205 -10
- package/dist/platform.js.map +1 -1
- package/dist/utils/api-proxy.d.ts +4 -1
- package/dist/utils/api-proxy.d.ts.map +1 -1
- package/dist/utils/api-proxy.js +5 -3
- package/dist/utils/api-proxy.js.map +1 -1
- package/dist/utils/session-store.d.ts +28 -0
- package/dist/utils/session-store.d.ts.map +1 -0
- package/dist/utils/session-store.js +92 -0
- package/dist/utils/session-store.js.map +1 -0
- package/package.json +2 -2
- package/src/accessories/air-purifier.accessory.ts +38 -22
- package/src/platform.ts +202 -8
- package/src/utils/api-proxy.ts +8 -2
- package/src/utils/session-store.ts +92 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-store.js","sourceRoot":"","sources":["../../src/utils/session-store.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-tsvesync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"description": "Homebridge plugin for VeSync devices including Levoit air purifiers, humidifiers, and Etekcity smart outlets",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
"axios": "^1.6.5",
|
|
99
99
|
"dotenv": "^16.3.1",
|
|
100
100
|
"js-yaml": "^4.1.0",
|
|
101
|
-
"tsvesync": "1.
|
|
101
|
+
"tsvesync": "1.3.3"
|
|
102
102
|
},
|
|
103
103
|
"devDependencies": {
|
|
104
104
|
"@types/jest": "^29.5.11",
|
|
@@ -291,13 +291,20 @@ export class AirPurifierAccessory extends BaseAccessory {
|
|
|
291
291
|
return 3;
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
private isCore200S(): boolean {
|
|
295
|
+
return (this.device.deviceType || '').toUpperCase().includes('CORE200S');
|
|
296
|
+
}
|
|
297
|
+
|
|
294
298
|
/**
|
|
295
299
|
* Calculate the appropriate step size for discrete speed levels
|
|
296
300
|
*/
|
|
297
301
|
private calculateRotationSpeedStep(): number {
|
|
298
|
-
// Expose Sleep as the first notch when supported
|
|
302
|
+
// Expose Sleep as the first notch when supported.
|
|
303
|
+
// Core 200S uses 3 manual speeds (25% step). 4-speed models use 20% step.
|
|
299
304
|
if (this.hasFeature('sleep_mode')) {
|
|
300
|
-
return 25;
|
|
305
|
+
if (this.isCore200S()) return 25;
|
|
306
|
+
const max = this.getMaxFanSpeed();
|
|
307
|
+
return max >= 4 ? 20 : 25;
|
|
301
308
|
}
|
|
302
309
|
const maxSpeed = this.getMaxFanSpeed();
|
|
303
310
|
if (maxSpeed === 3) return 33.33;
|
|
@@ -309,12 +316,14 @@ export class AirPurifierAccessory extends BaseAccessory {
|
|
|
309
316
|
* Convert device speed to percentage
|
|
310
317
|
*/
|
|
311
318
|
private speedToPercentage(speed: number): number {
|
|
312
|
-
// Sleep as first notch:
|
|
319
|
+
// Sleep as first notch: map manual speeds to notches based on model
|
|
313
320
|
if (this.hasFeature('sleep_mode')) {
|
|
314
321
|
if (typeof speed !== 'number' || speed <= 0) return 0;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
322
|
+
const step = this.calculateRotationSpeedStep();
|
|
323
|
+
const max = this.isCore200S() ? 3 : this.getMaxFanSpeed();
|
|
324
|
+
// Manual speeds occupy notches 2..(max+1)
|
|
325
|
+
const notch = Math.max(2, Math.min(max + 1, speed + 1));
|
|
326
|
+
return Math.min(100, notch * step);
|
|
318
327
|
}
|
|
319
328
|
|
|
320
329
|
const maxSpeed = this.getMaxFanSpeed();
|
|
@@ -377,13 +386,15 @@ export class AirPurifierAccessory extends BaseAccessory {
|
|
|
377
386
|
* Convert percentage to device speed
|
|
378
387
|
*/
|
|
379
388
|
private percentageToSpeed(percentage: number): number {
|
|
380
|
-
// Sleep as first notch: round to nearest
|
|
389
|
+
// Sleep as first notch: round to nearest step and map notches → speeds
|
|
381
390
|
if (this.hasFeature('sleep_mode')) {
|
|
382
391
|
if (typeof percentage !== 'number') return 1;
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
392
|
+
const step = this.calculateRotationSpeedStep();
|
|
393
|
+
const max = this.isCore200S() ? 3 : this.getMaxFanSpeed();
|
|
394
|
+
const maxNotch = max + 1; // include Sleep notch
|
|
395
|
+
const notch = Math.max(0, Math.min(maxNotch, Math.round(Math.min(100, Math.max(0, percentage)) / step)));
|
|
396
|
+
const manualNotch = Math.max(2, notch);
|
|
397
|
+
return Math.min(max, manualNotch - 1);
|
|
387
398
|
}
|
|
388
399
|
|
|
389
400
|
const maxSpeed = this.getMaxFanSpeed();
|
|
@@ -642,9 +653,10 @@ export class AirPurifierAccessory extends BaseAccessory {
|
|
|
642
653
|
|
|
643
654
|
// Update rotation speed
|
|
644
655
|
if (isOn && this.hasFeature('sleep_mode') && isSleep) {
|
|
645
|
-
this.
|
|
656
|
+
const step = this.calculateRotationSpeedStep();
|
|
657
|
+
this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, step);
|
|
646
658
|
this.lastSetSpeed = 0;
|
|
647
|
-
this.lastSetPercentage =
|
|
659
|
+
this.lastSetPercentage = step;
|
|
648
660
|
} else if (isOn && details.speed !== undefined && details.speed !== null) {
|
|
649
661
|
// Check if we should skip this update
|
|
650
662
|
if (this.skipNextUpdate) {
|
|
@@ -756,7 +768,7 @@ export class AirPurifierAccessory extends BaseAccessory {
|
|
|
756
768
|
|
|
757
769
|
// For all other devices, including Core300S, normal mode handling
|
|
758
770
|
const mode = targetState === 1 ? 'auto' : 'manual';
|
|
759
|
-
this.platform.log.
|
|
771
|
+
this.platform.log.info(`Changing mode to ${mode} for device: ${this.device.deviceName}`);
|
|
760
772
|
|
|
761
773
|
let success = false;
|
|
762
774
|
|
|
@@ -795,7 +807,7 @@ export class AirPurifierAccessory extends BaseAccessory {
|
|
|
795
807
|
// If in sleep mode, surface the first notch
|
|
796
808
|
const extended = this.device as ExtendedVeSyncAirPurifier;
|
|
797
809
|
if (this.hasFeature('sleep_mode') && (extended.mode || '').toLowerCase() === 'sleep') {
|
|
798
|
-
return
|
|
810
|
+
return this.calculateRotationSpeedStep();
|
|
799
811
|
}
|
|
800
812
|
return 0;
|
|
801
813
|
}
|
|
@@ -866,10 +878,13 @@ export class AirPurifierAccessory extends BaseAccessory {
|
|
|
866
878
|
|
|
867
879
|
// When using sleep-as-first-notch, map slider notches robustly by rounding
|
|
868
880
|
if (this.hasFeature('sleep_mode')) {
|
|
869
|
-
const
|
|
881
|
+
const max = this.isCore200S() ? 3 : this.getMaxFanSpeed();
|
|
882
|
+
const step = this.calculateRotationSpeedStep();
|
|
883
|
+
const maxNotch = max + 1; // include Sleep notch
|
|
884
|
+
const notch = Math.max(0, Math.min(maxNotch, Math.round(percentage / step)));
|
|
870
885
|
if (notch <= 1) {
|
|
871
886
|
// Sleep
|
|
872
|
-
this.platform.log.
|
|
887
|
+
this.platform.log.info(`Changing mode to sleep for device: ${this.device.deviceName}`);
|
|
873
888
|
let ok = false;
|
|
874
889
|
if (typeof extendedDevice.sleepMode === 'function') {
|
|
875
890
|
ok = await extendedDevice.sleepMode();
|
|
@@ -880,19 +895,20 @@ export class AirPurifierAccessory extends BaseAccessory {
|
|
|
880
895
|
throw new Error('Failed to set sleep mode');
|
|
881
896
|
}
|
|
882
897
|
// Immediate UI feedback
|
|
883
|
-
this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed,
|
|
898
|
+
this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, step);
|
|
884
899
|
this.service.updateCharacteristic(this.platform.Characteristic.Active, 1);
|
|
885
900
|
this.service.updateCharacteristic(this.platform.Characteristic.CurrentAirPurifierState, 2);
|
|
886
901
|
this.lastSetSpeed = 0;
|
|
887
|
-
this.lastSetPercentage =
|
|
902
|
+
this.lastSetPercentage = step;
|
|
888
903
|
this.skipNextUpdate = true;
|
|
889
904
|
return;
|
|
890
905
|
} else {
|
|
891
|
-
// Map notches 2
|
|
892
|
-
const desiredSpeed = Math.min(
|
|
906
|
+
// Map manual notches (2..maxNotch) to speeds 1..max
|
|
907
|
+
const desiredSpeed = Math.min(max, notch - 1);
|
|
893
908
|
// Ensure manual mode before speed change if necessary
|
|
894
909
|
const modeNow = (extendedDevice.mode || '').toLowerCase();
|
|
895
910
|
if (modeNow === 'auto' || modeNow === 'sleep') {
|
|
911
|
+
this.platform.log.info(`Changing mode to manual for device: ${this.device.deviceName}`);
|
|
896
912
|
if (typeof extendedDevice.manualMode === 'function') {
|
|
897
913
|
await extendedDevice.manualMode();
|
|
898
914
|
} else if (typeof extendedDevice.setMode === 'function') {
|
|
@@ -900,7 +916,7 @@ export class AirPurifierAccessory extends BaseAccessory {
|
|
|
900
916
|
}
|
|
901
917
|
}
|
|
902
918
|
// Update UI early
|
|
903
|
-
const uiPct = desiredSpeed
|
|
919
|
+
const uiPct = Math.min(100, (desiredSpeed + 1) * step);
|
|
904
920
|
this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, uiPct);
|
|
905
921
|
this.lastSetSpeed = desiredSpeed;
|
|
906
922
|
this.lastSetPercentage = uiPct;
|
package/src/platform.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { BaseAccessory } from './accessories/base.accessory';
|
|
|
6
6
|
import { PluginLogger } from './utils/logger';
|
|
7
7
|
import { createRateLimitedVeSync } from './utils/api-proxy';
|
|
8
8
|
import { PlatformConfig as TSVESyncPlatformConfig } from './types/device.types';
|
|
9
|
+
import { FileSessionStore, decodeJwtTimestampsLocal } from './utils/session-store';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* HomebridgePlatform
|
|
@@ -24,6 +25,10 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
|
|
|
24
25
|
|
|
25
26
|
private client!: VeSync;
|
|
26
27
|
private deviceUpdateInterval?: NodeJS.Timeout;
|
|
28
|
+
private refreshTimer?: NodeJS.Timeout;
|
|
29
|
+
private refreshInProgress = false;
|
|
30
|
+
private scheduledExpMs: number | null = null;
|
|
31
|
+
private refreshRemainingMs: number | null = null;
|
|
27
32
|
private readonly updateInterval!: number;
|
|
28
33
|
private readonly debug!: boolean;
|
|
29
34
|
private lastLoginAttempt: Date = new Date(0);
|
|
@@ -32,6 +37,7 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
|
|
|
32
37
|
private initializationResolver!: () => void;
|
|
33
38
|
private isInitialized = false;
|
|
34
39
|
private readonly logger!: PluginLogger;
|
|
40
|
+
private readonly sessionStore!: FileSessionStore;
|
|
35
41
|
// VeSync JWT tokens are valid for 30 days (verified by decoding the JWT)
|
|
36
42
|
// We'll refresh at 25 days to ensure we never hit expiration
|
|
37
43
|
private readonly TOKEN_EXPIRY = 25 * 24 * 60 * 60 * 1000; // 25 days in milliseconds
|
|
@@ -60,6 +66,9 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
|
|
|
60
66
|
return;
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
// Prepare session store
|
|
70
|
+
this.sessionStore = new FileSessionStore(this.api.user.storagePath(), this.logger);
|
|
71
|
+
|
|
63
72
|
// Initialize VeSync client with all configuration
|
|
64
73
|
this.client = createRateLimitedVeSync(
|
|
65
74
|
config.username,
|
|
@@ -73,6 +82,10 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
|
|
|
73
82
|
{
|
|
74
83
|
countryCode: config.countryCode,
|
|
75
84
|
quotaManagement: config.quotaManagement || { enabled: true }
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
store: this.sessionStore,
|
|
88
|
+
onTokenChange: (s) => this.onTokenChange(s)
|
|
76
89
|
}
|
|
77
90
|
);
|
|
78
91
|
|
|
@@ -91,6 +104,29 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
|
|
|
91
104
|
this.logger.debug('Executed didFinishLaunching callback');
|
|
92
105
|
|
|
93
106
|
try {
|
|
107
|
+
// Try to hydrate session from disk before any login
|
|
108
|
+
const session = await this.sessionStore.load();
|
|
109
|
+
if (session) {
|
|
110
|
+
try {
|
|
111
|
+
if ((session as any).username && (session as any).username !== this.config.username) {
|
|
112
|
+
this.logger.info('Found persisted session for a different account; ignoring persisted session.');
|
|
113
|
+
} else {
|
|
114
|
+
this.hydrateSessionCompat(session);
|
|
115
|
+
const ts = decodeJwtTimestampsLocal(session.token);
|
|
116
|
+
// Use actual token issuance time if available to avoid overextending lifetime
|
|
117
|
+
this.lastTokenRefresh = ts?.iat ? new Date(ts.iat * 1000) : new Date();
|
|
118
|
+
const expStr = ts?.exp ? new Date(ts.exp * 1000).toISOString() : 'unknown';
|
|
119
|
+
this.logger.info(`Reusing persisted VeSync session. Token exp: ${expStr}`);
|
|
120
|
+
// Schedule a proactive refresh before expiry
|
|
121
|
+
this.scheduleProactiveRefreshFromToken(session.token);
|
|
122
|
+
}
|
|
123
|
+
} catch (e: any) {
|
|
124
|
+
this.logger.debug(`Failed to hydrate persisted session, will login fresh: ${e?.message || e}`);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
this.logger.debug('No persisted VeSync session available; will authenticate.');
|
|
128
|
+
}
|
|
129
|
+
|
|
94
130
|
// Initialize platform
|
|
95
131
|
await this.initializePlatform();
|
|
96
132
|
|
|
@@ -116,6 +152,9 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
|
|
|
116
152
|
if (this.deviceUpdateInterval) {
|
|
117
153
|
clearInterval(this.deviceUpdateInterval);
|
|
118
154
|
}
|
|
155
|
+
if (this.refreshTimer) {
|
|
156
|
+
clearTimeout(this.refreshTimer);
|
|
157
|
+
}
|
|
119
158
|
});
|
|
120
159
|
}
|
|
121
160
|
|
|
@@ -146,12 +185,15 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
|
|
|
146
185
|
*/
|
|
147
186
|
private async initializePlatform(): Promise<void> {
|
|
148
187
|
try {
|
|
149
|
-
//
|
|
150
|
-
if
|
|
151
|
-
|
|
188
|
+
// If we don't have a token/account yet, perform a login once. Otherwise,
|
|
189
|
+
// trust the persisted token and let the library re-login only if the API rejects it.
|
|
190
|
+
if (!(this.client as any).token || !(this.client as any).accountId) {
|
|
191
|
+
if (!await this.ensureLogin()) {
|
|
192
|
+
throw new Error('Failed to login to VeSync API');
|
|
193
|
+
}
|
|
152
194
|
}
|
|
153
195
|
|
|
154
|
-
// Get devices from API
|
|
196
|
+
// Get devices from API (will re-login on 401/419 only)
|
|
155
197
|
await this.client.update();
|
|
156
198
|
|
|
157
199
|
// Discover devices
|
|
@@ -255,6 +297,26 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
|
|
|
255
297
|
// Reset backoff and update token refresh time on successful login
|
|
256
298
|
this.loginBackoffTime = 10000;
|
|
257
299
|
this.lastTokenRefresh = new Date();
|
|
300
|
+
// Best-effort: persist the fresh session immediately in case callbacks fail
|
|
301
|
+
try {
|
|
302
|
+
const token = (this.client as any).token as string | null;
|
|
303
|
+
const accountId = (this.client as any).accountId as string | null;
|
|
304
|
+
const region = (this.client as any).region as string | null;
|
|
305
|
+
const apiBaseUrl = (this.client as any).apiBaseUrl as string | null;
|
|
306
|
+
if (token && accountId && region && apiBaseUrl) {
|
|
307
|
+
const ts = decodeJwtTimestampsLocal(token);
|
|
308
|
+
await this.sessionStore.save({
|
|
309
|
+
token,
|
|
310
|
+
accountId,
|
|
311
|
+
region,
|
|
312
|
+
apiBaseUrl,
|
|
313
|
+
issuedAt: ts?.iat ?? null,
|
|
314
|
+
expiresAt: ts?.exp ?? null,
|
|
315
|
+
lastValidatedAt: Date.now(),
|
|
316
|
+
username: this.config.username,
|
|
317
|
+
} as any);
|
|
318
|
+
}
|
|
319
|
+
} catch {/* ignore */}
|
|
258
320
|
isLoggedIn = true;
|
|
259
321
|
return true;
|
|
260
322
|
} catch (error) {
|
|
@@ -285,6 +347,141 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
|
|
|
285
347
|
await this.discoverDevices();
|
|
286
348
|
}
|
|
287
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Handle token updates from the library
|
|
352
|
+
*/
|
|
353
|
+
private onTokenChange(session: { token: string } | undefined) {
|
|
354
|
+
if (!session?.token) return;
|
|
355
|
+
this.scheduleProactiveRefreshFromToken(session.token);
|
|
356
|
+
// Persist with username to ensure correct account reuse
|
|
357
|
+
try {
|
|
358
|
+
void this.sessionStore.save({ ...(session as any), username: this.config.username } as any);
|
|
359
|
+
} catch { /* noop */ }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Backward-compatible session hydration when using older tsvesync versions
|
|
364
|
+
*/
|
|
365
|
+
private hydrateSessionCompat(session: { token: string; accountId: string; countryCode?: string | null; apiBaseUrl?: string; region?: string }) {
|
|
366
|
+
const client: any = this.client as any;
|
|
367
|
+
if (typeof client.hydrateSession === 'function') {
|
|
368
|
+
client.hydrateSession(session);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// Fallback: set core fields directly
|
|
372
|
+
client.token = session.token;
|
|
373
|
+
client.accountId = session.accountId;
|
|
374
|
+
client.countryCode = session.countryCode ?? null;
|
|
375
|
+
if (session.apiBaseUrl) {
|
|
376
|
+
client.apiBaseUrl = session.apiBaseUrl;
|
|
377
|
+
}
|
|
378
|
+
if (session.region) {
|
|
379
|
+
try { client.region = session.region; } catch { /* ignore */ }
|
|
380
|
+
}
|
|
381
|
+
client.enabled = true;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Schedule a proactive token refresh before JWT expiry
|
|
386
|
+
*/
|
|
387
|
+
private scheduleProactiveRefreshFromToken(token: string) {
|
|
388
|
+
try {
|
|
389
|
+
const ts = decodeJwtTimestampsLocal(token);
|
|
390
|
+
if (!ts?.exp) {
|
|
391
|
+
return; // Cannot schedule without exp
|
|
392
|
+
}
|
|
393
|
+
const now = Date.now();
|
|
394
|
+
const expMs = ts.exp * 1000;
|
|
395
|
+
const msToExpiry = expMs - now;
|
|
396
|
+
|
|
397
|
+
// If we already have a timer for this exact token expiration, skip
|
|
398
|
+
if (this.scheduledExpMs === expMs && this.refreshTimer) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (msToExpiry <= 0) {
|
|
402
|
+
// Already expired; trigger immediate login in background
|
|
403
|
+
void this.ensureLogin(true);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Schedule policy to prevent thrash and avoid frequent logins:
|
|
408
|
+
// - If >7d left: refresh 5d before expiry
|
|
409
|
+
// - If 1–7d left: refresh 12h before expiry
|
|
410
|
+
// - If 1–24h left: refresh 1h before expiry
|
|
411
|
+
// - If <1h left: do not proactively refresh; rely on library's 401-triggered re-login
|
|
412
|
+
const ONE_HOUR = 60 * 60 * 1000;
|
|
413
|
+
const TWELVE_HOURS = 12 * ONE_HOUR;
|
|
414
|
+
const FIVE_DAYS = 5 * 24 * ONE_HOUR;
|
|
415
|
+
const SEVEN_DAYS = 7 * 24 * ONE_HOUR;
|
|
416
|
+
|
|
417
|
+
let refreshIn: number;
|
|
418
|
+
if (msToExpiry > SEVEN_DAYS) {
|
|
419
|
+
refreshIn = msToExpiry - FIVE_DAYS;
|
|
420
|
+
} else if (msToExpiry > 24 * ONE_HOUR) {
|
|
421
|
+
refreshIn = msToExpiry - TWELVE_HOURS;
|
|
422
|
+
} else if (msToExpiry > ONE_HOUR) {
|
|
423
|
+
refreshIn = msToExpiry - ONE_HOUR;
|
|
424
|
+
} else {
|
|
425
|
+
// Too close to expiry; avoid hammering login — let 401 path handle it
|
|
426
|
+
this.logger.debug('Token near expiry (<1h). Skipping proactive refresh; relying on auto re-login.');
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Safety floor: never schedule earlier than 30 minutes from now
|
|
431
|
+
refreshIn = Math.max(refreshIn, 30 * 60 * 1000);
|
|
432
|
+
|
|
433
|
+
if (this.refreshTimer) {
|
|
434
|
+
clearTimeout(this.refreshTimer);
|
|
435
|
+
}
|
|
436
|
+
this.scheduledExpMs = expMs;
|
|
437
|
+
// Handle Node.js setTimeout max delay (~24.8 days). Chain timers when needed.
|
|
438
|
+
const MAX_DELAY = 0x7fffffff; // 2,147,483,647 ms
|
|
439
|
+
if (refreshIn > MAX_DELAY) {
|
|
440
|
+
this.refreshRemainingMs = refreshIn - MAX_DELAY;
|
|
441
|
+
this.logger.debug('Proactive refresh scheduled beyond setTimeout max; chaining timers.');
|
|
442
|
+
this.refreshTimer = setTimeout(() => this.chainRefreshTimer(), MAX_DELAY);
|
|
443
|
+
} else {
|
|
444
|
+
this.refreshRemainingMs = 0;
|
|
445
|
+
this.refreshTimer = setTimeout(async () => {
|
|
446
|
+
if (this.refreshInProgress) {
|
|
447
|
+
this.logger.debug('Proactive refresh already in progress; skipping.');
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
this.refreshInProgress = true;
|
|
451
|
+
this.logger.debug('Proactively refreshing VeSync session before token expiry');
|
|
452
|
+
try {
|
|
453
|
+
await this.ensureLogin(true);
|
|
454
|
+
} finally {
|
|
455
|
+
this.refreshInProgress = false;
|
|
456
|
+
}
|
|
457
|
+
}, refreshIn);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const hours = Math.round(refreshIn / (60 * 60 * 1000));
|
|
461
|
+
this.logger.debug(`Scheduled proactive token refresh in ~${hours}h`);
|
|
462
|
+
} catch (e) {
|
|
463
|
+
// Best-effort scheduling; ignore errors
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private chainRefreshTimer() {
|
|
468
|
+
if (!this.refreshRemainingMs || this.refreshRemainingMs <= 0) {
|
|
469
|
+
// Final hop: trigger refresh now
|
|
470
|
+
if (this.refreshInProgress) {
|
|
471
|
+
this.logger.debug('Proactive refresh already in progress; skipping.');
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
this.refreshInProgress = true;
|
|
475
|
+
this.logger.debug('Proactively refreshing VeSync session before token expiry');
|
|
476
|
+
void this.ensureLogin(true).finally(() => { this.refreshInProgress = false; });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const MAX_DELAY = 0x7fffffff;
|
|
480
|
+
const hop = Math.min(this.refreshRemainingMs, MAX_DELAY);
|
|
481
|
+
this.refreshRemainingMs -= hop;
|
|
482
|
+
this.refreshTimer = setTimeout(() => this.chainRefreshTimer(), hop);
|
|
483
|
+
}
|
|
484
|
+
|
|
288
485
|
/**
|
|
289
486
|
* Check if a device should be excluded based on configuration
|
|
290
487
|
*/
|
|
@@ -342,10 +539,7 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
|
|
|
342
539
|
async discoverDevices() {
|
|
343
540
|
this.logger.debug('Discovering devices');
|
|
344
541
|
try {
|
|
345
|
-
//
|
|
346
|
-
if (!await this.ensureLogin()) {
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
542
|
+
// Do not force login; rely on library to re-login only if needed
|
|
349
543
|
|
|
350
544
|
let retryCount = 0;
|
|
351
545
|
let success = false;
|
package/src/utils/api-proxy.ts
CHANGED
|
@@ -200,7 +200,7 @@ const createRateLimitedProxy = (target: any, rateLimiter: RateLimiter, deviceId?
|
|
|
200
200
|
// Methods that bypass all rate limiting and debouncing
|
|
201
201
|
const bypassMethods = [
|
|
202
202
|
// Getters and internal methods
|
|
203
|
-
'get', 'getService', 'getCharacteristic',
|
|
203
|
+
'get', 'getService', 'getCharacteristic', 'hydrateSession',
|
|
204
204
|
// State accessors
|
|
205
205
|
'deviceStatus', 'speed', 'brightness', 'colorTemp',
|
|
206
206
|
'mode', 'filterLife', 'airQuality', 'airQualityValue', 'screenStatus',
|
|
@@ -258,6 +258,10 @@ export const createRateLimitedVeSync = (
|
|
|
258
258
|
bufferPercentage?: number;
|
|
259
259
|
priorityMethods?: string[];
|
|
260
260
|
}
|
|
261
|
+
},
|
|
262
|
+
session?: {
|
|
263
|
+
store?: any;
|
|
264
|
+
onTokenChange?: (s: any) => void;
|
|
261
265
|
}
|
|
262
266
|
): VeSync => {
|
|
263
267
|
// Use the new options object pattern for VeSync constructor
|
|
@@ -267,7 +271,9 @@ export const createRateLimitedVeSync = (
|
|
|
267
271
|
apiUrl: apiUrl,
|
|
268
272
|
customLogger: customLogger,
|
|
269
273
|
excludeConfig: exclusions,
|
|
270
|
-
countryCode: config?.countryCode || 'US' // Default to US
|
|
274
|
+
countryCode: config?.countryCode || 'US', // Default to US
|
|
275
|
+
sessionStore: session?.store,
|
|
276
|
+
onTokenChange: session?.onTokenChange
|
|
271
277
|
});
|
|
272
278
|
const logger = customLogger || new PluginLogger(console as any, debug || false);
|
|
273
279
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { PluginLogger } from './logger';
|
|
4
|
+
|
|
5
|
+
export interface PluginSession {
|
|
6
|
+
token: string;
|
|
7
|
+
accountId: string;
|
|
8
|
+
countryCode?: string | null;
|
|
9
|
+
region: string;
|
|
10
|
+
apiBaseUrl: string;
|
|
11
|
+
authFlowUsed?: 'legacy' | 'new';
|
|
12
|
+
issuedAt?: number | null;
|
|
13
|
+
expiresAt?: number | null;
|
|
14
|
+
lastValidatedAt?: number | null;
|
|
15
|
+
libraryVersion?: string;
|
|
16
|
+
// Optional: helps ensure session matches configured Homebridge account
|
|
17
|
+
username?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class FileSessionStore {
|
|
21
|
+
private readonly dir: string;
|
|
22
|
+
private readonly file: string;
|
|
23
|
+
|
|
24
|
+
constructor(basePath: string, private readonly logger: PluginLogger) {
|
|
25
|
+
this.dir = path.join(basePath, 'tsvesync');
|
|
26
|
+
this.file = path.join(this.dir, 'session.json');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async load(): Promise<PluginSession | null> {
|
|
30
|
+
try {
|
|
31
|
+
await fs.promises.mkdir(this.dir, { recursive: true });
|
|
32
|
+
const data = await fs.promises.readFile(this.file, 'utf8');
|
|
33
|
+
const session = JSON.parse(data) as PluginSession;
|
|
34
|
+
if (!session || !session.token || !session.accountId) return null;
|
|
35
|
+
try {
|
|
36
|
+
const expSec = session.expiresAt && session.expiresAt > 1e11 ? Math.floor(session.expiresAt / 1000) : session.expiresAt;
|
|
37
|
+
const exp = expSec ? new Date(expSec * 1000).toISOString() : 'unknown';
|
|
38
|
+
this.logger.debug(`Loaded persisted session from ${this.file} (exp: ${exp})`);
|
|
39
|
+
} catch {}
|
|
40
|
+
return session;
|
|
41
|
+
} catch (e: any) {
|
|
42
|
+
if (e?.code === 'ENOENT') {
|
|
43
|
+
this.logger.debug(`No persisted session found at ${this.file}`);
|
|
44
|
+
} else {
|
|
45
|
+
this.logger.debug(`Session load error: ${e?.message || e}`);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async save(session: PluginSession): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
await fs.promises.mkdir(this.dir, { recursive: true });
|
|
54
|
+
const tmp = this.file + '.tmp';
|
|
55
|
+
await fs.promises.writeFile(tmp, JSON.stringify(session), { encoding: 'utf8', mode: 0o600 });
|
|
56
|
+
await fs.promises.rename(tmp, this.file);
|
|
57
|
+
try { await fs.promises.chmod(this.file, 0o600); } catch { /* best effort */ }
|
|
58
|
+
try {
|
|
59
|
+
const expSec = session.expiresAt && session.expiresAt > 1e11 ? Math.floor(session.expiresAt / 1000) : session.expiresAt;
|
|
60
|
+
const exp = expSec ? new Date(expSec * 1000).toISOString() : 'unknown';
|
|
61
|
+
this.logger.debug(`Persisted session to ${this.file} (exp: ${exp})`);
|
|
62
|
+
} catch {}
|
|
63
|
+
} catch (e: any) {
|
|
64
|
+
this.logger.debug(`Session save error: ${e?.message || e}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async clear(): Promise<void> {
|
|
69
|
+
try {
|
|
70
|
+
await fs.promises.unlink(this.file);
|
|
71
|
+
} catch (e: any) {
|
|
72
|
+
if (e?.code !== 'ENOENT') {
|
|
73
|
+
this.logger.debug(`Session clear error: ${e?.message || e}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function decodeJwtTimestampsLocal(token: string): { iat?: number; exp?: number } | null {
|
|
80
|
+
try {
|
|
81
|
+
const parts = token.split('.');
|
|
82
|
+
if (parts.length !== 3) return null;
|
|
83
|
+
const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
|
|
84
|
+
let iat = typeof payload.iat === 'number' ? payload.iat : undefined;
|
|
85
|
+
let exp = typeof payload.exp === 'number' ? payload.exp : undefined;
|
|
86
|
+
if (iat && iat > 1e11) iat = Math.floor(iat / 1000);
|
|
87
|
+
if (exp && exp > 1e11) exp = Math.floor(exp / 1000);
|
|
88
|
+
return { iat, exp };
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|