@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.2 → 5.0.0-beta.21

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.
Files changed (56) hide show
  1. package/config.schema.json +24 -0
  2. package/dist/account/temporary-authentication.js +3 -1
  3. package/dist/account/temporary-authentication.js.map +1 -1
  4. package/dist/configuration.js +36 -2
  5. package/dist/configuration.js.map +1 -1
  6. package/dist/device/member-evidence.js +10 -3
  7. package/dist/device/member-evidence.js.map +1 -1
  8. package/dist/diagnostics.js +678 -83
  9. package/dist/diagnostics.js.map +1 -1
  10. package/dist/homekit/adapters/camera-streaming.js +54 -26
  11. package/dist/homekit/adapters/camera-streaming.js.map +1 -1
  12. package/dist/homekit/adapters/lock.js +45 -16
  13. package/dist/homekit/adapters/lock.js.map +1 -1
  14. package/dist/homekit/adapters/motion.js +1 -1
  15. package/dist/homekit/adapters/motion.js.map +1 -1
  16. package/dist/homekit/adapters/package-sensor.js +63 -0
  17. package/dist/homekit/adapters/package-sensor.js.map +1 -0
  18. package/dist/homekit/adapters/registry.js +2 -0
  19. package/dist/homekit/adapters/registry.js.map +1 -1
  20. package/dist/homekit/adapters/security-system.js +101 -45
  21. package/dist/homekit/adapters/security-system.js.map +1 -1
  22. package/dist/homekit/coverage-matrix.js +1 -3
  23. package/dist/homekit/coverage-matrix.js.map +1 -1
  24. package/dist/homekit/reconciler.js +17 -4
  25. package/dist/homekit/reconciler.js.map +1 -1
  26. package/dist/media/last-successful-image.js +5 -3
  27. package/dist/media/last-successful-image.js.map +1 -1
  28. package/dist/media/live-stream.js +109 -59
  29. package/dist/media/live-stream.js.map +1 -1
  30. package/dist/media/recording.js +7 -5
  31. package/dist/media/recording.js.map +1 -1
  32. package/dist/media/snapshot.js +13 -6
  33. package/dist/media/snapshot.js.map +1 -1
  34. package/dist/media/station-live-sessions.js +47 -43
  35. package/dist/media/station-live-sessions.js.map +1 -1
  36. package/dist/platform.js +25 -5
  37. package/dist/platform.js.map +1 -1
  38. package/dist/runtime/channel.js +6 -2
  39. package/dist/runtime/channel.js.map +1 -1
  40. package/dist/runtime/owner.js +3 -0
  41. package/dist/runtime/owner.js.map +1 -1
  42. package/dist/runtime/sdk-client.js +7 -4
  43. package/dist/runtime/sdk-client.js.map +1 -1
  44. package/dist/ui/dashboard.js +32 -14
  45. package/dist/ui/dashboard.js.map +1 -1
  46. package/dist/ui/server.js +27 -4
  47. package/dist/ui/server.js.map +1 -1
  48. package/homebridge-ui/public/app.css +353 -88
  49. package/homebridge-ui/public/i18n/en.json +110 -78
  50. package/homebridge-ui/public/i18n/fr.json +110 -78
  51. package/homebridge-ui/public/index.html +95 -106
  52. package/homebridge-ui/public/js/app.js +328 -345
  53. package/homebridge-ui/public/js/dashboard.js +139 -10
  54. package/homebridge-ui/public/js/profile-wizard.js +17 -54
  55. package/i18n/runtime/en.json +7 -3
  56. package/package.json +6 -6
@@ -8,12 +8,65 @@
8
8
  .replaceAll("'", ''');
9
9
  }
10
10
 
11
+ const EUFY_MODES = [
12
+ { value: 'away', label: 'Away' },
13
+ { value: 'home', label: 'Home' },
14
+ { value: 'schedule', label: 'Schedule' },
15
+ { value: 'custom1', label: 'Custom 1' },
16
+ { value: 'custom2', label: 'Custom 2' },
17
+ { value: 'custom3', label: 'Custom 3' },
18
+ { value: 'off', label: 'Off' },
19
+ { value: 'geo', label: 'Geofencing' },
20
+ { value: 'disarmed', label: 'Disarmed' },
21
+ ];
22
+ /** What each HomeKit state means where the household assigned nothing, as the adapter resolves it. */
23
+ const DEFAULT_ARMING_MODES = { home: 'home', away: 'away', off: 'disarmed' };
24
+ const ARMING_SLOTS = [
25
+ { slot: 'home', label: 'armingSlotHome' },
26
+ { slot: 'away', label: 'armingSlotAway' },
27
+ { slot: 'night', label: 'armingSlotNight' },
28
+ { slot: 'off', label: 'armingSlotOff' },
29
+ ];
30
+
31
+ /**
32
+ * The guard-mode map: four states HomeKit names against the nine a station reports, one pairing per row.
33
+ *
34
+ * Folded shut, because the four defaults are what nearly every household keeps and the panel has a camera's
35
+ * worth of settings above it. The summary carries the whole map as text, so the answer is readable without
36
+ * opening anything, and a household that assigned a state of its own finds it already open.
37
+ *
38
+ * A row is the whole control — the state names itself and the select beside it says what it means. An
39
+ * unassigned state shows a dash, which is what keeps Night out of HomeKit until it is given a mode.
40
+ *
41
+ * It is withheld while the security system it maps is turned off, and the stored map is kept for when it is back.
42
+ */
43
+ function armingModeControl(device, assigned, messages, securitySystem) {
44
+ const summary = ARMING_SLOTS.map(
45
+ ({ slot, label }) => `${messages[label]} → ${EUFY_MODES.find((mode) => mode.value === assigned[slot])?.label ?? '—'}`,
46
+ ).join(' · ');
47
+ const rows = ARMING_SLOTS.map(({ slot, label }) => {
48
+ const value = assigned[slot] ?? '';
49
+ const id = `arming-${escapeHtml(device.serial)}-${slot}`;
50
+ const options = EUFY_MODES.map(
51
+ (mode) =>
52
+ `<option value="${mode.value}"${value === mode.value ? ' selected' : ''}>${escapeHtml(mode.label)}</option>`,
53
+ ).join('');
54
+ return `<tr><th scope="row"><label for="${id}">${escapeHtml(messages[label])}</label></th><td><select id="${id}" data-preference="armingModes" data-slot="${slot}" data-serial="${escapeHtml(device.serial)}" data-original="${escapeHtml(value)}"><option value=""${value === '' ? ' selected' : ''}>—</option>${options}</select></td></tr>`;
55
+ }).join('');
56
+ const assignedItself = ARMING_SLOTS.some(({ slot }) => assigned[slot] !== DEFAULT_ARMING_MODES[slot]);
57
+ return `<details class="arming-setting" data-setting${assignedItself ? ' open' : ''}${device.represented && securitySystem ? '' : ' hidden'} data-requires-representation data-requires-security-system><summary><span class="setting-label">${escapeHtml(messages.preferenceArmingModes)}</span><span class="arming-summary">${escapeHtml(summary)}</span></summary><table class="arming-table"><thead><tr><th scope="col">HomeKit</th><th scope="col">eufy</th></tr></thead><tbody>${rows}</tbody></table><p class="snapshot-help">${escapeHtml(messages.armingModesHelp)}</p></details>`;
58
+ }
59
+
11
60
  function preferenceControl(device, key, preference, messages) {
12
61
  const labels = {
13
62
  represented: messages.preferenceRepresented,
14
63
  audio: messages.preferenceAudio,
64
+ securitySystem: messages.preferenceSecuritySystem,
15
65
  snapshotMode: messages.preferenceSnapshotMode,
16
66
  };
67
+ if (key === 'armingModes') {
68
+ return armingModeControl(device, preference.armingModes, messages, preference.securitySystem);
69
+ }
17
70
  const represented = preference.represented;
18
71
  const dependent = key === 'represented' ? '' : ` data-requires-representation${represented ? '' : ' hidden'}`;
19
72
  if (key === 'snapshotMode') {
@@ -29,7 +82,7 @@
29
82
 
30
83
  function markPendingPreference(control, key, value) {
31
84
  if (!control?.classList || control.dataset.original === undefined) return;
32
- const current = key === 'snapshotMode' ? String(value) : String(Boolean(value));
85
+ const current = key === 'snapshotMode' || key === 'armingModes' ? String(value) : String(Boolean(value));
33
86
  const setting = control.closest('[data-setting]');
34
87
  const changed = current !== control.dataset.original;
35
88
  if (key === 'snapshotMode') {
@@ -204,21 +257,59 @@
204
257
  }
205
258
 
206
259
  /*
207
- * The states a diagnostic run can shed light on. Sign-in, another process holding the session, and a pending
208
- * restart each have their own remedy stated on the page, so sending those to the wizard would be a detour.
260
+ * The states a diagnostic run can shed light on. Sign-in, another process holding the session, a pending
261
+ * restart, a plugin that stopped on an error, a page that could not load, and a connection that recovers on its
262
+ * own each have their own remedy stated on the page, so sending those to the wizard would be a detour.
209
263
  */
210
- const DIAGNOSABLE = new Set(['degraded', 'incomplete', 'missing', 'stale']);
264
+ const DIAGNOSABLE = new Set(['incomplete', 'missing', 'stale']);
211
265
 
266
+ /** The diagnostics area a condition's capture belongs to, by its code. A code no entry matches is `other`. */
267
+ const CONDITION_AREAS = [
268
+ [/^camera-recording/, 'hksv-recording'],
269
+ [/^camera-(?:live|media|streaming|snapshot|talkback)/, 'live-media'],
270
+ [/lock|light|arming|siren|contact|camera-control/, 'control-state'],
271
+ [/transport-degraded/, 'startup-authentication'],
272
+ ];
273
+
274
+ /**
275
+ * The problem the diagnostics action points at, or nothing: a state a run explains, then the first active condition
276
+ * this page has words for, then the first device nothing can reach. Each names the diagnostics area a run on it is
277
+ * about. A condition is worded under its log keys; the ones left unworded are those the page answers on its own,
278
+ * such as a sign-in or another owner, and the devices no action makes supported.
279
+ */
280
+ function attention(result, state, messages) {
281
+ if (DIAGNOSABLE.has(result.state)) return { ...state, profile: 'startup-authentication' };
282
+ const condition = (result.conditions ?? []).find(
283
+ ({ summaryKey, actionKey }) => messages[summaryKey] && messages[actionKey],
284
+ );
285
+ if (condition) {
286
+ return {
287
+ title: messages[condition.summaryKey],
288
+ summary: messages[condition.actionKey],
289
+ profile: CONDITION_AREAS.find(([code]) => code.test(condition.code))?.[1] ?? 'other',
290
+ };
291
+ }
292
+ const device = result.devices.find((candidate) => candidate.availability === 'unavailable');
293
+ return (
294
+ device && {
295
+ title: (messages.attentionUnreachableTitle ?? '').replace('{device}', device.name),
296
+ summary: messages.attentionUnreachableSummary ?? '',
297
+ profile: 'device-representation',
298
+ }
299
+ );
300
+ }
301
+
302
+ /** Draws the dashboard, and answers the problem it shows the diagnostics action, if any. */
212
303
  function render(result, config, messages, elements) {
213
304
  const suffix = result.state
214
305
  .split('-')
215
306
  .map((part) => part[0].toUpperCase() + part.slice(1))
216
307
  .join('');
217
- elements.title.textContent = messages[`dashboard${suffix}Title`] ?? messages.dashboardIncompleteTitle;
218
- elements.summary.textContent = messages[`dashboard${suffix}Summary`] ?? messages.dashboardIncompleteSummary;
308
+ const title = messages[`dashboard${suffix}Title`] ?? messages.dashboardIncompleteTitle;
309
+ const summary = messages[`dashboard${suffix}Summary`] ?? messages.dashboardIncompleteSummary;
310
+ elements.title.textContent = title;
311
+ elements.summary.textContent = summary;
219
312
  elements.dashboard.dataset.state = result.state;
220
- // Offered rather than announced: a run is worth suggesting only where the state is one a run explains.
221
- if (elements.diagnose) elements.diagnose.hidden = !DIAGNOSABLE.has(result.state);
222
313
  elements.dashboard.hidden = false;
223
314
  elements.masthead.hidden = true;
224
315
  renderUpdatePending(result, messages, elements);
@@ -230,6 +321,7 @@
230
321
  } else if (result.state === 'authentication-required') {
231
322
  elements.setup.hidden = false;
232
323
  }
324
+ return attention(result, { title, summary }, messages);
233
325
  }
234
326
 
235
327
  function bindPreferences(elements, getConfig, saveConfig, getMessages, openDevice) {
@@ -260,7 +352,11 @@
260
352
  const key = control?.dataset?.preference;
261
353
  const existing = getConfig();
262
354
  if (!existing || !serial || !key) return;
263
- const defaults = { represented: true, audio: true, snapshotMode: 'Refresh' };
355
+ const defaults = { represented: true, audio: true, securitySystem: true, snapshotMode: 'Refresh' };
356
+ if (key === 'armingModes') {
357
+ await saveArmingMode(control, serial, existing, saveConfig, getMessages, elements);
358
+ return;
359
+ }
264
360
  const value = key === 'snapshotMode' ? control.value : control.checked;
265
361
  markPendingPreference(control, key, value);
266
362
  if (key === 'snapshotMode') {
@@ -282,9 +378,16 @@
282
378
  (candidate) => candidate.dataset.serial === serial,
283
379
  );
284
380
  tile?.classList.toggle('device-tile-disabled', !value);
381
+ const grid = control.closest('.preference-grid');
382
+ const securityOff = grid?.querySelector('[data-preference="securitySystem"]')?.checked === false;
383
+ grid?.querySelectorAll('[data-requires-representation]').forEach((setting) => {
384
+ setting.hidden = !value || (securityOff && setting.hasAttribute('data-requires-security-system'));
385
+ });
386
+ }
387
+ if (key === 'securitySystem') {
285
388
  control
286
389
  .closest('.preference-grid')
287
- ?.querySelectorAll('[data-requires-representation]')
390
+ ?.querySelectorAll('[data-requires-security-system]')
288
391
  .forEach((setting) => {
289
392
  setting.hidden = !value;
290
393
  });
@@ -295,6 +398,30 @@
295
398
  });
296
399
  }
297
400
 
401
+ /**
402
+ * Records one HomeKit state's guard mode, keeping the map sparse: a state set back to the plugin's own choice
403
+ * is removed rather than pinned, so a later default reaches a household that never overrode it.
404
+ */
405
+ async function saveArmingMode(control, serial, existing, saveConfig, getMessages, elements) {
406
+ const slot = control.dataset.slot;
407
+ const value = control.value;
408
+ markPendingPreference(control, 'armingModes', value);
409
+ const entityPreferences = { ...(existing.entityPreferences ?? {}) };
410
+ const preference = { ...(entityPreferences[serial] ?? {}) };
411
+ const armingModes = { ...(preference.armingModes ?? {}) };
412
+ if (value === '') delete armingModes[slot];
413
+ else armingModes[slot] = value;
414
+ if (Object.keys(armingModes).length === 0) delete preference.armingModes;
415
+ else preference.armingModes = armingModes;
416
+ if (Object.keys(preference).length === 0) delete entityPreferences[serial];
417
+ else entityPreferences[serial] = preference;
418
+ try {
419
+ await saveConfig({ ...existing, entityPreferences });
420
+ } catch {
421
+ elements.summary.textContent = getMessages().preferenceSaveFailed;
422
+ }
423
+ }
424
+
298
425
  /**
299
426
  * Fills the settings page for one device, which is where its controls live rather than on the tile itself.
300
427
  *
@@ -312,7 +439,9 @@
312
439
  const preference = {
313
440
  represented: preferences[device.serial]?.represented ?? true,
314
441
  audio: preferences[device.serial]?.audio ?? true,
442
+ securitySystem: preferences[device.serial]?.securitySystem ?? true,
315
443
  snapshotMode: preferences[device.serial]?.snapshotMode ?? 'Refresh',
444
+ armingModes: { ...DEFAULT_ARMING_MODES, ...preferences[device.serial]?.armingModes },
316
445
  };
317
446
  const controls = device.preferences
318
447
  .map((key) => preferenceControl(device, key, preference, messages))
@@ -1,52 +1,22 @@
1
1
  (function attachDiagnosticsWizard(global) {
2
- const questions = [
3
- { profile: 'dashboard-ui', message: 'diagnosticsQuestionDashboard' },
4
- { profile: 'startup-authentication', message: 'diagnosticsQuestionStartup' },
5
- { profile: 'device-representation', message: 'diagnosticsQuestionDevices' },
6
- { profile: 'control-state', message: 'diagnosticsQuestionControl' },
7
- { profile: 'live-media', message: 'diagnosticsQuestionLiveMedia' },
8
- { profile: 'hksv-recording', message: 'diagnosticsQuestionRecording' },
2
+ const profiles = [
3
+ 'startup-authentication',
4
+ 'device-representation',
5
+ 'control-state',
6
+ 'live-media',
7
+ 'hksv-recording',
8
+ 'dashboard-ui',
9
+ 'other',
9
10
  ];
10
11
 
11
- function start() {
12
- return { mode: 'questions', questionIndex: 0, profile: undefined, source: 'questions' };
13
- }
14
-
15
- function answer(state, matches) {
16
- if (matches) {
17
- return { ...state, mode: 'frequency', profile: questions[state.questionIndex].profile };
18
- }
19
- if (state.questionIndex === questions.length - 1) {
20
- return { ...state, mode: 'frequency', profile: 'other' };
21
- }
22
- return { ...state, questionIndex: state.questionIndex + 1 };
23
- }
24
-
25
- function direct() {
26
- return { mode: 'direct', questionIndex: 0, profile: undefined, source: 'direct' };
27
- }
28
-
29
- function selectDirect(state, profile) {
30
- return { ...state, mode: 'frequency', profile, source: 'direct' };
31
- }
32
-
33
- function reject(state) {
34
- return state.source === 'direct' ? direct() : start();
35
- }
36
-
37
- function chooseReproductionMode(state, reproductionMode) {
38
- return { ...state, mode: 'match', reproductionMode };
39
- }
40
-
41
- function backFromFrequency(state) {
42
- return state.source === 'direct' ? direct() : start();
43
- }
44
-
45
- function screen(session, startingAnother) {
46
- if (session.partialExportAvailable && !startingAnother) return 'review';
47
- if (startingAnother || session.status === 'inactive' || session.status === 'expired') return 'choose';
48
- if (session.status === 'authorized' || session.status === 'reproducing') return 'reproduce';
49
- return 'status';
12
+ /**
13
+ * Which screen a session puts on the panel: its file while one can be downloaded, its capture while one runs, and
14
+ * otherwise the question, since picking an area replaces the session with a capture of that area.
15
+ */
16
+ function screen(session) {
17
+ if (session.partialExportAvailable) return 'review';
18
+ if (session.status === 'reproducing') return 'reproduce';
19
+ return 'choose';
50
20
  }
51
21
 
52
22
  function backgroundActive(session) {
@@ -54,15 +24,8 @@
54
24
  }
55
25
 
56
26
  global.HomebridgeEufyDiagnosticsWizard = {
57
- answer,
58
- backFromFrequency,
59
27
  backgroundActive,
60
- chooseReproductionMode,
61
- direct,
62
- questions,
63
- reject,
28
+ profiles,
64
29
  screen,
65
- selectDirect,
66
- start,
67
30
  };
68
31
  })(window);
@@ -4,22 +4,26 @@
4
4
  "log.action.checkPhysicalArmingMode": "Check the physical security-system mode before retrying",
5
5
  "log.action.checkPhysicalLight": "Check the physical smart-light state before retrying",
6
6
  "log.action.checkPhysicalLock": "Check the physical lock state before retrying",
7
+ "log.action.controlTimedOut": "The device did not answer in time; use the Eufy app for this control, and attach a diagnostics file to your issue if it keeps happening",
7
8
  "log.action.checkCameraSnapshot": "Check that the camera is reachable and can produce an image, then open the camera again",
8
9
  "log.action.waitCameraControl": "No action is needed; the control returns when the camera reports it again",
9
10
  "log.action.checkCameraControl": "Check the camera in the Eufy app; this control is unavailable until it reports its state",
10
11
  "log.action.retryCameraControl": "Try the control again; the camera did not confirm the change",
11
12
  "log.action.enableCamera": "Turn the camera on in the Eufy app, then open live view again",
12
- "log.action.openDashboard": "Review device representation in the Homebridge Eufy dashboard",
13
+ "log.action.notSupportedYet": "No action is needed; these devices are not supported in HomeKit yet",
13
14
  "log.action.reduceConcurrentMedia": "Open live view again; if it keeps happening, close a camera already open or raise the concurrent camera media limit in the plugin settings",
14
15
  "log.action.reauthenticate": "Authenticate again in the Homebridge Eufy dashboard, then restart Homebridge",
15
16
  "log.action.retryArming": "Retry the security-system control manually",
16
17
  "log.action.retryLight": "Retry the smart-light control manually",
17
18
  "log.action.retryLiveView": "Open live view again, and check network access between Homebridge and the camera",
19
+ "log.action.setFfmpegPath": "Set a custom FFmpeg path in Advanced settings in the Homebridge Eufy dashboard",
20
+ "log.action.checkFfmpegPath": "FFmpeg could not be started; check the custom FFmpeg path in Advanced settings in the Homebridge Eufy dashboard",
21
+ "log.action.checkStation": "Check that the station or HomeBase this camera uses is powered and online, and network access between Homebridge and it, then open live view again",
18
22
  "log.action.retryLock": "Retry the lock control manually",
19
23
  "log.action.retryTalkback": "Close and reopen the camera, then try speaking again",
20
24
  "log.action.reviewRuntime": "Review Homebridge Eufy health in the dashboard and restart Homebridge",
21
25
  "log.action.stopOtherOwner": "Stop the other Homebridge Eufy owner before restarting Homebridge",
22
- "log.action.selectSupportedArmingMode": "Select Stay, Away, or Disarm to leave the unsupported mode",
26
+ "log.action.mapArmingMode": "Assign this mode to a HomeKit state under HomeKit guard modes in the device's settings in the Homebridge Eufy dashboard",
23
27
  "log.action.waitBattery": "Wait for complete battery capability evidence",
24
28
  "log.action.waitArming": "Wait for complete arming capability evidence",
25
29
  "log.action.waitBatteryObservation": "Wait for a valid battery observation",
@@ -85,7 +89,7 @@
85
89
  "log.notice.stateSubscriberFailed": "A runtime state consumer failed; review Homebridge Eufy health in the dashboard.",
86
90
  "log.notice.statusPublicationFailed": "Runtime status could not be published; dashboard status may be stale.",
87
91
  "log.notice.channelServeFailed": "Live runtime status could not be served to the plugin settings page; it will show the last published status instead.",
88
- "log.notice.diagnosticsAuthorizationArmed": "Diagnostic evidence collection is now active in the plugin runtime.",
92
+ "log.notice.diagnosticsAuthorizationArmed": "A diagnostics capture started; finish it from the plugin settings before {finishBy}.",
89
93
  "log.notice.standDownNotRearmed": "Homebridge Eufy stood down for an authentication and could not take the account session back; restart Homebridge to bring it back.",
90
94
  "log.notice.standDownAccountReplaced": "Homebridge Eufy stood down for an authentication that replaced the account; restart Homebridge to use the new account.",
91
95
  "log.runtime.authenticationRequired": "Eufy authentication is required",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "Homebridge Eufy",
3
3
  "name": "@homebridge-plugins/homebridge-eufy-security",
4
- "version": "5.0.0-beta.2",
4
+ "version": "5.0.0-beta.21",
5
5
  "description": "Control Eufy Security from homebridge.",
6
6
  "type": "module",
7
7
  "license": "Apache-2.0",
@@ -47,17 +47,17 @@
47
47
  ],
48
48
  "dependencies": {
49
49
  "@homebridge/plugin-ui-utils": "^2.2.6",
50
- "@mega-yfue/eufy-sdk": "0.1.0",
50
+ "@mega-yfue/eufy-sdk": "0.4.0-beta.18",
51
51
  "ffmpeg-for-homebridge": "2.2.2"
52
52
  },
53
53
  "devDependencies": {
54
- "@homebridge/hap-nodejs": "2.2.2",
55
- "@types/node": "^26.4.1",
54
+ "@homebridge/hap-nodejs": "2.2.3",
55
+ "@types/node": "^26.6.3",
56
56
  "homebridge": "^2.4.0",
57
- "prettier": "^3.9.6",
57
+ "prettier": "^3.9.9",
58
58
  "rimraf": "^6.1.3",
59
59
  "typescript": "^7.0.2",
60
- "vitest": "^5.0.0"
60
+ "vitest": "^5.0.3"
61
61
  },
62
62
  "homepage": "https://homebridge-plugins.github.io/homebridge-eufy/",
63
63
  "author": "homebridge-eufy",