@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.16 → 5.0.0-beta.17

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.
@@ -270,13 +270,14 @@ const BUG_REPORT_FORM = 'https://github.com/homebridge-plugins/homebridge-eufy/i
270
270
  function bugReportUrl(session, missingEvidence, host) {
271
271
  const url = new URL(BUG_REPORT_FORM);
272
272
  url.searchParams.set('template', 'bug_report.yml');
273
+ url.searchParams.set('title', `[Diagnostics] ${BUG_REPORT_AREAS[session.profile]}: `);
273
274
  url.searchParams.set('environment', [
274
275
  `- **Plugin Version**: ${PLUGIN_VERSION}`,
275
276
  `- **eufy SDK**: ${SDK_VERSION}`,
276
277
  `- **Node.js Version**: ${process.version}`,
277
278
  `- **Homebridge Version**: ${host?.homebridge ?? ''}`,
278
279
  `- **OS**: ${process.platform} ${process.arch}`,
279
- `- **Diagnostics profile**: ${session.profile} (${session.reproductionMode})`,
280
+ `- **Diagnostics profile**: ${session.profile}`,
280
281
  `- **Missing evidence**: ${missingEvidence.length ? missingEvidence.join(', ') : 'none'}`,
281
282
  ].join('\n'));
282
283
  url.searchParams.set('area', BUG_REPORT_AREAS[session.profile]);
@@ -289,7 +290,7 @@ const DIAGNOSTICS_PROFILES = {
289
290
  'live-media': ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
290
291
  'hksv-recording': ['plugin-log', 'sdk-log', 'ffmpeg-log'],
291
292
  'dashboard-ui': ['plugin-log', 'ui-log'],
292
- other: ['plugin-log', 'sdk-log', 'homekit-log'],
293
+ other: ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
293
294
  };
294
295
  /** Narrows external profile input against the diagnostics-owned profile registry. */
295
296
  export function isDiagnosticsProfile(value) {
@@ -339,6 +340,22 @@ function activeDiagnosticsSession(storageRoot, now) {
339
340
  const session = readDiagnosticsSession(storageRoot);
340
341
  return session && Date.parse(session.expiresAt) > now ? session : undefined;
341
342
  }
343
+ /**
344
+ * A session as it stands at `now`: a capture still running when its authorization ends is finished at that end.
345
+ *
346
+ * A finished capture can be reviewed and exported until the archive retention has elapsed past the end of its
347
+ * authorization, so a capture that ran out its 72 hours keeps what it captured.
348
+ */
349
+ function settledDiagnosticsSession(session, now) {
350
+ const expiresAt = Date.parse(session.expiresAt);
351
+ const settled = session.reproductionStartedAt && !session.reproductionEndedAt && expiresAt <= now
352
+ ? { ...session, reproductionEndedAt: session.expiresAt }
353
+ : session;
354
+ return {
355
+ session: settled,
356
+ exportable: Boolean(settled.reproductionEndedAt) && now < expiresAt + SUPPORT_ARCHIVE_RETENTION_MS,
357
+ };
358
+ }
342
359
  /**
343
360
  * Collapses one supplied string to bounded printable text, or nothing where none of it survives.
344
361
  *
@@ -577,6 +594,22 @@ export class GuidedDiagnostics {
577
594
  await this.ensureMarker(session, 'reproduction-started', session.reproductionStartedAt);
578
595
  return this.project(session);
579
596
  }
597
+ /**
598
+ * Deletes the persisted session without an archive, which also ends the verbose retention it authorized. UI events
599
+ * already queued are written first and later ones are refused until it is gone, so none lands after the delete.
600
+ */
601
+ async cancel() {
602
+ this.uiEventsClosing = true;
603
+ try {
604
+ await this.uiEventWrites;
605
+ this.pendingSupportArchive = undefined;
606
+ await rm(diagnosticsSessionPath(this.storageRoot), { force: true });
607
+ return await this.status();
608
+ }
609
+ finally {
610
+ this.uiEventsClosing = false;
611
+ }
612
+ }
580
613
  async endReproduction() {
581
614
  let session = this.requireAuthorized();
582
615
  if (!session.reproductionStartedAt) {
@@ -657,10 +690,16 @@ export class GuidedDiagnostics {
657
690
  /** Prepares the exact manifest and evidence snapshot that one subsequent export may encrypt. */
658
691
  async reviewSupportArchive() {
659
692
  await this.uiEventWrites;
660
- const session = readDiagnosticsSession(this.storageRoot);
693
+ const persisted = readDiagnosticsSession(this.storageRoot);
694
+ const { session, exportable } = persisted
695
+ ? settledDiagnosticsSession(persisted, this.now())
696
+ : { session: undefined, exportable: false };
661
697
  if (!session?.reproductionStartedAt || !session.reproductionEndedAt) {
662
698
  throw new Error('A completed reproduction is required before archive review');
663
699
  }
700
+ if (!exportable) {
701
+ throw new Error('Diagnostics authorization is inactive or expired');
702
+ }
664
703
  const collected = await this.collectSupportEvidence(session);
665
704
  const reproductionStartedAt = Date.parse(session.reproductionStartedAt);
666
705
  const selected = DIAGNOSTICS_PROFILES[session.profile];
@@ -703,7 +742,13 @@ export class GuidedDiagnostics {
703
742
  this.pendingSupportArchive = { reviewId, manifest, evidence: collected };
704
743
  return { reviewId, manifest };
705
744
  }
706
- /** Consumes one reviewed snapshot and returns an encrypted envelope without writing plaintext or an archive to disk. */
745
+ /**
746
+ * Consumes one reviewed snapshot and returns an encrypted envelope without writing plaintext or an archive to disk.
747
+ *
748
+ * The session outlives the export, because nothing here proves the envelope reached the reporter: a fresh review
749
+ * exports it again until the reporter leaves it with {@link cancel}, another capture replaces it, or the archive
750
+ * retention ends it.
751
+ */
707
752
  async exportSupportArchive(reviewId) {
708
753
  const pending = this.pendingSupportArchive;
709
754
  if (!pending || pending.reviewId !== reviewId) {
@@ -749,11 +794,12 @@ export class GuidedDiagnostics {
749
794
  authTag: cipher.getAuthTag().toString('base64'),
750
795
  ciphertext: ciphertext.toString('base64'),
751
796
  };
752
- return {
797
+ const exported = {
753
798
  filename: `homebridge-eufy-${pending.manifest.supportCaseId}.eufysupport.gz`,
754
799
  mediaType: 'application/gzip',
755
800
  archive: await gzip(Buffer.from(`${JSON.stringify(envelope)}\n`, 'utf8')),
756
801
  };
802
+ return exported;
757
803
  }
758
804
  finally {
759
805
  contentKey.fill(0);
@@ -1038,16 +1084,17 @@ export class GuidedDiagnostics {
1038
1084
  throw new Error('Diagnostics authorization is inactive or expired');
1039
1085
  return session;
1040
1086
  }
1041
- async project(session) {
1042
- if (!session) {
1087
+ async project(persisted) {
1088
+ if (!persisted) {
1043
1089
  return { status: 'inactive', selectedEvidence: [], missingEvidence: [], partialExportAvailable: false };
1044
1090
  }
1091
+ const { session, exportable } = settledDiagnosticsSession(persisted, this.now());
1045
1092
  const selectedEvidence = DIAGNOSTICS_PROFILES[session.profile];
1046
1093
  const collected = session.reproductionStartedAt && session.reproductionEndedAt
1047
1094
  ? await this.collectSupportEvidence(session)
1048
1095
  : undefined;
1049
1096
  const missingEvidence = collected ? missingEvidenceClasses(selectedEvidence, collected) : [];
1050
- const expired = Date.parse(session.expiresAt) <= this.now();
1097
+ const expired = Date.parse(session.expiresAt) <= this.now() && !exportable;
1051
1098
  const status = expired
1052
1099
  ? 'expired'
1053
1100
  : session.reproductionEndedAt
@@ -1066,7 +1113,7 @@ export class GuidedDiagnostics {
1066
1113
  expiresAt: session.expiresAt,
1067
1114
  ...(session.reproductionStartedAt ? { reproductionStartedAt: session.reproductionStartedAt } : {}),
1068
1115
  ...(session.reproductionEndedAt ? { reproductionEndedAt: session.reproductionEndedAt } : {}),
1069
- partialExportAvailable: Boolean(session.reproductionEndedAt),
1116
+ partialExportAvailable: exportable,
1070
1117
  ...(session.affectedDevices === undefined ? {} : { affectedDevices: session.affectedDevices }),
1071
1118
  issueUrl: bugReportUrl(session, missingEvidence, readHostEnvironment(this.storageRoot)),
1072
1119
  };
@@ -1984,6 +2031,33 @@ const HOMEKIT_REASON_ACTIONS = {
1984
2031
  'camera-recording-unavailable:adapter-missing': 'log.action.setFfmpegPath',
1985
2032
  'camera-live-session-failed:adaptation-spawn-failed': 'log.action.checkFfmpegPath',
1986
2033
  };
2034
+ /** Projects a value onto an active condition, or nothing where the catalog does not write that code with those keys. */
2035
+ export function knownCondition(value) {
2036
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
2037
+ return undefined;
2038
+ const { code, summaryKey, actionKey, serials } = value;
2039
+ if (typeof code !== 'string')
2040
+ return undefined;
2041
+ const runtime = Object.values(RUNTIME_CONDITIONS).find((condition) => condition.code === code);
2042
+ const homeKit = Object.hasOwn(HOMEKIT_CONDITIONS, code)
2043
+ ? HOMEKIT_CONDITIONS[code]
2044
+ : undefined;
2045
+ const [summary, ...actions] = runtime
2046
+ ? [runtime.summaryKey, runtime.actionKey]
2047
+ : homeKit
2048
+ ? [
2049
+ ...homeKit,
2050
+ ...Object.entries(HOMEKIT_REASON_ACTIONS)
2051
+ .filter(([key]) => key.startsWith(`${code}:`))
2052
+ .map(([, action]) => action),
2053
+ ]
2054
+ : [];
2055
+ const action = allowlistedLabel(actionKey, actions);
2056
+ if (summary === undefined || summaryKey !== summary || action === undefined)
2057
+ return undefined;
2058
+ const named = Array.isArray(serials) ? serials.filter((serial) => typeof serial === 'string') : [];
2059
+ return { code, summaryKey: summary, actionKey: action, ...(named.length === 0 ? {} : { serials: named }) };
2060
+ }
1987
2061
  /** HomeKit conditions that state a gap nothing in the user's setup closes, so an active one is not a warning. */
1988
2062
  const INFORMATIONAL_HOMEKIT_CONDITIONS = new Set(['recognized-device-not-represented']);
1989
2063
  /** The level and action one HomeKit condition is written with, decided once for the console and the record. */
@@ -2179,7 +2253,7 @@ export function reportRuntimeNotice(target, code, fields = {}) {
2179
2253
  const notice = RUNTIME_NOTICES[code];
2180
2254
  const durationMs = fields.durationMs === undefined ? undefined : Math.max(0, Math.trunc(fields.durationMs));
2181
2255
  const messageKey = durationMs !== undefined && 'durationMessageKey' in notice ? notice.durationMessageKey : notice.messageKey;
2182
- target[notice.level]?.(`[${code}] ${localize(target, messageKey, { durationMs: durationMs ?? 0 })}`);
2256
+ target[notice.level]?.(`[${code}] ${localize(target, messageKey, { durationMs: durationMs ?? 0, finishBy: fields.finishBy ?? '' })}`);
2183
2257
  target.debug?.(JSON.stringify({
2184
2258
  scope: 'runtime-notice',
2185
2259
  level: notice.level,
@@ -2194,14 +2268,17 @@ export function reportRuntimeNotice(target, code, fields = {}) {
2194
2268
  * The file is the authority: the supplied identifier only names which window the caller believes was opened,
2195
2269
  * and a session the file does not hold as active changes nothing and reports nothing. A confirmed pickup emits
2196
2270
  * one operational record, which is the evidence that this process began retaining the window's scopes, taken
2197
- * at the moment it did rather than whenever a later record arrives.
2271
+ * at the moment it did rather than whenever a later record arrives, and its console line names the time the
2272
+ * capture stops on its own.
2198
2273
  */
2199
2274
  export function armDiagnosticsAuthorization(target, storageRoot, supportCaseId, now = Date.now) {
2200
- if (!isSupportCaseId(supportCaseId) ||
2201
- activeDiagnosticsSession(storageRoot, now())?.supportCaseId !== supportCaseId) {
2275
+ const session = activeDiagnosticsSession(storageRoot, now());
2276
+ if (!isSupportCaseId(supportCaseId) || session?.supportCaseId !== supportCaseId) {
2202
2277
  return false;
2203
2278
  }
2204
- reportRuntimeNotice(target, 'diagnostics-authorization-armed');
2279
+ reportRuntimeNotice(target, 'diagnostics-authorization-armed', {
2280
+ finishBy: new Date(session.expiresAt).toLocaleString(),
2281
+ });
2205
2282
  return true;
2206
2283
  }
2207
2284
  /** Emits the startup notice for discarded settings awaiting acknowledgement. */
@@ -2575,7 +2652,11 @@ export class DiagnosticConditions {
2575
2652
  const alias = this.aliases.get(serial);
2576
2653
  return name === undefined ? undefined : alias === undefined ? name : `${name} (${alias})`;
2577
2654
  })
2578
- .filter((named) => named !== undefined));
2655
+ .filter((named) => named !== undefined), uniqueDeviceIds);
2656
+ }
2657
+ /** Every condition active now, each held from the transition that raised it to the one that clears it. */
2658
+ current() {
2659
+ return [...this.active.values()].map(({ condition }) => condition);
2579
2660
  }
2580
2661
  /**
2581
2662
  * The support-case alias for one device identity, minted on first use.
@@ -2593,13 +2674,16 @@ export class DiagnosticConditions {
2593
2674
  }
2594
2675
  write(code, active, reason, summaryKey, actionKey, level, fields = {}, conditionKey = code,
2595
2676
  /** Accessory names for the console line only — never retained, see {@link DiagnosticConditions}. */
2596
- names) {
2597
- const fingerprint = JSON.stringify({ active, reason, ...fields });
2677
+ names, serials = []) {
2678
+ const fingerprint = JSON.stringify({ active, reason, ...fields, serials });
2598
2679
  if (active) {
2599
- if (this.active.get(conditionKey) === fingerprint) {
2680
+ if (this.active.get(conditionKey)?.fingerprint === fingerprint) {
2600
2681
  return;
2601
2682
  }
2602
- this.active.set(conditionKey, fingerprint);
2683
+ this.active.set(conditionKey, {
2684
+ fingerprint,
2685
+ condition: { code, summaryKey, actionKey, ...(serials.length === 0 ? {} : { serials: [...serials] }) },
2686
+ });
2603
2687
  }
2604
2688
  else if (!this.active.delete(conditionKey)) {
2605
2689
  return;