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.
@@ -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.1.2",
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.1.2"
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: 0,25,50,75,100
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: show manual 1→50, 2→75, 3+→100
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
- if (speed === 1) return 50;
316
- if (speed === 2) return 75;
317
- return 100;
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 25% and map 50/75/100 → 1/2/3
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 notch = Math.max(0, Math.min(4, Math.round(Math.min(100, Math.max(0, percentage)) / 25)));
384
- if (notch <= 2) return 1; // 50%
385
- if (notch === 3) return 2; // 75%
386
- return 3; // 100%
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.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, 25);
656
+ const step = this.calculateRotationSpeedStep();
657
+ this.service.updateCharacteristic(this.platform.Characteristic.RotationSpeed, step);
646
658
  this.lastSetSpeed = 0;
647
- this.lastSetPercentage = 25;
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.debug(`Setting mode to ${mode} for device: ${this.device.deviceName}`);
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 25;
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 notch = Math.max(0, Math.min(4, Math.round(percentage / 25)));
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.debug(`Setting sleep mode via slider on ${this.device.deviceName} (notch=${notch})`);
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, 25);
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 = 25;
902
+ this.lastSetPercentage = step;
888
903
  this.skipNextUpdate = true;
889
904
  return;
890
905
  } else {
891
- // Map notches 2/3/4 to speeds 1/2/3
892
- const desiredSpeed = Math.min(3, notch - 1);
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 === 1 ? 50 : desiredSpeed === 2 ? 75 : 100;
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
- // Try to login first
150
- if (!await this.ensureLogin()) {
151
- throw new Error('Failed to login to VeSync API');
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
- // Try to login first
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;
@@ -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
+ }