@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.40 → 5.0.0-beta.42

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.
@@ -254,6 +254,20 @@ const DIAGNOSTICS_UI_EVENTS = [
254
254
  'dashboard-opened',
255
255
  'authentication-opened',
256
256
  'request-failed',
257
+ 'authentication-submitted',
258
+ 'authentication-captcha',
259
+ 'authentication-two-factor',
260
+ 'authentication-restart-required',
261
+ 'authentication-blocked',
262
+ 'authentication-plugin-running',
263
+ 'authentication-failed',
264
+ 'authentication-commit-failed',
265
+ 'authentication-timed-out',
266
+ 'authentication-closed',
267
+ 'live-picture-seen',
268
+ 'live-picture-not-seen',
269
+ 'live-preview-seen',
270
+ 'live-preview-not-seen',
257
271
  ];
258
272
  const SUPPORT_ARCHIVE_KEY = {
259
273
  keyId: 'support-2026-08-01',
@@ -331,7 +345,7 @@ function bugReportUrl(session, missingEvidence, host) {
331
345
  return url.toString();
332
346
  }
333
347
  const DIAGNOSTICS_PROFILES = {
334
- 'startup-authentication': ['plugin-log', 'sdk-log'],
348
+ 'startup-authentication': ['plugin-log', 'sdk-log', 'ui-log'],
335
349
  'device-representation': ['plugin-log', 'sdk-log', 'homekit-log'],
336
350
  'control-state': ['plugin-log', 'sdk-log', 'homekit-log'],
337
351
  'live-media': ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
@@ -339,6 +353,18 @@ const DIAGNOSTICS_PROFILES = {
339
353
  'dashboard-ui': ['plugin-log', 'ui-log'],
340
354
  other: ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
341
355
  };
356
+ /**
357
+ * The evidence a session collects: its profile's, and the UI log too for a live-media capture that named cameras,
358
+ * whose guided encoder attempts record the reporter's picture answers.
359
+ */
360
+ function selectedEvidence(session) {
361
+ const evidence = DIAGNOSTICS_PROFILES[session.profile];
362
+ return session.profile === 'live-media' &&
363
+ Array.isArray(session.affectedDevices) &&
364
+ session.affectedDevices.length > 0
365
+ ? [...evidence, 'ui-log']
366
+ : evidence;
367
+ }
342
368
  /** Narrows external profile input against the diagnostics-owned profile registry. */
343
369
  export function isDiagnosticsProfile(value) {
344
370
  return typeof value === 'string' && Object.hasOwn(DIAGNOSTICS_PROFILES, value);
@@ -387,6 +413,35 @@ function activeDiagnosticsSession(storageRoot, now) {
387
413
  const session = readDiagnosticsSession(storageRoot);
388
414
  return session && Date.parse(session.expiresAt) > now ? session : undefined;
389
415
  }
416
+ /** How many live starts of each named camera a guided live-media capture codes with an encoder variant. */
417
+ const LIVE_ENCODE_ATTEMPTS = 6;
418
+ /**
419
+ * Counts each live start of a camera that a reproducing live-media capture named, and returns where it falls: the
420
+ * camera's position among the named ones, and the start's index while it is below {@link LIVE_ENCODE_ATTEMPTS}.
421
+ * Counting restarts with each reproduction. Outside one, and for a camera it did not name, it counts nothing.
422
+ */
423
+ export function liveEncodeAttempts(storageRoot, now = Date.now) {
424
+ let reproduction;
425
+ const starts = new Map();
426
+ return (serial) => {
427
+ const session = activeDiagnosticsSession(storageRoot, now());
428
+ const named = session?.affectedDevices;
429
+ const camera = named === undefined || named === 'all' ? -1 : named.indexOf(serial);
430
+ if (session?.profile !== 'live-media' ||
431
+ !session.reproductionStartedAt ||
432
+ session.reproductionEndedAt ||
433
+ camera < 0) {
434
+ return undefined;
435
+ }
436
+ if (reproduction !== `${session.supportCaseId}@${session.reproductionStartedAt}`) {
437
+ reproduction = `${session.supportCaseId}@${session.reproductionStartedAt}`;
438
+ starts.clear();
439
+ }
440
+ const started = starts.get(serial) ?? 0;
441
+ starts.set(serial, started + 1);
442
+ return { camera, ...(started < LIVE_ENCODE_ATTEMPTS ? { variant: started } : {}) };
443
+ };
444
+ }
390
445
  /**
391
446
  * A session as it stands at `now`: a capture still running when its authorization ends is finished at that end.
392
447
  *
@@ -732,8 +787,10 @@ export class GuidedDiagnostics {
732
787
  this.pendingUiEvents += 1;
733
788
  const write = this.uiEventWrites.then(async () => {
734
789
  const session = this.requireAuthorized();
735
- if (session.profile !== 'dashboard-ui' || !session.reproductionStartedAt || session.reproductionEndedAt) {
736
- throw new Error('An active dashboard reproduction is required');
790
+ if (!selectedEvidence(session).includes('ui-log') ||
791
+ !session.reproductionStartedAt ||
792
+ session.reproductionEndedAt) {
793
+ throw new Error('An active dashboard or sign-in reproduction is required');
737
794
  }
738
795
  const directory = join(this.storageRoot, DIAGNOSTICS_DIRECTORY);
739
796
  const path = join(directory, UI_EVENTS_FILE);
@@ -784,7 +841,7 @@ export class GuidedDiagnostics {
784
841
  }
785
842
  const collected = await this.collectSupportEvidence(session);
786
843
  const reproductionStartedAt = Date.parse(session.reproductionStartedAt);
787
- const selected = DIAGNOSTICS_PROFILES[session.profile];
844
+ const selected = selectedEvidence(session);
788
845
  const createdAt = this.now();
789
846
  const manifest = {
790
847
  version: 2,
@@ -983,7 +1040,7 @@ export class GuidedDiagnostics {
983
1040
  });
984
1041
  }
985
1042
  const log = await this.readReproductionLog(session.reproductionStartedAt, session.reproductionEndedAt);
986
- for (const selected of DIAGNOSTICS_PROFILES[session.profile]) {
1043
+ for (const selected of selectedEvidence(session)) {
987
1044
  const carried = log.get(selected);
988
1045
  if (!carried)
989
1046
  continue;
@@ -1171,11 +1228,11 @@ export class GuidedDiagnostics {
1171
1228
  return { status: 'inactive', selectedEvidence: [], missingEvidence: [], partialExportAvailable: false };
1172
1229
  }
1173
1230
  const { session, exportable } = settledDiagnosticsSession(persisted, this.now());
1174
- const selectedEvidence = DIAGNOSTICS_PROFILES[session.profile];
1231
+ const selected = selectedEvidence(session);
1175
1232
  const collected = session.reproductionStartedAt && session.reproductionEndedAt
1176
1233
  ? await this.collectSupportEvidence(session)
1177
1234
  : undefined;
1178
- const missingEvidence = collected ? missingEvidenceClasses(selectedEvidence, collected) : [];
1235
+ const missingEvidence = collected ? missingEvidenceClasses(selected, collected) : [];
1179
1236
  const expired = Date.parse(session.expiresAt) <= this.now() && !exportable;
1180
1237
  const status = expired
1181
1238
  ? 'expired'
@@ -1189,7 +1246,7 @@ export class GuidedDiagnostics {
1189
1246
  supportCaseId: session.supportCaseId,
1190
1247
  profile: session.profile,
1191
1248
  reproductionMode: session.reproductionMode,
1192
- selectedEvidence,
1249
+ selectedEvidence: selected,
1193
1250
  missingEvidence,
1194
1251
  authorizedAt: session.authorizedAt,
1195
1252
  expiresAt: session.expiresAt,
@@ -1198,8 +1255,76 @@ export class GuidedDiagnostics {
1198
1255
  partialExportAvailable: exportable,
1199
1256
  ...(session.affectedDevices === undefined ? {} : { affectedDevices: session.affectedDevices }),
1200
1257
  issueUrl: bugReportUrl(session, missingEvidence, readHostEnvironment(this.storageRoot)),
1258
+ ...(status === 'reproducing' && session.profile === 'live-media' && Array.isArray(session.affectedDevices)
1259
+ ? { liveEncode: await this.liveEncode(session) }
1260
+ : {}),
1201
1261
  };
1202
1262
  }
1263
+ /** What {@link liveEncodeAttempts} was followed through so far, for one reproduction of one capture. */
1264
+ liveEncodeScan;
1265
+ /**
1266
+ * Follows the live starts a reproducing live-media capture recorded, reading only what the current plugin log
1267
+ * gained since the last read. The starts are kept across a rotation; a process that restarts after one follows
1268
+ * only what the new file holds.
1269
+ */
1270
+ async liveEncode(session) {
1271
+ const reproduction = `${session.supportCaseId}@${session.reproductionStartedAt}`;
1272
+ if (this.liveEncodeScan?.reproduction !== reproduction) {
1273
+ this.liveEncodeScan = { reproduction, offset: 0, starts: [], byAccessory: new Map() };
1274
+ }
1275
+ const scan = this.liveEncodeScan;
1276
+ const startedAt = Date.parse(session.reproductionStartedAt);
1277
+ try {
1278
+ const file = await open(join(this.storageRoot, LOG_DIRECTORY, LOG_FILE), 'r');
1279
+ try {
1280
+ const size = (await file.stat()).size;
1281
+ if (size < scan.offset)
1282
+ scan.offset = 0;
1283
+ const buffer = Buffer.alloc(size - scan.offset);
1284
+ await file.read(buffer, 0, buffer.length, scan.offset);
1285
+ const end = buffer.lastIndexOf(0x0a) + 1;
1286
+ scan.offset += end;
1287
+ for (const line of buffer.subarray(0, end).toString('utf8').split('\n')) {
1288
+ let record;
1289
+ try {
1290
+ record = JSON.parse(line);
1291
+ }
1292
+ catch {
1293
+ continue;
1294
+ }
1295
+ if (record.adapter !== 'camera.streaming' || !(Date.parse(String(record.timestamp)) >= startedAt))
1296
+ continue;
1297
+ const known = scan.byAccessory.get(record.accessory);
1298
+ if (record.event === 'live-video-selected' && record.operation === 'start') {
1299
+ const start = {
1300
+ ...(typeof record.camera === 'number' ? { camera: record.camera } : {}),
1301
+ ...(typeof record.variant === 'number' ? { variant: record.variant } : {}),
1302
+ state: 'started',
1303
+ };
1304
+ scan.starts.push(start);
1305
+ scan.byAccessory.set(record.accessory, start);
1306
+ }
1307
+ else if (record.event === 'live-session-streaming' && known?.state === 'started') {
1308
+ known.state = 'streaming';
1309
+ }
1310
+ else if (record.event === 'live-session-released' && known) {
1311
+ known.state = 'ended';
1312
+ }
1313
+ }
1314
+ }
1315
+ finally {
1316
+ await file.close();
1317
+ }
1318
+ }
1319
+ catch (error) {
1320
+ if (error.code !== 'ENOENT')
1321
+ throw error;
1322
+ }
1323
+ const events = (await this.readUiEvents(session)).match(/"event":"live-(?:picture|preview)-[a-z-]+"/g) ?? [];
1324
+ const answers = events.filter((event) => event.includes('picture')).length;
1325
+ const previews = events.filter((event) => event.includes('preview')).map((event) => !event.includes('not-seen'));
1326
+ return { attempts: LIVE_ENCODE_ATTEMPTS, starts: scan.starts, answers, previews };
1327
+ }
1203
1328
  /**
1204
1329
  * Replaces the persisted session, and drops the `diagnostics/evidence` tree with it.
1205
1330
  *
@@ -1376,7 +1501,7 @@ export function createDiagnosticLogger(target, storageRoot, now = Date.now) {
1376
1501
  return;
1377
1502
  const session = activeDiagnosticsSession(storageRoot, now());
1378
1503
  const verbose = VERBOSE_SCOPES.has(String(event.scope));
1379
- if (verbose && (!session || !DIAGNOSTICS_PROFILES[session.profile].includes(evidenceClassOf(event)))) {
1504
+ if (verbose && (!session || !selectedEvidence(session).includes(evidenceClassOf(event)))) {
1380
1505
  return;
1381
1506
  }
1382
1507
  file.write(event);
@@ -2184,7 +2309,7 @@ const HOMEKIT_REASON_ACTIONS = {
2184
2309
  'camera-live-session-failed:station-unreachable': 'log.action.checkStation',
2185
2310
  'camera-live-session-failed:source-warm-timeout': 'log.action.retryLiveViewNoPicture',
2186
2311
  'camera-snapshot-unavailable:live-station-unreachable': 'log.action.checkStation',
2187
- 'camera-talkback-failed:no-controller-audio': 'log.action.trySmallVideoPackets',
2312
+ 'camera-talkback-failed:no-controller-audio': 'log.action.checkTalkbackMicrophone',
2188
2313
  };
2189
2314
  /** Projects a value onto an active condition, or nothing where the catalog does not write that code with those keys. */
2190
2315
  export function knownCondition(value) {
@@ -2597,6 +2722,7 @@ function sanitizeAdaptationNotice(value, level) {
2597
2722
  const sourceHeight = nonNegativeInteger(value.sourceHeight);
2598
2723
  const cause = allowlistedLabel(value.cause, ADAPTATION_CAUSES);
2599
2724
  const encoder = allowlistedLabel(value.encoder, LIVE_VIDEO_ENCODERS);
2725
+ const variant = boundedInteger(value.variant, LIVE_ENCODE_ATTEMPTS - 1);
2600
2726
  const source = allowlistedLabel(value.source, LIVE_VIDEO_SOURCES);
2601
2727
  return {
2602
2728
  scope: 'ffmpeg',
@@ -2605,6 +2731,7 @@ function sanitizeAdaptationNotice(value, level) {
2605
2731
  event: value.event,
2606
2732
  ...(cause === undefined ? {} : { cause }),
2607
2733
  ...(encoder === undefined ? {} : { encoder }),
2734
+ ...(variant === undefined ? {} : { variant }),
2608
2735
  ...(source === undefined ? {} : { source }),
2609
2736
  ...(sourceWidth === undefined || sourceHeight === undefined ? {} : { sourceWidth, sourceHeight }),
2610
2737
  ...(code === undefined ? {} : { code }),
@@ -2730,6 +2857,8 @@ function sanitizeLiveVideoSelection(value) {
2730
2857
  const mtu = boundedInteger(value.mtu, MAX_LIVE_VIDEO_MTU, MIN_LIVE_VIDEO_MTU);
2731
2858
  const maxBitRate = boundedInteger(value.maxBitRate, MAX_LIVE_VIDEO_BIT_RATE, 0);
2732
2859
  const addressVersion = allowlistedLabel(value.addressVersion, HOMEKIT_LIVE_ADDRESS_VERSIONS);
2860
+ const camera = boundedInteger(value.camera, MAX_ACCESSORY_ALIASES);
2861
+ const variant = boundedInteger(value.variant, LIVE_ENCODE_ATTEMPTS - 1);
2733
2862
  if (!operation ||
2734
2863
  !profile ||
2735
2864
  !codecLevel ||
@@ -2753,6 +2882,8 @@ function sanitizeLiveVideoSelection(value) {
2753
2882
  mtu,
2754
2883
  ...(maxBitRate === undefined ? {} : { maxBitRate }),
2755
2884
  addressVersion,
2885
+ ...(camera === undefined ? {} : { camera }),
2886
+ ...(variant === undefined ? {} : { variant }),
2756
2887
  ...(alias === undefined ? {} : { accessory: alias }),
2757
2888
  };
2758
2889
  }