@homebridge-plugins/homebridge-eufy-security 4.6.2-beta.4 → 4.6.2-beta.5

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/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const LIB_VERSION = "4.6.2-beta.4";
1
+ export const LIB_VERSION = "4.6.2-beta.5";
2
2
  //# sourceMappingURL=version.js.map
@@ -7,6 +7,8 @@ import { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils';
7
7
  import path from 'path';
8
8
  import { fileURLToPath } from 'url';
9
9
  import { createRequire } from 'module';
10
+ import os from 'os';
11
+ import { setTimeout as delay } from 'node:timers/promises';
10
12
  import { encryptDiagnostics } from './diagnosticsCrypto.js';
11
13
 
12
14
  const require = createRequire(import.meta.url);
@@ -22,14 +24,22 @@ class UiServer extends HomebridgePluginUiServer {
22
24
  log;
23
25
  tsLog;
24
26
  storagePath;
25
- storedAccessories_file;
27
+ storedAccessoriesPath;
28
+ unsupportedPath;
26
29
 
27
30
  adminAccountUsed = false;
28
31
 
29
32
  // Batch processing for stations and devices
30
33
  pendingStations = [];
31
34
  pendingDevices = [];
32
- processingTimeout;
35
+
36
+ // Timer handles — declared here for visibility; cleared via scoped helpers below
37
+ /** @type {ReturnType<typeof setTimeout>|null} */ _loginTimeout = null;
38
+ /** @type {ReturnType<typeof setTimeout>|null} */ processingTimeout = null;
39
+ /** @type {ReturnType<typeof setTimeout>|null} */ _closeTimeout = null;
40
+ /** @type {ReturnType<typeof setInterval>|null} */ _debounceTickInterval = null;
41
+ /** @type {ReturnType<typeof setTimeout>|null} */ _discoveryInactivityTimeout = null;
42
+ /** @type {ReturnType<typeof setInterval>|null} */ _discoveryInactivityTickInterval = null;
33
43
 
34
44
  /** Set to true when the user clicks "Skip" in the UI to abort the unsupported intel wait. */
35
45
  _skipIntelWait = false;
@@ -69,8 +79,8 @@ class UiServer extends HomebridgePluginUiServer {
69
79
  super();
70
80
 
71
81
  this.storagePath = this.homebridgeStoragePath + '/eufysecurity';
72
- this.storedAccessories_file = this.storagePath + '/accessories.json';
73
- this.unsupported_file = this.storagePath + '/unsupported.json';
82
+ this.storedAccessoriesPath = this.storagePath + '/accessories.json';
83
+ this.unsupportedPath = this.storagePath + '/unsupported.json';
74
84
  this.config.persistentDir = this.storagePath;
75
85
 
76
86
  this.initLogger();
@@ -169,9 +179,7 @@ class UiServer extends HomebridgePluginUiServer {
169
179
  }
170
180
 
171
181
  initTransportStreams() {
172
- if (!fs.existsSync(this.storagePath)) {
173
- fs.mkdirSync(this.storagePath, { recursive: true });
174
- }
182
+ fs.mkdirSync(this.storagePath, { recursive: true });
175
183
 
176
184
  const logStreams = [
177
185
  { name: 'configui-server.log', logger: this.log },
@@ -246,6 +254,17 @@ class UiServer extends HomebridgePluginUiServer {
246
254
  };
247
255
  }
248
256
 
257
+ /** Emit a discovery progress event to the UI. */
258
+ _emitProgress(phase, progress, message) {
259
+ this.pushEvent('discoveryProgress', {
260
+ phase,
261
+ progress,
262
+ stations: this.pendingStations.length,
263
+ devices: this.pendingDevices.length,
264
+ message,
265
+ });
266
+ }
267
+
249
268
  async deleteFileIfExists(filePath) {
250
269
  try {
251
270
  await fs.promises.unlink(filePath);
@@ -261,7 +280,7 @@ class UiServer extends HomebridgePluginUiServer {
261
280
  }
262
281
 
263
282
  async resetAccessoryData() {
264
- return this.deleteFileIfExists(this.storedAccessories_file);
283
+ return this.deleteFileIfExists(this.storedAccessoriesPath);
265
284
  }
266
285
 
267
286
  async checkCache() {
@@ -282,11 +301,13 @@ class UiServer extends HomebridgePluginUiServer {
282
301
  }
283
302
 
284
303
  async login(options) {
304
+ this._clearLoginTimeout();
305
+
285
306
  // Block login if the plugin is already running (accessories updated within 90s)
286
307
  if (!this.eufyClient) {
287
308
  try {
288
- if (fs.existsSync(this.storedAccessories_file)) {
289
- const data = JSON.parse(fs.readFileSync(this.storedAccessories_file, 'utf-8'));
309
+ if (fs.existsSync(this.storedAccessoriesPath)) {
310
+ const data = JSON.parse(fs.readFileSync(this.storedAccessoriesPath, 'utf-8'));
290
311
  if (data?.storedAt) {
291
312
  const ageMs = Date.now() - new Date(data.storedAt).getTime();
292
313
  if (ageMs < 90_000) {
@@ -353,8 +374,8 @@ class UiServer extends HomebridgePluginUiServer {
353
374
  this.eufyClient?.on('connect', () => this.log.debug('Connected!'));
354
375
  this.eufyClient?.on('close', () => this.log.debug('Closed!'));
355
376
  } catch (error) {
356
- this.log.error(error);
357
- this.pushEvent('authError', { message: `Initialization failed: ${error.message || error}` });
377
+ this.log.error('EufySecurity init failed:', error);
378
+ this.pushEvent('authError', { message: 'Initialization failed. Check the logs for details.' });
358
379
  this._discoveryPhase = 'idle';
359
380
  return { success: false };
360
381
  }
@@ -373,52 +394,46 @@ class UiServer extends HomebridgePluginUiServer {
373
394
  .then(() => this.log.debug('connected?: ' + this.eufyClient?.isConnected()))
374
395
  .catch((error) => this.log.error(error));
375
396
  } catch (error) {
376
- this.log.error(error);
377
- clearTimeout(this._loginTimeout);
378
- this.pushEvent('authError', { message: 'Login error: ' + (error.message || error) });
397
+ this.log.error('Login error:', error);
398
+ this._clearLoginTimeout();
399
+ this.pushEvent('authError', { message: 'Login failed. Check the logs for details.' });
379
400
  }
380
401
  } else if (options && options.verifyCode) {
381
402
  this.log.debug('login with TFA code');
382
- this.pushEvent('discoveryProgress', {
383
- phase: 'authenticating',
384
- progress: 10,
385
- message: 'Verifying TFA code...',
386
- });
403
+ this._emitProgress('authenticating', 10, 'Verifying TFA code...');
387
404
  try {
388
405
  this._registerOneTimeAuthHandlers();
389
406
  this.eufyClient?.connect({ verifyCode: options.verifyCode, force: false })
390
407
  .then(() => this.log.debug('TFA connect resolved, connected?: ' + this.eufyClient?.isConnected()))
391
408
  .catch((error) => {
392
- this.log.error('TFA connect error: ' + error);
393
- clearTimeout(this._loginTimeout);
394
- this.pushEvent('authError', { message: 'TFA verification failed: ' + (error.message || error) });
409
+ this.log.error('TFA connect error:', error);
410
+ this._clearLoginTimeout();
411
+ this.pushEvent('authError', { message: 'TFA verification failed. Check the logs for details.' });
395
412
  });
396
413
  } catch (error) {
397
- clearTimeout(this._loginTimeout);
398
- this.pushEvent('authError', { message: 'TFA verification error: ' + (error.message || error) });
414
+ this.log.error('TFA verification error:', error);
415
+ this._clearLoginTimeout();
416
+ this.pushEvent('authError', { message: 'TFA verification failed. Check the logs for details.' });
399
417
  }
400
418
  } else if (options && options.captcha) {
401
419
  this.log.debug('login with captcha');
402
- this.pushEvent('discoveryProgress', {
403
- phase: 'authenticating',
404
- progress: 10,
405
- message: 'Verifying captcha...',
406
- });
420
+ this._emitProgress('authenticating', 10, 'Verifying captcha...');
407
421
  try {
408
422
  this._registerOneTimeAuthHandlers();
409
423
  this.eufyClient?.connect({ captcha: { captchaCode: options.captcha.captchaCode, captchaId: options.captcha.captchaId }, force: false })
410
424
  .then(() => this.log.debug('Captcha connect resolved, connected?: ' + this.eufyClient?.isConnected()))
411
425
  .catch((error) => {
412
- this.log.error('Captcha connect error: ' + error);
413
- clearTimeout(this._loginTimeout);
414
- this.pushEvent('authError', { message: 'Captcha verification failed: ' + (error.message || error) });
426
+ this.log.error('Captcha connect error:', error);
427
+ this._clearLoginTimeout();
428
+ this.pushEvent('authError', { message: 'Captcha verification failed. Check the logs for details.' });
415
429
  });
416
430
  } catch (error) {
417
- clearTimeout(this._loginTimeout);
418
- this.pushEvent('authError', { message: 'Captcha verification error: ' + (error.message || error) });
431
+ this.log.error('Captcha verification error:', error);
432
+ this._clearLoginTimeout();
433
+ this.pushEvent('authError', { message: 'Captcha verification failed. Check the logs for details.' });
419
434
  }
420
435
  } else {
421
- clearTimeout(this._loginTimeout);
436
+ this._clearLoginTimeout();
422
437
  this.pushEvent('authError', { message: 'Unsupported login method.' });
423
438
  }
424
439
 
@@ -429,24 +444,20 @@ class UiServer extends HomebridgePluginUiServer {
429
444
  /** Register once-only auth event handlers (TFA, captcha, connect) on the eufy client. */
430
445
  _registerOneTimeAuthHandlers() {
431
446
  this.eufyClient?.once('tfa request', () => {
432
- clearTimeout(this._loginTimeout);
447
+ this._clearLoginTimeout();
433
448
  this.pushEvent('tfaRequest', {});
434
449
  });
435
450
  this.eufyClient?.once('captcha request', (id, captcha) => {
436
- clearTimeout(this._loginTimeout);
451
+ this._clearLoginTimeout();
437
452
  this.pushEvent('captchaRequest', { id, captcha });
438
453
  });
439
454
  this.eufyClient?.once('connect', () => {
440
- clearTimeout(this._loginTimeout);
455
+ this._clearLoginTimeout();
441
456
  if (this.adminAccountUsed) {
442
457
  return;
443
458
  }
444
459
  this.pushEvent('authSuccess', {});
445
- this.pushEvent('discoveryProgress', {
446
- phase: 'authenticating',
447
- progress: 15,
448
- message: 'Authenticated — waiting for devices...',
449
- });
460
+ this._emitProgress('authenticating', 15, 'Authenticated — waiting for devices...');
450
461
  this._startDiscoveryInactivityTimer();
451
462
  });
452
463
  }
@@ -465,11 +476,7 @@ class UiServer extends HomebridgePluginUiServer {
465
476
  const elapsed = Math.floor((Date.now() - start) / 1000);
466
477
  const remaining = Math.max(0, totalSec - elapsed);
467
478
  const pct = Math.min(95, 15 + Math.floor((elapsed / totalSec) * 80));
468
- this.pushEvent('discoveryProgress', {
469
- phase: 'waitingForDevices',
470
- progress: pct,
471
- message: `Authenticated — waiting for devices... ${remaining}s`,
472
- });
479
+ this._emitProgress('waitingForDevices', pct, `Authenticated — waiting for devices... ${remaining}s`);
473
480
  }, 1000);
474
481
 
475
482
  this._discoveryInactivityTimeout = setTimeout(() => {
@@ -486,11 +493,7 @@ class UiServer extends HomebridgePluginUiServer {
486
493
  } catch (error) {
487
494
  this.log.error('Error storing empty accessories:', error);
488
495
  }
489
- this.pushEvent('discoveryProgress', {
490
- phase: 'done',
491
- progress: 100,
492
- message: 'No devices found.',
493
- });
496
+ this._emitProgress('done', 100, 'No devices found.');
494
497
  this.pushEvent('addAccessory', { stations: [], noDevices: true });
495
498
  this.eufyClient?.removeAllListeners();
496
499
  this.eufyClient?.close();
@@ -522,35 +525,35 @@ class UiServer extends HomebridgePluginUiServer {
522
525
  `);
523
526
  }
524
527
 
525
- /** Clear all pending timers (login, processing, close, debounce tick, discovery inactivity). */
526
- _clearAllTimers() {
528
+ /** Clear the login timeout. */
529
+ _clearLoginTimeout() {
527
530
  clearTimeout(this._loginTimeout);
528
531
  this._loginTimeout = null;
529
- if (this.processingTimeout) {
530
- clearTimeout(this.processingTimeout);
531
- this.processingTimeout = null;
532
- }
533
- if (this._closeTimeout) {
534
- clearTimeout(this._closeTimeout);
535
- this._closeTimeout = null;
536
- }
537
- if (this._debounceTickInterval) {
538
- clearInterval(this._debounceTickInterval);
539
- this._debounceTickInterval = null;
540
- }
541
- this._clearDiscoveryInactivityTimer();
532
+ }
533
+
534
+ /** Clear debounce-related timers (processing, close, tick interval). */
535
+ _clearDebounceTimers() {
536
+ clearTimeout(this.processingTimeout);
537
+ this.processingTimeout = null;
538
+ clearTimeout(this._closeTimeout);
539
+ this._closeTimeout = null;
540
+ clearInterval(this._debounceTickInterval);
541
+ this._debounceTickInterval = null;
542
542
  }
543
543
 
544
544
  /** Clear the post-auth discovery inactivity timer. */
545
545
  _clearDiscoveryInactivityTimer() {
546
- if (this._discoveryInactivityTickInterval) {
547
- clearInterval(this._discoveryInactivityTickInterval);
548
- this._discoveryInactivityTickInterval = null;
549
- }
550
- if (this._discoveryInactivityTimeout) {
551
- clearTimeout(this._discoveryInactivityTimeout);
552
- this._discoveryInactivityTimeout = null;
553
- }
546
+ clearInterval(this._discoveryInactivityTickInterval);
547
+ this._discoveryInactivityTickInterval = null;
548
+ clearTimeout(this._discoveryInactivityTimeout);
549
+ this._discoveryInactivityTimeout = null;
550
+ }
551
+
552
+ /** Clear all pending timers. */
553
+ _clearAllTimers() {
554
+ this._clearLoginTimeout();
555
+ this._clearDebounceTimers();
556
+ this._clearDiscoveryInactivityTimer();
554
557
  }
555
558
 
556
559
  /** Parse a semver string (e.g. '4.4.2-beta.18') into [major, minor, patch]. */
@@ -560,12 +563,12 @@ class UiServer extends HomebridgePluginUiServer {
560
563
 
561
564
  async loadStoredAccessories() {
562
565
  try {
563
- if (!fs.existsSync(this.storedAccessories_file)) {
566
+ if (!fs.existsSync(this.storedAccessoriesPath)) {
564
567
  this.log.debug('Stored accessories file does not exist.');
565
568
  return [];
566
569
  }
567
570
 
568
- const storedData = await fs.promises.readFile(this.storedAccessories_file, { encoding: 'utf-8' });
571
+ const storedData = await fs.promises.readFile(this.storedAccessoriesPath, { encoding: 'utf-8' });
569
572
  const { version: storedVersion, storedAt, stations: storedAccessories } = JSON.parse(storedData);
570
573
 
571
574
  // --- Cache age check (30 days) ---
@@ -610,10 +613,6 @@ class UiServer extends HomebridgePluginUiServer {
610
613
  }
611
614
  }
612
615
 
613
- async delay(ms) {
614
- return new Promise(resolve => setTimeout(resolve, ms));
615
- }
616
-
617
616
  async _onStationDiscovered(station) {
618
617
  if (this.adminAccountUsed) {
619
618
  return;
@@ -628,13 +627,7 @@ class UiServer extends HomebridgePluginUiServer {
628
627
  this.pendingStations.push(station);
629
628
  this.log.debug(`${station.getName()}: Station queued for processing`);
630
629
  this._discoveryPhase = 'queuing';
631
- this.pushEvent('discoveryProgress', {
632
- phase: 'queuing',
633
- progress: 30,
634
- stations: this.pendingStations.length,
635
- devices: this.pendingDevices.length,
636
- message: `Discovered ${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s)...`,
637
- });
630
+ this._emitProgress('queuing', 30, `Discovered ${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s)...`);
638
631
  this._restartDiscoveryDebounce();
639
632
  }
640
633
 
@@ -654,19 +647,14 @@ class UiServer extends HomebridgePluginUiServer {
654
647
  this.pendingDevices.push(device);
655
648
  this.log.debug(`${device.getName()}: Device queued for processing`);
656
649
  this._discoveryPhase = 'queuing';
657
- this.pushEvent('discoveryProgress', {
658
- phase: 'queuing',
659
- progress: 30,
660
- stations: this.pendingStations.length,
661
- devices: this.pendingDevices.length,
662
- message: `Discovered ${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s)...`,
663
- });
650
+ this._emitProgress('queuing', 30, `Discovered ${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s)...`);
664
651
  this._restartDiscoveryDebounce();
665
652
  }
666
653
 
667
654
  /** Restart the debounce timer — processing fires after DISCOVERY_DEBOUNCE_SEC of silence. */
668
655
  _restartDiscoveryDebounce() {
669
- this._clearAllTimers();
656
+ this._clearDebounceTimers();
657
+ this._clearDiscoveryInactivityTimer();
670
658
  const delaySec = UiServer.DISCOVERY_DEBOUNCE_SEC;
671
659
  this.log.debug(
672
660
  `Discovery debounce reset — will process in ${delaySec}s if no more devices arrive ` +
@@ -679,13 +667,7 @@ class UiServer extends HomebridgePluginUiServer {
679
667
  const elapsed = (Date.now() - debounceStart) / 1000;
680
668
  const pct = Math.min(95, 30 + Math.floor((elapsed / delaySec) * 65));
681
669
  const remaining = Math.max(0, Math.ceil(delaySec - elapsed));
682
- this.pushEvent('discoveryProgress', {
683
- phase: 'queuing',
684
- progress: pct,
685
- stations: this.pendingStations.length,
686
- devices: this.pendingDevices.length,
687
- message: `Discovered ${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s) — waiting for more... ${remaining}s`,
688
- });
670
+ this._emitProgress('queuing', pct, `Discovered ${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s) — waiting for more... ${remaining}s`);
689
671
  }, 1000);
690
672
 
691
673
  this.processingTimeout = setTimeout(() => {
@@ -706,13 +688,7 @@ class UiServer extends HomebridgePluginUiServer {
706
688
  this.log.debug(`Processing ${this.pendingStations.length} stations and ${this.pendingDevices.length} devices`);
707
689
 
708
690
  this._discoveryPhase = 'processing';
709
- this.pushEvent('discoveryProgress', {
710
- phase: 'processing',
711
- progress: 95,
712
- stations: this.pendingStations.length,
713
- devices: this.pendingDevices.length,
714
- message: `Processing ${this.pendingStations.length} station(s) and ${this.pendingDevices.length} device(s)...`,
715
- });
691
+ this._emitProgress('processing', 95, `Processing ${this.pendingStations.length} station(s) and ${this.pendingDevices.length} device(s)...`);
716
692
 
717
693
  if (this.pendingStations.length === 0 || this.pendingDevices.length === 0) {
718
694
  this.log.warn(
@@ -754,15 +730,11 @@ class UiServer extends HomebridgePluginUiServer {
754
730
  const pollMs = 1000;
755
731
  let waited = 0;
756
732
  while (waited < UNSUPPORTED_INTEL_WAIT_MS && !this._skipIntelWait) {
757
- await this.delay(pollMs);
733
+ await delay(pollMs);
758
734
  waited += pollMs;
759
735
  const pct = Math.min(95, 50 + Math.floor((waited / UNSUPPORTED_INTEL_WAIT_MS) * 45));
760
736
  const remaining = Math.max(0, Math.ceil((UNSUPPORTED_INTEL_WAIT_MS - waited) / 1000));
761
- this.pushEvent('discoveryProgress', {
762
- phase: 'unsupportedWait',
763
- progress: pct,
764
- message: `Collecting data for ${unsupportedItems.length} unsupported device(s)... ${remaining}s`,
765
- });
737
+ this._emitProgress('unsupportedWait', pct, `Collecting data for ${unsupportedItems.length} unsupported device(s)... ${remaining}s`);
766
738
  }
767
739
 
768
740
  if (this._skipIntelWait) {
@@ -772,11 +744,7 @@ class UiServer extends HomebridgePluginUiServer {
772
744
  }
773
745
  }
774
746
 
775
- this.pushEvent('discoveryProgress', {
776
- phase: 'buildingStations',
777
- progress: 96,
778
- message: 'Building station list...',
779
- });
747
+ this._emitProgress('buildingStations', 96, 'Building station list...');
780
748
 
781
749
  // Process queued stations
782
750
  for (const station of this.pendingStations) {
@@ -844,11 +812,7 @@ class UiServer extends HomebridgePluginUiServer {
844
812
  this.stations.push(s);
845
813
  }
846
814
 
847
- this.pushEvent('discoveryProgress', {
848
- phase: 'buildingDevices',
849
- progress: 98,
850
- message: 'Building device list...',
851
- });
815
+ this._emitProgress('buildingDevices', 98, 'Building device list...');
852
816
 
853
817
  // Process queued devices and attach them to stations
854
818
  for (const device of this.pendingDevices) {
@@ -931,22 +895,16 @@ class UiServer extends HomebridgePluginUiServer {
931
895
  this.log.error('Error storing accessories:', error);
932
896
  }
933
897
 
934
- this.pushEvent('discoveryProgress', {
935
- phase: 'done',
936
- progress: 100,
937
- message: 'Discovery complete!',
938
- });
898
+ this._emitProgress('done', 100, 'Discovery complete!');
939
899
 
940
900
  this.pushEvent('addAccessory', { stations: this.stations, extendedDiscovery: unsupportedItems.length > 0 });
941
901
  }
942
902
 
943
903
  /** Persist discovered stations/devices to accessories.json. */
944
904
  storeAccessories() {
945
- if (!fs.existsSync(this.storagePath)) {
946
- fs.mkdirSync(this.storagePath, { recursive: true });
947
- }
905
+ fs.mkdirSync(this.storagePath, { recursive: true });
948
906
  const dataToStore = { version: LIB_VERSION, storedAt: new Date().toISOString(), stations: this.stations };
949
- fs.writeFileSync(this.storedAccessories_file, JSON.stringify(dataToStore));
907
+ fs.writeFileSync(this.storedAccessoriesPath, JSON.stringify(dataToStore));
950
908
  }
951
909
 
952
910
  // ── Sensitive-field redaction ──────────────────────────────────────────────
@@ -1049,70 +1007,47 @@ class UiServer extends HomebridgePluginUiServer {
1049
1007
  for (const station of pendingStations) {
1050
1008
  const stationType = station.getDeviceType();
1051
1009
  if (!Device.isStation(stationType) && !Device.isSupported(stationType)) {
1052
- unsupportedEntries.push(this._buildUnsupportedStationEntry(station));
1010
+ unsupportedEntries.push(this._buildUnsupportedEntry(station, 'station'));
1053
1011
  }
1054
1012
  }
1055
1013
 
1056
1014
  // Collect unsupported devices
1057
1015
  for (const device of pendingDevices) {
1058
1016
  if (!Device.isSupported(device.getDeviceType())) {
1059
- unsupportedEntries.push(this._buildUnsupportedDeviceEntry(device));
1017
+ unsupportedEntries.push(this._buildUnsupportedEntry(device, 'device'));
1060
1018
  }
1061
1019
  }
1062
1020
 
1063
- if (!fs.existsSync(this.storagePath)) {
1064
- fs.mkdirSync(this.storagePath, { recursive: true });
1065
- }
1021
+ fs.mkdirSync(this.storagePath, { recursive: true });
1066
1022
 
1067
1023
  const dataToStore = { version: LIB_VERSION, storedAt: new Date().toISOString(), devices: unsupportedEntries };
1068
- fs.writeFileSync(this.unsupported_file, JSON.stringify(dataToStore));
1024
+ fs.writeFileSync(this.unsupportedPath, JSON.stringify(dataToStore));
1069
1025
  this.log.debug(`Persisted ${unsupportedEntries.length} unsupported device(s) to unsupported.json`);
1070
1026
  }
1071
1027
 
1072
- /** Build a triage-ready intel object for an unsupported device. */
1073
- _buildUnsupportedDeviceEntry(device) {
1074
- const rawDevice = device.getRawDevice ? device.getRawDevice() : {};
1075
- const rawProps = device.getRawProperties ? device.getRawProperties() : {};
1076
-
1077
- const wifiSsid = rawDevice.wifi_ssid || undefined;
1078
- const localIp = rawDevice.ip_addr || rawDevice.local_ip || undefined;
1079
-
1080
- return {
1081
- uniqueId: device.getSerial(),
1082
- displayName: device.getName(),
1083
- type: device.getDeviceType(),
1084
- typename: DeviceType[device.getDeviceType()] || undefined,
1085
- stationSerialNumber: device.getStationSerial(),
1086
- model: rawDevice.device_model,
1087
- hardwareVersion: rawDevice.main_hw_version,
1088
- softwareVersion: rawDevice.main_sw_version,
1089
- wifiSsid: wifiSsid ? UiServer._partialMask(wifiSsid, 3, 0) : undefined,
1090
- localIp: localIp ? UiServer._partialMask(localIp, 3, 0) : undefined,
1091
- rawDevice: UiServer._redactSensitiveFields(rawDevice),
1092
- rawProperties: rawProps,
1093
- };
1094
- }
1095
-
1096
- /** Build a triage-ready intel object for an unsupported standalone station. */
1097
- _buildUnsupportedStationEntry(station) {
1098
- const rawStation = station.getRawStation ? station.getRawStation() : {};
1099
- const rawProps = station.getRawProperties ? station.getRawProperties() : {};
1028
+ /** Build a triage-ready intel object for an unsupported device or station. */
1029
+ _buildUnsupportedEntry(item, kind) {
1030
+ const isStation = kind === 'station';
1031
+ const rawData = isStation
1032
+ ? (item.getRawStation ? item.getRawStation() : {})
1033
+ : (item.getRawDevice ? item.getRawDevice() : {});
1034
+ const rawProps = item.getRawProperties ? item.getRawProperties() : {};
1100
1035
 
1101
- const wifiSsid = rawStation.wifi_ssid || undefined;
1102
- const localIp = rawStation.ip_addr || undefined;
1036
+ const wifiSsid = rawData.wifi_ssid || undefined;
1037
+ const localIp = rawData.ip_addr || rawData.local_ip || undefined;
1103
1038
 
1104
1039
  return {
1105
- uniqueId: station.getSerial(),
1106
- displayName: station.getName(),
1107
- type: station.getDeviceType(),
1108
- typename: DeviceType[station.getDeviceType()] || undefined,
1109
- stationSerialNumber: station.getSerial(),
1110
- model: rawStation.station_model,
1111
- hardwareVersion: rawStation.main_hw_version,
1112
- softwareVersion: rawStation.main_sw_version,
1040
+ uniqueId: item.getSerial(),
1041
+ displayName: item.getName(),
1042
+ type: item.getDeviceType(),
1043
+ typename: DeviceType[item.getDeviceType()] || undefined,
1044
+ stationSerialNumber: isStation ? item.getSerial() : item.getStationSerial(),
1045
+ model: rawData[isStation ? 'station_model' : 'device_model'],
1046
+ hardwareVersion: rawData.main_hw_version,
1047
+ softwareVersion: rawData.main_sw_version,
1113
1048
  wifiSsid: wifiSsid ? UiServer._partialMask(wifiSsid, 3, 0) : undefined,
1114
1049
  localIp: localIp ? UiServer._partialMask(localIp, 3, 0) : undefined,
1115
- rawDevice: UiServer._redactSensitiveFields(rawStation),
1050
+ rawDevice: UiServer._redactSensitiveFields(rawData),
1116
1051
  rawProperties: rawProps,
1117
1052
  };
1118
1053
  }
@@ -1120,10 +1055,10 @@ class UiServer extends HomebridgePluginUiServer {
1120
1055
  /** Load unsupported device intel from disk. */
1121
1056
  async loadUnsupportedDevices() {
1122
1057
  try {
1123
- if (!fs.existsSync(this.unsupported_file)) {
1058
+ if (!fs.existsSync(this.unsupportedPath)) {
1124
1059
  return { devices: [] };
1125
1060
  }
1126
- const data = JSON.parse(await fs.promises.readFile(this.unsupported_file, 'utf-8'));
1061
+ const data = JSON.parse(await fs.promises.readFile(this.unsupportedPath, 'utf-8'));
1127
1062
  return { devices: data.devices || [] };
1128
1063
  } catch (error) {
1129
1064
  this.log.error('Could not load unsupported devices: ' + error);
@@ -1179,13 +1114,13 @@ class UiServer extends HomebridgePluginUiServer {
1179
1114
  const filesToArchive = [...finalLogFiles];
1180
1115
 
1181
1116
  // Include accessories.json for diagnostics
1182
- if (fs.existsSync(this.storedAccessories_file)) {
1183
- filesToArchive.push(path.basename(this.storedAccessories_file));
1117
+ if (fs.existsSync(this.storedAccessoriesPath)) {
1118
+ filesToArchive.push(path.basename(this.storedAccessoriesPath));
1184
1119
  }
1185
1120
 
1186
1121
  // Include unsupported.json for diagnostics
1187
- if (fs.existsSync(this.unsupported_file)) {
1188
- filesToArchive.push(path.basename(this.unsupported_file));
1122
+ if (fs.existsSync(this.unsupportedPath)) {
1123
+ filesToArchive.push(path.basename(this.unsupportedPath));
1189
1124
  }
1190
1125
 
1191
1126
  this.pushEvent('diagnosticsProgress', { progress: 40, status: 'Checking archive content' });
@@ -1203,7 +1138,7 @@ class UiServer extends HomebridgePluginUiServer {
1203
1138
  // Snapshot files to a temp directory before archiving.
1204
1139
  // Log files are actively written to — reading them directly with tar
1205
1140
  // causes "did not encounter expected EOF" when file size changes mid-read.
1206
- const os = await import('os');
1141
+
1207
1142
  const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eufy-diag-'));
1208
1143
  let fileBuffer;
1209
1144
  try {
@@ -1250,17 +1185,17 @@ class UiServer extends HomebridgePluginUiServer {
1250
1185
  'configui-server.log',
1251
1186
  'configui-lib.log',
1252
1187
  ]);
1253
- const files = await fs.promises.readdir(this.storagePath);
1188
+ const entries = await fs.promises.readdir(this.storagePath, { withFileTypes: true });
1254
1189
  let deleted = 0;
1255
- for (const file of files) {
1256
- if (preserved.has(file)) continue;
1257
- const filePath = path.join(this.storagePath, file);
1190
+ for (const entry of entries) {
1191
+ if (!entry.isFile() || preserved.has(entry.name)) continue;
1192
+ const filePath = path.join(this.storagePath, entry.name);
1258
1193
  try {
1259
1194
  await fs.promises.unlink(filePath);
1260
1195
  deleted++;
1261
- this.log.debug(`Deleted: ${file}`);
1196
+ this.log.debug(`Deleted: ${entry.name}`);
1262
1197
  } catch (error) {
1263
- this.log.warn(`Failed to delete ${file}: ${error}`);
1198
+ this.log.warn(`Failed to delete ${entry.name}: ${error}`);
1264
1199
  }
1265
1200
  }
1266
1201
  this.log.info(`Cleaned ${deleted} file(s)`);
@@ -1268,7 +1203,7 @@ class UiServer extends HomebridgePluginUiServer {
1268
1203
  }
1269
1204
 
1270
1205
  async getSystemInfo() {
1271
- const os = await import('os');
1206
+
1272
1207
  let homebridgeVersion = 'unknown';
1273
1208
  try {
1274
1209
  const hbPkg = require('homebridge/package.json');
@@ -1279,8 +1214,8 @@ class UiServer extends HomebridgePluginUiServer {
1279
1214
 
1280
1215
  let deviceSummary = [];
1281
1216
  try {
1282
- if (fs.existsSync(this.storedAccessories_file)) {
1283
- const storedData = JSON.parse(fs.readFileSync(this.storedAccessories_file, 'utf-8'));
1217
+ if (fs.existsSync(this.storedAccessoriesPath)) {
1218
+ const storedData = JSON.parse(fs.readFileSync(this.storedAccessoriesPath, 'utf-8'));
1284
1219
  if (storedData.stations) {
1285
1220
  deviceSummary = storedData.stations.map(s => ({
1286
1221
  name: s.displayName,
@@ -1308,6 +1243,23 @@ class UiServer extends HomebridgePluginUiServer {
1308
1243
  };
1309
1244
  }
1310
1245
 
1246
+ /** Resolve and validate a recording filename, returning the absolute path. */
1247
+ _resolveRecordingPath(filename) {
1248
+ if (!filename || typeof filename !== 'string') {
1249
+ throw new Error('Missing or invalid filename parameter.');
1250
+ }
1251
+ const recordingsDir = path.join(this.storagePath, 'recordings');
1252
+ const sanitized = path.basename(filename);
1253
+ if (!sanitized.endsWith('.mp4')) {
1254
+ throw new Error('Invalid filename.');
1255
+ }
1256
+ const resolved = path.resolve(path.join(recordingsDir, sanitized));
1257
+ if (!resolved.startsWith(path.resolve(recordingsDir))) {
1258
+ throw new Error('Invalid filename.');
1259
+ }
1260
+ return resolved;
1261
+ }
1262
+
1311
1263
  /**
1312
1264
  * List all debug recording files available for download.
1313
1265
  */
@@ -1347,8 +1299,8 @@ class UiServer extends HomebridgePluginUiServer {
1347
1299
  .sort((a, b) => b.createdAt - a.createdAt);
1348
1300
  return { recordings: files };
1349
1301
  } catch (error) {
1350
- this.log.error('Error listing debug recordings: ' + error);
1351
- throw error;
1302
+ this.log.error('Error listing debug recordings:', error);
1303
+ throw new Error('Failed to list debug recordings. Check the logs for details.');
1352
1304
  }
1353
1305
  }
1354
1306
 
@@ -1359,26 +1311,14 @@ class UiServer extends HomebridgePluginUiServer {
1359
1311
  */
1360
1312
  async downloadDebugRecording(options = {}) {
1361
1313
  const { filename, offset: rawOffset = 0, chunkSize: rawChunkSize = 256 * 1024 } = options;
1362
-
1363
- if (!filename || typeof filename !== 'string') {
1364
- throw new Error('Missing or invalid filename parameter.');
1365
- }
1314
+ const resolved = this._resolveRecordingPath(filename);
1366
1315
 
1367
1316
  // Coerce and clamp parameters
1368
1317
  const numOffset = Number(rawOffset) || 0;
1318
+ if (numOffset < 0) throw new Error('Invalid offset.');
1369
1319
  const MAX_CHUNK = 2 * 1024 * 1024; // 2 MB
1370
1320
  const numChunkSize = Math.min(Number(rawChunkSize) || 256 * 1024, MAX_CHUNK);
1371
1321
 
1372
- const recordingsDir = path.join(this.storagePath, 'recordings');
1373
- const sanitized = path.basename(filename);
1374
- const filePath = path.join(recordingsDir, sanitized);
1375
- const resolved = path.resolve(filePath);
1376
-
1377
- // Path traversal guard
1378
- if (!resolved.startsWith(path.resolve(recordingsDir))) {
1379
- throw new Error('Invalid filename.');
1380
- }
1381
-
1382
1322
  if (!fs.existsSync(resolved)) {
1383
1323
  throw new Error('Recording file not found.');
1384
1324
  }
@@ -1391,10 +1331,13 @@ class UiServer extends HomebridgePluginUiServer {
1391
1331
  return { data: null, totalSize, offset: numOffset, chunkSize: 0, done: true };
1392
1332
  }
1393
1333
 
1394
- const fd = fs.openSync(resolved, 'r');
1334
+ const fh = await fs.promises.open(resolved, 'r');
1395
1335
  const buf = Buffer.alloc(readSize);
1396
- fs.readSync(fd, buf, 0, readSize, numOffset);
1397
- fs.closeSync(fd);
1336
+ try {
1337
+ await fh.read(buf, 0, readSize, numOffset);
1338
+ } finally {
1339
+ await fh.close();
1340
+ }
1398
1341
 
1399
1342
  const newOffset = numOffset + readSize;
1400
1343
 
@@ -1404,7 +1347,7 @@ class UiServer extends HomebridgePluginUiServer {
1404
1347
  offset: newOffset,
1405
1348
  chunkSize: readSize,
1406
1349
  done: newOffset >= totalSize,
1407
- filename: sanitized,
1350
+ filename: path.basename(resolved),
1408
1351
  };
1409
1352
  }
1410
1353
 
@@ -1412,26 +1355,14 @@ class UiServer extends HomebridgePluginUiServer {
1412
1355
  * Delete a specific debug recording file.
1413
1356
  */
1414
1357
  async deleteDebugRecording(options = {}) {
1415
- const { filename } = options;
1416
-
1417
- if (!filename || typeof filename !== 'string') {
1418
- throw new Error('Missing or invalid filename parameter.');
1419
- }
1420
-
1421
- const recordingsDir = path.join(this.storagePath, 'recordings');
1422
- const sanitized = path.basename(filename);
1423
- const filePath = path.join(recordingsDir, sanitized);
1424
- const resolved = path.resolve(filePath);
1425
-
1426
- if (!resolved.startsWith(path.resolve(recordingsDir))) {
1427
- throw new Error('Invalid filename.');
1428
- }
1358
+ const resolved = this._resolveRecordingPath(options.filename);
1429
1359
 
1430
1360
  if (!fs.existsSync(resolved)) {
1431
1361
  throw new Error('Recording file not found.');
1432
1362
  }
1433
1363
 
1434
1364
  fs.unlinkSync(resolved);
1365
+ const sanitized = path.basename(resolved);
1435
1366
  this.log.info(`Deleted debug recording: ${sanitized}`);
1436
1367
  return { deleted: true, filename: sanitized };
1437
1368
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "Homebridge Eufy Security",
3
3
  "name": "@homebridge-plugins/homebridge-eufy-security",
4
- "version": "4.6.2-beta.4",
4
+ "version": "4.6.2-beta.5",
5
5
  "description": "Control Eufy Security from homebridge.",
6
6
  "type": "module",
7
7
  "license": "Apache-2.0",