homebridge-tsvesync 1.1.2 → 1.3.4
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 +312 -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/accessories/light.accessory.d.ts +25 -3
- package/dist/accessories/light.accessory.d.ts.map +1 -1
- package/dist/accessories/light.accessory.js +290 -14
- package/dist/accessories/light.accessory.js.map +1 -1
- package/dist/platform.d.ts +18 -0
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +224 -11
- package/dist/platform.js.map +1 -1
- package/dist/types/device.types.d.ts +16 -0
- package/dist/types/device.types.d.ts.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/device-factory.d.ts +1 -0
- package/dist/utils/device-factory.d.ts.map +1 -1
- package/dist/utils/device-factory.js +10 -2
- package/dist/utils/device-factory.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/accessories/light.accessory.ts +415 -34
- package/src/platform.ts +223 -9
- package/src/types/device.types.ts +18 -0
- package/src/utils/api-proxy.ts +8 -2
- package/src/utils/device-factory.ts +16 -4
- package/src/utils/session-store.ts +92 -0
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) {
|
|
@@ -282,7 +344,162 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
|
|
|
282
344
|
* Update device states periodically
|
|
283
345
|
*/
|
|
284
346
|
private async updateDeviceStates() {
|
|
285
|
-
|
|
347
|
+
if (this.refreshInProgress) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
this.refreshInProgress = true;
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
await this.discoverDevices();
|
|
355
|
+
|
|
356
|
+
const syncTasks = Array.from(this.deviceAccessories.values()).map(async accessory => {
|
|
357
|
+
try {
|
|
358
|
+
await accessory.syncDeviceState();
|
|
359
|
+
} catch (error) {
|
|
360
|
+
this.logger.warn('Failed to sync device state during scheduled refresh', error as Error);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
await Promise.all(syncTasks);
|
|
365
|
+
} finally {
|
|
366
|
+
this.refreshInProgress = false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Handle token updates from the library
|
|
372
|
+
*/
|
|
373
|
+
private onTokenChange(session: { token: string } | undefined) {
|
|
374
|
+
if (!session?.token) return;
|
|
375
|
+
this.scheduleProactiveRefreshFromToken(session.token);
|
|
376
|
+
// Persist with username to ensure correct account reuse
|
|
377
|
+
try {
|
|
378
|
+
void this.sessionStore.save({ ...(session as any), username: this.config.username } as any);
|
|
379
|
+
} catch { /* noop */ }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Backward-compatible session hydration when using older tsvesync versions
|
|
384
|
+
*/
|
|
385
|
+
private hydrateSessionCompat(session: { token: string; accountId: string; countryCode?: string | null; apiBaseUrl?: string; region?: string }) {
|
|
386
|
+
const client: any = this.client as any;
|
|
387
|
+
if (typeof client.hydrateSession === 'function') {
|
|
388
|
+
client.hydrateSession(session);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// Fallback: set core fields directly
|
|
392
|
+
client.token = session.token;
|
|
393
|
+
client.accountId = session.accountId;
|
|
394
|
+
client.countryCode = session.countryCode ?? null;
|
|
395
|
+
if (session.apiBaseUrl) {
|
|
396
|
+
client.apiBaseUrl = session.apiBaseUrl;
|
|
397
|
+
}
|
|
398
|
+
if (session.region) {
|
|
399
|
+
try { client.region = session.region; } catch { /* ignore */ }
|
|
400
|
+
}
|
|
401
|
+
client.enabled = true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Schedule a proactive token refresh before JWT expiry
|
|
406
|
+
*/
|
|
407
|
+
private scheduleProactiveRefreshFromToken(token: string) {
|
|
408
|
+
try {
|
|
409
|
+
const ts = decodeJwtTimestampsLocal(token);
|
|
410
|
+
if (!ts?.exp) {
|
|
411
|
+
return; // Cannot schedule without exp
|
|
412
|
+
}
|
|
413
|
+
const now = Date.now();
|
|
414
|
+
const expMs = ts.exp * 1000;
|
|
415
|
+
const msToExpiry = expMs - now;
|
|
416
|
+
|
|
417
|
+
// If we already have a timer for this exact token expiration, skip
|
|
418
|
+
if (this.scheduledExpMs === expMs && this.refreshTimer) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (msToExpiry <= 0) {
|
|
422
|
+
// Already expired; trigger immediate login in background
|
|
423
|
+
void this.ensureLogin(true);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Schedule policy to prevent thrash and avoid frequent logins:
|
|
428
|
+
// - If >7d left: refresh 5d before expiry
|
|
429
|
+
// - If 1–7d left: refresh 12h before expiry
|
|
430
|
+
// - If 1–24h left: refresh 1h before expiry
|
|
431
|
+
// - If <1h left: do not proactively refresh; rely on library's 401-triggered re-login
|
|
432
|
+
const ONE_HOUR = 60 * 60 * 1000;
|
|
433
|
+
const TWELVE_HOURS = 12 * ONE_HOUR;
|
|
434
|
+
const FIVE_DAYS = 5 * 24 * ONE_HOUR;
|
|
435
|
+
const SEVEN_DAYS = 7 * 24 * ONE_HOUR;
|
|
436
|
+
|
|
437
|
+
let refreshIn: number;
|
|
438
|
+
if (msToExpiry > SEVEN_DAYS) {
|
|
439
|
+
refreshIn = msToExpiry - FIVE_DAYS;
|
|
440
|
+
} else if (msToExpiry > 24 * ONE_HOUR) {
|
|
441
|
+
refreshIn = msToExpiry - TWELVE_HOURS;
|
|
442
|
+
} else if (msToExpiry > ONE_HOUR) {
|
|
443
|
+
refreshIn = msToExpiry - ONE_HOUR;
|
|
444
|
+
} else {
|
|
445
|
+
// Too close to expiry; avoid hammering login — let 401 path handle it
|
|
446
|
+
this.logger.debug('Token near expiry (<1h). Skipping proactive refresh; relying on auto re-login.');
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Safety floor: never schedule earlier than 30 minutes from now
|
|
451
|
+
refreshIn = Math.max(refreshIn, 30 * 60 * 1000);
|
|
452
|
+
|
|
453
|
+
if (this.refreshTimer) {
|
|
454
|
+
clearTimeout(this.refreshTimer);
|
|
455
|
+
}
|
|
456
|
+
this.scheduledExpMs = expMs;
|
|
457
|
+
// Handle Node.js setTimeout max delay (~24.8 days). Chain timers when needed.
|
|
458
|
+
const MAX_DELAY = 0x7fffffff; // 2,147,483,647 ms
|
|
459
|
+
if (refreshIn > MAX_DELAY) {
|
|
460
|
+
this.refreshRemainingMs = refreshIn - MAX_DELAY;
|
|
461
|
+
this.logger.debug('Proactive refresh scheduled beyond setTimeout max; chaining timers.');
|
|
462
|
+
this.refreshTimer = setTimeout(() => this.chainRefreshTimer(), MAX_DELAY);
|
|
463
|
+
} else {
|
|
464
|
+
this.refreshRemainingMs = 0;
|
|
465
|
+
this.refreshTimer = setTimeout(async () => {
|
|
466
|
+
if (this.refreshInProgress) {
|
|
467
|
+
this.logger.debug('Proactive refresh already in progress; skipping.');
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
this.refreshInProgress = true;
|
|
471
|
+
this.logger.debug('Proactively refreshing VeSync session before token expiry');
|
|
472
|
+
try {
|
|
473
|
+
await this.ensureLogin(true);
|
|
474
|
+
} finally {
|
|
475
|
+
this.refreshInProgress = false;
|
|
476
|
+
}
|
|
477
|
+
}, refreshIn);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const hours = Math.round(refreshIn / (60 * 60 * 1000));
|
|
481
|
+
this.logger.debug(`Scheduled proactive token refresh in ~${hours}h`);
|
|
482
|
+
} catch (e) {
|
|
483
|
+
// Best-effort scheduling; ignore errors
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private chainRefreshTimer() {
|
|
488
|
+
if (!this.refreshRemainingMs || this.refreshRemainingMs <= 0) {
|
|
489
|
+
// Final hop: trigger refresh now
|
|
490
|
+
if (this.refreshInProgress) {
|
|
491
|
+
this.logger.debug('Proactive refresh already in progress; skipping.');
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
this.refreshInProgress = true;
|
|
495
|
+
this.logger.debug('Proactively refreshing VeSync session before token expiry');
|
|
496
|
+
void this.ensureLogin(true).finally(() => { this.refreshInProgress = false; });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const MAX_DELAY = 0x7fffffff;
|
|
500
|
+
const hop = Math.min(this.refreshRemainingMs, MAX_DELAY);
|
|
501
|
+
this.refreshRemainingMs -= hop;
|
|
502
|
+
this.refreshTimer = setTimeout(() => this.chainRefreshTimer(), hop);
|
|
286
503
|
}
|
|
287
504
|
|
|
288
505
|
/**
|
|
@@ -342,10 +559,7 @@ export class TSVESyncPlatform implements DynamicPlatformPlugin {
|
|
|
342
559
|
async discoverDevices() {
|
|
343
560
|
this.logger.debug('Discovering devices');
|
|
344
561
|
try {
|
|
345
|
-
//
|
|
346
|
-
if (!await this.ensureLogin()) {
|
|
347
|
-
return;
|
|
348
|
-
}
|
|
562
|
+
// Do not force login; rely on library to re-login only if needed
|
|
349
563
|
|
|
350
564
|
let retryCount = 0;
|
|
351
565
|
let success = false;
|
|
@@ -22,6 +22,7 @@ export interface VeSyncDeviceWithPower extends VeSyncBaseDevice {
|
|
|
22
22
|
deviceStatus: string;
|
|
23
23
|
turnOn(): Promise<boolean>;
|
|
24
24
|
turnOff(): Promise<boolean>;
|
|
25
|
+
getDetails?: (...args: any[]) => any;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export interface VeSyncDeviceWithBrightness extends VeSyncDeviceWithPower {
|
|
@@ -84,8 +85,25 @@ export interface VeSyncFan extends VeSyncDeviceWithPower, VeSyncDeviceWithSpeed,
|
|
|
84
85
|
setMode(mode: 'normal' | 'auto' | 'sleep' | 'turbo'): Promise<boolean>;
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
export interface VeSyncDimmerSwitch extends VeSyncDeviceWithBrightness {
|
|
89
|
+
rgbLightStatus: string;
|
|
90
|
+
indicatorLightStatus: string;
|
|
91
|
+
rgbLightValue: {
|
|
92
|
+
red: number;
|
|
93
|
+
green: number;
|
|
94
|
+
blue: number;
|
|
95
|
+
};
|
|
96
|
+
rgbColorSet(red: number, green: number, blue: number): Promise<boolean>;
|
|
97
|
+
rgbColorOn(): Promise<boolean>;
|
|
98
|
+
rgbColorOff(): Promise<boolean>;
|
|
99
|
+
indicatorLightOn(): Promise<boolean>;
|
|
100
|
+
indicatorLightOff(): Promise<boolean>;
|
|
101
|
+
}
|
|
102
|
+
|
|
87
103
|
export interface VeSyncBulb extends VeSyncDeviceWithPower, VeSyncDeviceWithBrightness, VeSyncDeviceWithColor {}
|
|
88
104
|
|
|
105
|
+
export type VeSyncLightDevice = VeSyncBulb | VeSyncDimmerSwitch;
|
|
106
|
+
|
|
89
107
|
export interface VeSyncOutlet extends VeSyncDeviceWithPower {
|
|
90
108
|
power?: number;
|
|
91
109
|
voltage?: number;
|
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
|
|
|
@@ -9,11 +9,12 @@ import { OutletAccessory } from '../accessories/outlet.accessory';
|
|
|
9
9
|
import { SwitchAccessory } from '../accessories/switch.accessory';
|
|
10
10
|
import { BaseAccessory } from '../accessories/base.accessory';
|
|
11
11
|
import { AirQualitySensorAccessory } from '../accessories/air-quality-sensor.accessory';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
13
|
VeSyncAirPurifier,
|
|
14
14
|
VeSyncHumidifier,
|
|
15
15
|
VeSyncFan,
|
|
16
16
|
VeSyncBulb,
|
|
17
|
+
VeSyncDimmerSwitch,
|
|
17
18
|
VeSyncOutlet,
|
|
18
19
|
VeSyncSwitch
|
|
19
20
|
} from '../types/device.types';
|
|
@@ -38,7 +39,9 @@ const OUTLET_MODELS = [
|
|
|
38
39
|
'ESW10-USA', 'wifi-switch-1.3'
|
|
39
40
|
];
|
|
40
41
|
|
|
41
|
-
const SWITCH_MODELS = ['
|
|
42
|
+
const SWITCH_MODELS = ['ESWL01', 'ESWL03'];
|
|
43
|
+
|
|
44
|
+
const DIMMER_MODELS = ['ESWD16'];
|
|
42
45
|
|
|
43
46
|
export class DeviceFactory {
|
|
44
47
|
private static modelMatches(deviceType: string, models: string[]): boolean {
|
|
@@ -66,6 +69,10 @@ export class DeviceFactory {
|
|
|
66
69
|
return deviceType.startsWith('ESL') || deviceType === 'XYD0001';
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
private static isDimmer(deviceType: string): boolean {
|
|
73
|
+
return this.modelMatches(deviceType, DIMMER_MODELS);
|
|
74
|
+
}
|
|
75
|
+
|
|
69
76
|
private static isOutlet(deviceType: string): boolean {
|
|
70
77
|
return this.modelMatches(deviceType, OUTLET_MODELS);
|
|
71
78
|
}
|
|
@@ -96,6 +103,11 @@ export class DeviceFactory {
|
|
|
96
103
|
return new FanAccessory(platform, accessory, device as VeSyncFan);
|
|
97
104
|
}
|
|
98
105
|
|
|
106
|
+
// Dimmers
|
|
107
|
+
if (this.isDimmer(deviceType)) {
|
|
108
|
+
return new LightAccessory(platform, accessory, device as VeSyncDimmerSwitch);
|
|
109
|
+
}
|
|
110
|
+
|
|
99
111
|
// Bulbs
|
|
100
112
|
if (this.isBulb(deviceType)) {
|
|
101
113
|
return new LightAccessory(platform, accessory, device as VeSyncBulb);
|
|
@@ -131,7 +143,7 @@ export class DeviceFactory {
|
|
|
131
143
|
return Categories.FAN;
|
|
132
144
|
}
|
|
133
145
|
|
|
134
|
-
if (this.isBulb(type)) {
|
|
146
|
+
if (this.isDimmer(type) || this.isBulb(type)) {
|
|
135
147
|
return Categories.LIGHTBULB;
|
|
136
148
|
}
|
|
137
149
|
|
|
@@ -179,4 +191,4 @@ export class DeviceFactory {
|
|
|
179
191
|
platform.log.debug(`Not creating AQ sensor for ${device.deviceName} - no air quality support detected`);
|
|
180
192
|
return null;
|
|
181
193
|
}
|
|
182
|
-
}
|
|
194
|
+
}
|
|
@@ -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
|
+
}
|