@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.41 → 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.
@@ -264,6 +264,10 @@ const DIAGNOSTICS_UI_EVENTS = [
264
264
  'authentication-commit-failed',
265
265
  'authentication-timed-out',
266
266
  'authentication-closed',
267
+ 'live-picture-seen',
268
+ 'live-picture-not-seen',
269
+ 'live-preview-seen',
270
+ 'live-preview-not-seen',
267
271
  ];
268
272
  const SUPPORT_ARCHIVE_KEY = {
269
273
  keyId: 'support-2026-08-01',
@@ -349,6 +353,18 @@ const DIAGNOSTICS_PROFILES = {
349
353
  'dashboard-ui': ['plugin-log', 'ui-log'],
350
354
  other: ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
351
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
+ }
352
368
  /** Narrows external profile input against the diagnostics-owned profile registry. */
353
369
  export function isDiagnosticsProfile(value) {
354
370
  return typeof value === 'string' && Object.hasOwn(DIAGNOSTICS_PROFILES, value);
@@ -397,6 +413,35 @@ function activeDiagnosticsSession(storageRoot, now) {
397
413
  const session = readDiagnosticsSession(storageRoot);
398
414
  return session && Date.parse(session.expiresAt) > now ? session : undefined;
399
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
+ }
400
445
  /**
401
446
  * A session as it stands at `now`: a capture still running when its authorization ends is finished at that end.
402
447
  *
@@ -742,7 +787,7 @@ export class GuidedDiagnostics {
742
787
  this.pendingUiEvents += 1;
743
788
  const write = this.uiEventWrites.then(async () => {
744
789
  const session = this.requireAuthorized();
745
- if (!DIAGNOSTICS_PROFILES[session.profile].includes('ui-log') ||
790
+ if (!selectedEvidence(session).includes('ui-log') ||
746
791
  !session.reproductionStartedAt ||
747
792
  session.reproductionEndedAt) {
748
793
  throw new Error('An active dashboard or sign-in reproduction is required');
@@ -796,7 +841,7 @@ export class GuidedDiagnostics {
796
841
  }
797
842
  const collected = await this.collectSupportEvidence(session);
798
843
  const reproductionStartedAt = Date.parse(session.reproductionStartedAt);
799
- const selected = DIAGNOSTICS_PROFILES[session.profile];
844
+ const selected = selectedEvidence(session);
800
845
  const createdAt = this.now();
801
846
  const manifest = {
802
847
  version: 2,
@@ -995,7 +1040,7 @@ export class GuidedDiagnostics {
995
1040
  });
996
1041
  }
997
1042
  const log = await this.readReproductionLog(session.reproductionStartedAt, session.reproductionEndedAt);
998
- for (const selected of DIAGNOSTICS_PROFILES[session.profile]) {
1043
+ for (const selected of selectedEvidence(session)) {
999
1044
  const carried = log.get(selected);
1000
1045
  if (!carried)
1001
1046
  continue;
@@ -1183,11 +1228,11 @@ export class GuidedDiagnostics {
1183
1228
  return { status: 'inactive', selectedEvidence: [], missingEvidence: [], partialExportAvailable: false };
1184
1229
  }
1185
1230
  const { session, exportable } = settledDiagnosticsSession(persisted, this.now());
1186
- const selectedEvidence = DIAGNOSTICS_PROFILES[session.profile];
1231
+ const selected = selectedEvidence(session);
1187
1232
  const collected = session.reproductionStartedAt && session.reproductionEndedAt
1188
1233
  ? await this.collectSupportEvidence(session)
1189
1234
  : undefined;
1190
- const missingEvidence = collected ? missingEvidenceClasses(selectedEvidence, collected) : [];
1235
+ const missingEvidence = collected ? missingEvidenceClasses(selected, collected) : [];
1191
1236
  const expired = Date.parse(session.expiresAt) <= this.now() && !exportable;
1192
1237
  const status = expired
1193
1238
  ? 'expired'
@@ -1201,7 +1246,7 @@ export class GuidedDiagnostics {
1201
1246
  supportCaseId: session.supportCaseId,
1202
1247
  profile: session.profile,
1203
1248
  reproductionMode: session.reproductionMode,
1204
- selectedEvidence,
1249
+ selectedEvidence: selected,
1205
1250
  missingEvidence,
1206
1251
  authorizedAt: session.authorizedAt,
1207
1252
  expiresAt: session.expiresAt,
@@ -1210,8 +1255,76 @@ export class GuidedDiagnostics {
1210
1255
  partialExportAvailable: exportable,
1211
1256
  ...(session.affectedDevices === undefined ? {} : { affectedDevices: session.affectedDevices }),
1212
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
+ : {}),
1213
1261
  };
1214
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
+ }
1215
1328
  /**
1216
1329
  * Replaces the persisted session, and drops the `diagnostics/evidence` tree with it.
1217
1330
  *
@@ -1388,7 +1501,7 @@ export function createDiagnosticLogger(target, storageRoot, now = Date.now) {
1388
1501
  return;
1389
1502
  const session = activeDiagnosticsSession(storageRoot, now());
1390
1503
  const verbose = VERBOSE_SCOPES.has(String(event.scope));
1391
- if (verbose && (!session || !DIAGNOSTICS_PROFILES[session.profile].includes(evidenceClassOf(event)))) {
1504
+ if (verbose && (!session || !selectedEvidence(session).includes(evidenceClassOf(event)))) {
1392
1505
  return;
1393
1506
  }
1394
1507
  file.write(event);
@@ -2609,6 +2722,7 @@ function sanitizeAdaptationNotice(value, level) {
2609
2722
  const sourceHeight = nonNegativeInteger(value.sourceHeight);
2610
2723
  const cause = allowlistedLabel(value.cause, ADAPTATION_CAUSES);
2611
2724
  const encoder = allowlistedLabel(value.encoder, LIVE_VIDEO_ENCODERS);
2725
+ const variant = boundedInteger(value.variant, LIVE_ENCODE_ATTEMPTS - 1);
2612
2726
  const source = allowlistedLabel(value.source, LIVE_VIDEO_SOURCES);
2613
2727
  return {
2614
2728
  scope: 'ffmpeg',
@@ -2617,6 +2731,7 @@ function sanitizeAdaptationNotice(value, level) {
2617
2731
  event: value.event,
2618
2732
  ...(cause === undefined ? {} : { cause }),
2619
2733
  ...(encoder === undefined ? {} : { encoder }),
2734
+ ...(variant === undefined ? {} : { variant }),
2620
2735
  ...(source === undefined ? {} : { source }),
2621
2736
  ...(sourceWidth === undefined || sourceHeight === undefined ? {} : { sourceWidth, sourceHeight }),
2622
2737
  ...(code === undefined ? {} : { code }),
@@ -2742,6 +2857,8 @@ function sanitizeLiveVideoSelection(value) {
2742
2857
  const mtu = boundedInteger(value.mtu, MAX_LIVE_VIDEO_MTU, MIN_LIVE_VIDEO_MTU);
2743
2858
  const maxBitRate = boundedInteger(value.maxBitRate, MAX_LIVE_VIDEO_BIT_RATE, 0);
2744
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);
2745
2862
  if (!operation ||
2746
2863
  !profile ||
2747
2864
  !codecLevel ||
@@ -2765,6 +2882,8 @@ function sanitizeLiveVideoSelection(value) {
2765
2882
  mtu,
2766
2883
  ...(maxBitRate === undefined ? {} : { maxBitRate }),
2767
2884
  addressVersion,
2885
+ ...(camera === undefined ? {} : { camera }),
2886
+ ...(variant === undefined ? {} : { variant }),
2768
2887
  ...(alias === undefined ? {} : { accessory: alias }),
2769
2888
  };
2770
2889
  }