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

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 (51) 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 +582 -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 +36 -13
  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 +95 -44
  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 +6 -2
  25. package/dist/homekit/reconciler.js.map +1 -1
  26. package/dist/media/live-stream.js +98 -54
  27. package/dist/media/live-stream.js.map +1 -1
  28. package/dist/media/recording.js +7 -5
  29. package/dist/media/recording.js.map +1 -1
  30. package/dist/media/snapshot.js +4 -4
  31. package/dist/media/station-live-sessions.js +47 -43
  32. package/dist/media/station-live-sessions.js.map +1 -1
  33. package/dist/platform.js +25 -5
  34. package/dist/platform.js.map +1 -1
  35. package/dist/runtime/channel.js +6 -2
  36. package/dist/runtime/channel.js.map +1 -1
  37. package/dist/runtime/owner.js +3 -0
  38. package/dist/runtime/owner.js.map +1 -1
  39. package/dist/ui/dashboard.js +32 -14
  40. package/dist/ui/dashboard.js.map +1 -1
  41. package/dist/ui/server.js +27 -4
  42. package/dist/ui/server.js.map +1 -1
  43. package/homebridge-ui/public/app.css +353 -88
  44. package/homebridge-ui/public/i18n/en.json +108 -78
  45. package/homebridge-ui/public/i18n/fr.json +108 -78
  46. package/homebridge-ui/public/index.html +95 -106
  47. package/homebridge-ui/public/js/app.js +328 -345
  48. package/homebridge-ui/public/js/dashboard.js +139 -10
  49. package/homebridge-ui/public/js/profile-wizard.js +17 -54
  50. package/i18n/runtime/en.json +5 -3
  51. package/package.json +6 -6
@@ -33,6 +33,19 @@ export function unconfirmedWriteCondition(property) {
33
33
  ? undefined
34
34
  : { code: 'camera-control-operation-failed', ...named, active: true, reason: 'not-confirmed' };
35
35
  }
36
+ /** Why a live session gave its source back: a stop from outside it, or the session ending itself. */
37
+ const LIVE_SESSION_RELEASES = ['requested', 'failed'];
38
+ /** The claims that contend for a station's one live channel, and the decisions taken over it. */
39
+ const STATION_CLAIMS = ['live', 'recording', 'snapshot'];
40
+ const STATION_CLAIM_ACTIONS = ['held', 'yielded', 'refused', 'released'];
41
+ /**
42
+ * The widest camera index a station may name as the one it is serving.
43
+ *
44
+ * A base fans a bounded number of cameras over one session, and an index beyond that is a value this plugin
45
+ * did not expect rather than a channel. The index carries no identity: which camera holds it is stated by the
46
+ * records about that camera.
47
+ */
48
+ const MAX_STATION_CHANNEL = 63;
36
49
  /** The bounded reasons a stream request is refused before it reaches the source. */
37
50
  const REFUSAL_REASONS = new Set(['disabled', 'at-capacity', 'cancelled', 'prepare-failed']);
38
51
  /**
@@ -47,6 +60,27 @@ const HOMEKIT_LIVE_REQUEST_EVENTS = new Set([
47
60
  'live-session-released',
48
61
  'live-session-streaming',
49
62
  ]);
63
+ /**
64
+ * The error identities an SDK record may name, which is all a record carries of one.
65
+ *
66
+ * A message is never among them, so an identity absent here is recorded as `Error` and reads as any other
67
+ * failure.
68
+ */
69
+ const SDK_ERROR_TYPES = [
70
+ 'DeviceChannelUnresolvedError',
71
+ 'Error',
72
+ 'RangeError',
73
+ 'SessionExpiredError',
74
+ 'StationKeyUnavailableError',
75
+ 'StationUnreachableError',
76
+ 'TypeError',
77
+ ];
78
+ /**
79
+ * The retained fields that stand for a device rather than describing one, which is the privacy class a manifest
80
+ * declares for them. Each holds a per-case alias: stable within one archive, and resolvable to a device only by
81
+ * the owner whose console paired it with a name.
82
+ */
83
+ const PSEUDONYMOUS_FIELDS = new Set(['accessory', 'accessoryAliases']);
50
84
  const MAX_SDK_DETAILS = 16;
51
85
  const MAX_LOG_RECORD_BYTES = 64 * 1024;
52
86
  const MAX_CURRENT_LOG_BYTES = 50 * 1024 * 1024;
@@ -71,7 +105,12 @@ const DIAGNOSTICS_DIRECTORY = 'diagnostics';
71
105
  const GUIDED_SESSION_FILE = 'session.json';
72
106
  const REPRODUCTION_MARKERS_FILE = 'reproduction-markers.jsonl';
73
107
  const UI_EVENTS_FILE = 'ui-events.jsonl';
74
- const FFMPEG_ENVIRONMENT_FILE = 'ffmpeg.json';
108
+ const HOST_ENVIRONMENT_FILE = 'host.json';
109
+ const FLEET_FILE = 'fleet.json';
110
+ /** The longest device model or adapter key retained, which bounds values this plugin does not choose alone. */
111
+ const MAX_DEVICE_MODEL_LENGTH = 64;
112
+ /** How many HomeKit services one fleet entry names, which is above what any single device is represented by. */
113
+ const MAX_FLEET_SERVICES = 12;
75
114
  /** How much of an FFmpeg path or version banner is kept, which is more than either needs. */
76
115
  const MAX_FFMPEG_IDENTITY_LENGTH = 256;
77
116
  const DEBUG_AUTHORIZATION_MS = 72 * 60 * 60 * 1_000;
@@ -153,6 +192,15 @@ const SDK_EVENT_KEYS = new Set([
153
192
  'transport-error',
154
193
  ]);
155
194
  const EN_MESSAGES = JSON.parse(readFileSync(new URL('../i18n/runtime/en.json', import.meta.url), 'utf8'));
195
+ /**
196
+ * The reporter's answer as a support archive carries it: every device, or how many they named.
197
+ *
198
+ * The serials themselves stay on the host. They identify one household's hardware, and no evidence class in an
199
+ * archive carries a device identity.
200
+ */
201
+ function countedAffectedDevices(affected) {
202
+ return affected === 'all' ? 'all' : new Set(affected).size;
203
+ }
156
204
  /** The bounded UI events a support session may record, and the only vocabulary the sink accepts. */
157
205
  const DIAGNOSTICS_UI_EVENTS = [
158
206
  'background-started',
@@ -183,9 +231,58 @@ const SUPPORT_ARCHIVE_EXCLUDED_CLASSES = [
183
231
  'tokens-cookies-and-authorization',
184
232
  'session-and-push-stores',
185
233
  'private-and-symmetric-keys',
186
- 'unconstrained-sdk-objects',
234
+ 'unconstrained-internal-objects',
187
235
  'camera-images-talkback-and-raw-media',
188
236
  ];
237
+ /**
238
+ * The bug form's `area` option each diagnostics profile belongs to.
239
+ *
240
+ * A dropdown prefill is honoured only on an exact match with a declared option, so these strings are the
241
+ * form's own. Two profiles share `HomeKit Integration`, which is one area to triage.
242
+ */
243
+ const BUG_REPORT_AREAS = {
244
+ 'startup-authentication': 'Login / Authentication',
245
+ 'device-representation': 'HomeKit Integration',
246
+ 'control-state': 'Device controls',
247
+ 'live-media': 'Livestream / Streaming',
248
+ 'hksv-recording': 'Recording',
249
+ 'dashboard-ui': 'Plugin UI / Dashboard',
250
+ other: 'Other',
251
+ };
252
+ /** Where a report goes, and the form whose field ids the query string addresses. */
253
+ const BUG_REPORT_FORM = 'https://github.com/homebridge-plugins/homebridge-eufy/issues/new';
254
+ /**
255
+ * A prepared bug report for a finished support session.
256
+ *
257
+ * Addresses the committed issue form by filename, and fills the field ids that form declares. A prefill is
258
+ * honoured only against a named template, and only the operational class travels in a query string.
259
+ *
260
+ * Every field this process can answer is answered, because a blank one is charged to the reporter: the form's
261
+ * own triage asks for what the environment block leaves empty, so a line left for the reporter to complete
262
+ * turns a report made the intended way into an incomplete one. The host version is the host's own public
263
+ * version and empty where no run has recorded it yet. The support case id is not among them — it sits above
264
+ * the operational class, and the archive's own filename carries it to the report that attaches one.
265
+ *
266
+ * The archive field is left unaddressed, because a query string cannot carry a file. Any text placed there
267
+ * satisfies the field's own `required` validation, so a prefill would answer it on the reporter's behalf and
268
+ * the form would accept a report with no archive on it.
269
+ */
270
+ function bugReportUrl(session, missingEvidence, host) {
271
+ const url = new URL(BUG_REPORT_FORM);
272
+ url.searchParams.set('template', 'bug_report.yml');
273
+ url.searchParams.set('title', `[Diagnostics] ${BUG_REPORT_AREAS[session.profile]}: `);
274
+ url.searchParams.set('environment', [
275
+ `- **Plugin Version**: ${PLUGIN_VERSION}`,
276
+ `- **eufy SDK**: ${SDK_VERSION}`,
277
+ `- **Node.js Version**: ${process.version}`,
278
+ `- **Homebridge Version**: ${host?.homebridge ?? ''}`,
279
+ `- **OS**: ${process.platform} ${process.arch}`,
280
+ `- **Diagnostics profile**: ${session.profile}`,
281
+ `- **Missing evidence**: ${missingEvidence.length ? missingEvidence.join(', ') : 'none'}`,
282
+ ].join('\n'));
283
+ url.searchParams.set('area', BUG_REPORT_AREAS[session.profile]);
284
+ return url.toString();
285
+ }
189
286
  const DIAGNOSTICS_PROFILES = {
190
287
  'startup-authentication': ['plugin-log', 'sdk-log'],
191
288
  'device-representation': ['plugin-log', 'sdk-log', 'homekit-log'],
@@ -193,7 +290,7 @@ const DIAGNOSTICS_PROFILES = {
193
290
  'live-media': ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
194
291
  'hksv-recording': ['plugin-log', 'sdk-log', 'ffmpeg-log'],
195
292
  'dashboard-ui': ['plugin-log', 'ui-log'],
196
- other: ['plugin-log', 'sdk-log', 'homekit-log'],
293
+ other: ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
197
294
  };
198
295
  /** Narrows external profile input against the diagnostics-owned profile registry. */
199
296
  export function isDiagnosticsProfile(value) {
@@ -243,6 +340,22 @@ function activeDiagnosticsSession(storageRoot, now) {
243
340
  const session = readDiagnosticsSession(storageRoot);
244
341
  return session && Date.parse(session.expiresAt) > now ? session : undefined;
245
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
+ }
246
359
  /**
247
360
  * Collapses one supplied string to bounded printable text, or nothing where none of it survives.
248
361
  *
@@ -259,54 +372,135 @@ function boundedText(value, maximumLength) {
259
372
  .trim();
260
373
  return printable === '' ? undefined : printable;
261
374
  }
262
- function ffmpegEnvironmentPath(storageRoot) {
263
- return join(storageRoot, DIAGNOSTICS_DIRECTORY, FFMPEG_ENVIRONMENT_FILE);
375
+ function fleetPath(storageRoot) {
376
+ return join(storageRoot, DIAGNOSTICS_DIRECTORY, FLEET_FILE);
377
+ }
378
+ /**
379
+ * Records what the account's devices are and what each becomes, replacing the record whole.
380
+ *
381
+ * Written by the process that holds both the inventory and the accessory aliases, because an archive is
382
+ * assembled by another one. Rewritten on every complete inventory, so a device withdrawn or newly recognised
383
+ * cannot be reported as still present. A write that fails is dropped: an archive then declares no fleet, which
384
+ * is honest, whereas failing over a diagnostic file would cost the user their cameras.
385
+ */
386
+ export function recordFleet(storageRoot, fleet) {
387
+ try {
388
+ const path = fleetPath(storageRoot);
389
+ mkdirSync(dirname(path), { mode: 0o700, recursive: true });
390
+ writeFileSync(path, `${JSON.stringify({ version: 1, devices: fleet })}\n`, { mode: 0o600 });
391
+ }
392
+ catch { }
393
+ }
394
+ /** Reads the recorded fleet, keeping each entry whose own fields narrow and dropping each that does not. */
395
+ function readFleet(storageRoot) {
396
+ try {
397
+ const candidate = JSON.parse(readFileSync(fleetPath(storageRoot), 'utf8'));
398
+ if (candidate.version !== 1 || !Array.isArray(candidate.devices)) {
399
+ return undefined;
400
+ }
401
+ const devices = candidate.devices.flatMap((entry) => {
402
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
403
+ return [];
404
+ const row = entry;
405
+ const accessory = opaqueAccessoryAlias(row.accessory);
406
+ if (accessory === undefined || typeof row.represented !== 'boolean' || typeof row.attached !== 'boolean') {
407
+ return [];
408
+ }
409
+ const model = typeof row.model === 'string' ? boundedText(row.model, MAX_DEVICE_MODEL_LENGTH) : undefined;
410
+ const services = (Array.isArray(row.services) ? row.services : [])
411
+ .filter((service) => typeof service === 'string')
412
+ .slice(0, MAX_FLEET_SERVICES)
413
+ .flatMap((service) => {
414
+ const bounded = boundedText(service, MAX_DEVICE_MODEL_LENGTH);
415
+ return bounded === undefined ? [] : [bounded];
416
+ });
417
+ return [
418
+ {
419
+ accessory,
420
+ ...(model === undefined ? {} : { model }),
421
+ represented: row.represented,
422
+ attached: row.attached,
423
+ services,
424
+ },
425
+ ];
426
+ });
427
+ return devices.length === 0 ? undefined : devices.slice(0, MAX_ACCESSORY_ALIASES);
428
+ }
429
+ catch {
430
+ return undefined;
431
+ }
432
+ }
433
+ function hostEnvironmentPath(storageRoot) {
434
+ return join(storageRoot, DIAGNOSTICS_DIRECTORY, HOST_ENVIRONMENT_FILE);
264
435
  }
436
+ /** The longest host version string retained, which bounds a value this plugin does not choose. */
437
+ const MAX_HOST_VERSION_LENGTH = 64;
265
438
  /**
266
- * Records which FFmpeg this run resolved, so a support archive states the build a failure came from.
439
+ * Records what this run is hosted by, so a support archive states the host a failure came from.
440
+ *
441
+ * It is persisted rather than held in memory because the process that knows these facts is not the one that
442
+ * assembles an archive, and it is rewritten on every start so a host or binary that changed between runs
443
+ * cannot be reported as the one in use. Each call replaces the record whole, so a caller states everything it
444
+ * knows rather than accumulating into a file two writers would race for.
267
445
  *
268
- * It is persisted rather than held in memory because the process that resolves it is not the one that
269
- * assembles an archive, and it is rewritten on every start so a path or binary that changed between runs
270
- * cannot be reported as the one in use. A write that fails is dropped: the archive then declares the
271
- * environment without an FFmpeg identity, which is honest, whereas failing startup over a diagnostic file
272
- * would cost the user their cameras.
446
+ * A write that fails is dropped: the archive then declares the environment without these facts, which is
447
+ * honest, whereas failing startup over a diagnostic file would cost the user their cameras.
273
448
  */
274
- export function recordFfmpegEnvironment(storageRoot, ffmpeg) {
449
+ export function recordHostEnvironment(storageRoot, host) {
275
450
  try {
276
- const path = ffmpegEnvironmentPath(storageRoot);
451
+ const path = hostEnvironmentPath(storageRoot);
277
452
  mkdirSync(dirname(path), { mode: 0o700, recursive: true });
278
- writeFileSync(path, `${JSON.stringify({ version: 1, ffmpeg })}\n`, { mode: 0o600 });
453
+ writeFileSync(path, `${JSON.stringify({ version: 2, ...host })}\n`, { mode: 0o600 });
279
454
  }
280
455
  catch { }
281
456
  }
282
457
  /**
283
- * Reads the recorded adaptation binary, refusing a record whose own fields do not narrow.
458
+ * Reads what this run is hosted by, keeping each fact that narrows and dropping each that does not.
284
459
  *
285
- * The path and the banner are kept as written rather than pattern-replaced, because naming the binary is the
286
- * entire purpose of the record and a redacted one answers nothing. Neither is device or account material: the
287
- * path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
460
+ * The facts are independent, so a malformed binary identity does not withhold a host version, and a record
461
+ * stating neither is no record at all.
288
462
  */
289
- function readFfmpegEnvironment(storageRoot) {
463
+ function readHostEnvironment(storageRoot) {
290
464
  try {
291
- const candidate = JSON.parse(readFileSync(ffmpegEnvironmentPath(storageRoot), 'utf8'));
292
- const ffmpeg = candidate.ffmpeg;
293
- if (candidate.version !== 1 || !ffmpeg || typeof ffmpeg !== 'object' || Array.isArray(ffmpeg)) {
465
+ const candidate = JSON.parse(readFileSync(hostEnvironmentPath(storageRoot), 'utf8'));
466
+ if (candidate.version !== 2) {
294
467
  return undefined;
295
468
  }
296
- const recorded = ffmpeg;
297
- if (typeof recorded.path !== 'string' || (recorded.source !== 'bundled' && recorded.source !== 'configured')) {
469
+ const homebridge = typeof candidate.homebridge === 'string' ? boundedText(candidate.homebridge, MAX_HOST_VERSION_LENGTH) : undefined;
470
+ const ffmpeg = readFfmpegIdentity(candidate.ffmpeg);
471
+ if (homebridge === undefined && ffmpeg === undefined) {
298
472
  return undefined;
299
473
  }
300
- const path = boundedText(recorded.path, MAX_FFMPEG_IDENTITY_LENGTH);
301
- const version = typeof recorded.version === 'string' ? boundedText(recorded.version, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
302
- return path === undefined
303
- ? undefined
304
- : { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
474
+ return {
475
+ ...(homebridge === undefined ? {} : { homebridge }),
476
+ ...(ffmpeg === undefined ? {} : { ffmpeg }),
477
+ };
305
478
  }
306
479
  catch {
307
480
  return undefined;
308
481
  }
309
482
  }
483
+ /**
484
+ * The recorded adaptation binary, or nothing where the record's own fields do not narrow.
485
+ *
486
+ * The path and the banner are kept as written rather than pattern-replaced, because naming the binary is the
487
+ * entire purpose of the record and a redacted one answers nothing. Neither is device or account material: the
488
+ * path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
489
+ */
490
+ function readFfmpegIdentity(value) {
491
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
492
+ return undefined;
493
+ }
494
+ const recorded = value;
495
+ if (recorded.source !== 'bundled' && recorded.source !== 'configured') {
496
+ return undefined;
497
+ }
498
+ const path = typeof recorded.path === 'string' ? boundedText(recorded.path, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
499
+ const version = typeof recorded.version === 'string' ? boundedText(recorded.version, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
500
+ return path === undefined
501
+ ? undefined
502
+ : { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
503
+ }
310
504
  /**
311
505
  * The one log evidence class a retained record belongs to.
312
506
  *
@@ -348,13 +542,16 @@ export class GuidedDiagnostics {
348
542
  this.now = now;
349
543
  this.supportArchiveKey = supportArchiveKey;
350
544
  }
351
- async authorize(profile, reproductionMode) {
545
+ async authorize(profile, reproductionMode, affectedDevices) {
352
546
  if (!isDiagnosticsProfile(profile)) {
353
547
  throw new Error('Unknown diagnostics profile');
354
548
  }
355
549
  if (!isDiagnosticsReproductionMode(reproductionMode)) {
356
550
  throw new Error('Unknown diagnostics reproduction mode');
357
551
  }
552
+ if (affectedDevices !== undefined && affectedDevices !== 'all' && !Array.isArray(affectedDevices)) {
553
+ throw new Error('Affected devices must be "all" or a list of serials');
554
+ }
358
555
  this.uiEventsClosing = true;
359
556
  try {
360
557
  await this.uiEventWrites;
@@ -366,6 +563,7 @@ export class GuidedDiagnostics {
366
563
  reproductionMode,
367
564
  authorizedAt: new Date(authorizedAt).toISOString(),
368
565
  expiresAt: new Date(authorizedAt + DEBUG_AUTHORIZATION_MS).toISOString(),
566
+ ...(affectedDevices === undefined ? {} : { affectedDevices }),
369
567
  };
370
568
  await this.writeSession(session);
371
569
  this.pendingSupportArchive = undefined;
@@ -396,6 +594,22 @@ export class GuidedDiagnostics {
396
594
  await this.ensureMarker(session, 'reproduction-started', session.reproductionStartedAt);
397
595
  return this.project(session);
398
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
+ }
399
613
  async endReproduction() {
400
614
  let session = this.requireAuthorized();
401
615
  if (!session.reproductionStartedAt) {
@@ -476,10 +690,16 @@ export class GuidedDiagnostics {
476
690
  /** Prepares the exact manifest and evidence snapshot that one subsequent export may encrypt. */
477
691
  async reviewSupportArchive() {
478
692
  await this.uiEventWrites;
479
- 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 };
480
697
  if (!session?.reproductionStartedAt || !session.reproductionEndedAt) {
481
698
  throw new Error('A completed reproduction is required before archive review');
482
699
  }
700
+ if (!exportable) {
701
+ throw new Error('Diagnostics authorization is inactive or expired');
702
+ }
483
703
  const collected = await this.collectSupportEvidence(session);
484
704
  const reproductionStartedAt = Date.parse(session.reproductionStartedAt);
485
705
  const selected = DIAGNOSTICS_PROFILES[session.profile];
@@ -522,7 +742,13 @@ export class GuidedDiagnostics {
522
742
  this.pendingSupportArchive = { reviewId, manifest, evidence: collected };
523
743
  return { reviewId, manifest };
524
744
  }
525
- /** 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
+ */
526
752
  async exportSupportArchive(reviewId) {
527
753
  const pending = this.pendingSupportArchive;
528
754
  if (!pending || pending.reviewId !== reviewId) {
@@ -568,35 +794,79 @@ export class GuidedDiagnostics {
568
794
  authTag: cipher.getAuthTag().toString('base64'),
569
795
  ciphertext: ciphertext.toString('base64'),
570
796
  };
571
- return {
797
+ const exported = {
572
798
  filename: `homebridge-eufy-${pending.manifest.supportCaseId}.eufysupport.gz`,
573
799
  mediaType: 'application/gzip',
574
800
  archive: await gzip(Buffer.from(`${JSON.stringify(envelope)}\n`, 'utf8')),
575
801
  };
802
+ return exported;
576
803
  }
577
804
  finally {
578
805
  contentKey.fill(0);
579
806
  }
580
807
  }
581
808
  async collectSupportEvidence(session) {
582
- const ffmpeg = readFfmpegEnvironment(this.storageRoot);
809
+ const host = readHostEnvironment(this.storageRoot);
810
+ const fleet = readFleet(this.storageRoot);
583
811
  const evidence = [
812
+ ...(session.affectedDevices === undefined
813
+ ? []
814
+ : [
815
+ {
816
+ evidence: 'reporter-statement',
817
+ privacyClass: 'diagnostic',
818
+ contentType: 'application/json',
819
+ content: `${JSON.stringify({ version: 2, affectedDevices: countedAffectedDevices(session.affectedDevices) })}\n`,
820
+ /**
821
+ * What the reporter answered, not what the plugin observed. A serial is a device identity and no
822
+ * evidence class carries one, so the answer travels as its scope: every device, or how many were
823
+ * named. Which ones is the reporter's to say in their own words.
824
+ */
825
+ fields: [
826
+ { field: 'version', privacyClass: 'operational' },
827
+ { field: 'affectedDevices', privacyClass: 'operational' },
828
+ ],
829
+ },
830
+ ]),
831
+ ...(fleet === undefined
832
+ ? []
833
+ : [
834
+ {
835
+ evidence: 'fleet',
836
+ privacyClass: 'operational',
837
+ contentType: 'application/json',
838
+ content: `${JSON.stringify({ version: 1, devices: fleet })}\n`,
839
+ /**
840
+ * What each device is and becomes, against the alias every other record about it carries. A model
841
+ * and an admission outcome are product facts rather than device identity; the alias is what makes
842
+ * an entry resolvable to a device, and only by the owner whose console paired it with a name.
843
+ */
844
+ fields: [
845
+ { field: 'version', privacyClass: 'operational' },
846
+ { field: 'devices', privacyClass: 'pseudonymous' },
847
+ ],
848
+ },
849
+ ]),
584
850
  {
585
851
  evidence: 'environment',
586
852
  privacyClass: 'operational',
587
853
  contentType: 'application/json',
588
- content: `${JSON.stringify({ version: 2, plugin: PLUGIN_VERSION, sdk: SDK_VERSION, node: process.version, platform: process.platform, arch: process.arch, ...(ffmpeg ? { ffmpeg } : {}) })}\n`,
854
+ content: `${JSON.stringify({ version: 3, plugin: PLUGIN_VERSION, sdk: SDK_VERSION, node: process.version, ...(host?.homebridge ? { homebridge: host.homebridge } : {}), platform: process.platform, arch: process.arch, ...(host?.ffmpeg ? { ffmpeg: host.ffmpeg } : {}) })}\n`,
589
855
  /**
590
856
  * The record is operational and one field is classified above it: a resolved FFmpeg path is an
591
857
  * environment fact, but it can carry the home directory of the account Homebridge runs as, so it is
592
858
  * declared as diagnostic rather than presented alongside the host's own architecture.
859
+ *
860
+ * The Homebridge version is operational and load-bearing: this plugin requires Homebridge 2, and a
861
+ * host that predates it loads the plugin and then fails in ways a media fault cannot be told from.
593
862
  */
594
863
  fields: [
595
864
  ...['version', 'plugin', 'sdk', 'node', 'platform', 'arch'].map((field) => ({
596
865
  field,
597
866
  privacyClass: 'operational',
598
867
  })),
599
- ...(ffmpeg ? [{ field: 'ffmpeg', privacyClass: 'diagnostic' }] : []),
868
+ ...(host?.homebridge ? [{ field: 'homebridge', privacyClass: 'operational' }] : []),
869
+ ...(host?.ffmpeg ? [{ field: 'ffmpeg', privacyClass: 'diagnostic' }] : []),
600
870
  ],
601
871
  },
602
872
  ];
@@ -647,7 +917,11 @@ export class GuidedDiagnostics {
647
917
  ...(carried.retainedFrom === undefined ? {} : { retainedFrom: carried.retainedFrom }),
648
918
  fields: [...new Set(carried.records.flatMap((record) => Object.keys(record)))].sort().map((field) => ({
649
919
  field,
650
- privacyClass: field === 'accessoryAliases' ? 'pseudonymous' : field === 'timestamp' ? 'operational' : 'diagnostic',
920
+ privacyClass: PSEUDONYMOUS_FIELDS.has(field)
921
+ ? 'pseudonymous'
922
+ : field === 'timestamp'
923
+ ? 'operational'
924
+ : 'diagnostic',
651
925
  })),
652
926
  });
653
927
  }
@@ -810,16 +1084,17 @@ export class GuidedDiagnostics {
810
1084
  throw new Error('Diagnostics authorization is inactive or expired');
811
1085
  return session;
812
1086
  }
813
- async project(session) {
814
- if (!session) {
1087
+ async project(persisted) {
1088
+ if (!persisted) {
815
1089
  return { status: 'inactive', selectedEvidence: [], missingEvidence: [], partialExportAvailable: false };
816
1090
  }
1091
+ const { session, exportable } = settledDiagnosticsSession(persisted, this.now());
817
1092
  const selectedEvidence = DIAGNOSTICS_PROFILES[session.profile];
818
1093
  const collected = session.reproductionStartedAt && session.reproductionEndedAt
819
1094
  ? await this.collectSupportEvidence(session)
820
1095
  : undefined;
821
1096
  const missingEvidence = collected ? missingEvidenceClasses(selectedEvidence, collected) : [];
822
- const expired = Date.parse(session.expiresAt) <= this.now();
1097
+ const expired = Date.parse(session.expiresAt) <= this.now() && !exportable;
823
1098
  const status = expired
824
1099
  ? 'expired'
825
1100
  : session.reproductionEndedAt
@@ -827,12 +1102,6 @@ export class GuidedDiagnostics {
827
1102
  : session.reproductionStartedAt
828
1103
  ? 'reproducing'
829
1104
  : 'authorized';
830
- const issueBody = [
831
- `Support case: ${session.supportCaseId}`,
832
- `Profile: ${session.profile}`,
833
- `Reproduction mode: ${session.reproductionMode}`,
834
- `Missing evidence: ${missingEvidence.length ? missingEvidence.join(', ') : 'none'}`,
835
- ].join('\n');
836
1105
  return {
837
1106
  status,
838
1107
  supportCaseId: session.supportCaseId,
@@ -844,8 +1113,9 @@ export class GuidedDiagnostics {
844
1113
  expiresAt: session.expiresAt,
845
1114
  ...(session.reproductionStartedAt ? { reproductionStartedAt: session.reproductionStartedAt } : {}),
846
1115
  ...(session.reproductionEndedAt ? { reproductionEndedAt: session.reproductionEndedAt } : {}),
847
- partialExportAvailable: Boolean(session.reproductionEndedAt),
848
- issueUrl: `https://github.com/homebridge-plugins/homebridge-eufy/issues/new?body=${encodeURIComponent(issueBody)}`,
1116
+ partialExportAvailable: exportable,
1117
+ ...(session.affectedDevices === undefined ? {} : { affectedDevices: session.affectedDevices }),
1118
+ issueUrl: bugReportUrl(session, missingEvidence, readHostEnvironment(this.storageRoot)),
849
1119
  };
850
1120
  }
851
1121
  /**
@@ -1133,6 +1403,14 @@ function allowlistedLabel(value, allowed) {
1133
1403
  * like any other field that fails its shape.
1134
1404
  */
1135
1405
  const MAX_STARTUP_WINDOW_MS = 60_000;
1406
+ /** Why a level-2 wait ended without a key, which is the whole of what such an ending states. */
1407
+ const LEVEL2_UNAVAILABLE_REASONS = [
1408
+ 'no-cipher-key',
1409
+ 'derivation-failed',
1410
+ 'not-negotiating',
1411
+ 'session-closed',
1412
+ 'grace-elapsed',
1413
+ ];
1136
1414
  /** The three phases whose only field is the action they report, which the union fixes at `start`. */
1137
1415
  const retainedStart = (candidate) => candidate.action === 'start' ? { action: 'start' } : undefined;
1138
1416
  /** A phase whose only field is one bounded integer, retained under the name the SDK declares for it. */
@@ -1178,7 +1456,67 @@ const LIVE_TRACE_PHASES = {
1178
1456
  'sequence-restart': retainedInteger('dataType', 3),
1179
1457
  'level2-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
1180
1458
  'level2-ready': retainedInteger('cipherId', 65535),
1181
- 'level2-absent': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
1459
+ 'session-connect-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
1460
+ 'session-connected': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
1461
+ 'session-unreachable': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
1462
+ /**
1463
+ * Why a level-2 wait ended without a key, and what it had to name.
1464
+ *
1465
+ * The reason is the record: a wait that ran out states how long it waited, and one answered without waiting
1466
+ * states nothing more, because the negotiation is one-shot per connection. Only a reason reached under a
1467
+ * negotiation has a cipher to name, so both durations are optional and each is retained where it is stated.
1468
+ */
1469
+ 'level2-unavailable': (c) => {
1470
+ const reason = allowlistedLabel(c.reason, LEVEL2_UNAVAILABLE_REASONS);
1471
+ if (!reason) {
1472
+ return undefined;
1473
+ }
1474
+ const cipherId = boundedInteger(c.cipherId, 65535);
1475
+ const waitedMs = boundedInteger(c.waitedMs, MAX_STARTUP_WINDOW_MS);
1476
+ return {
1477
+ reason,
1478
+ ...(cipherId === undefined ? {} : { cipherId }),
1479
+ ...(waitedMs === undefined ? {} : { waitedMs }),
1480
+ };
1481
+ },
1482
+ /** The cipher a station asked for, beside the one whose material answered it. */
1483
+ 'cipher-fallback': (c) => {
1484
+ const cipherId = boundedInteger(c.cipherId, 65535);
1485
+ const answeredCipherId = boundedInteger(c.answeredCipherId, 65535);
1486
+ return cipherId === undefined || answeredCipherId === undefined ? undefined : { cipherId, answeredCipherId };
1487
+ },
1488
+ 'level2-negotiating': retainedInteger('cipherId', 65535),
1489
+ /**
1490
+ * What a call resolved a station as, which decides what its failure means.
1491
+ *
1492
+ * An attached camera's media start has no unencrypted form, so the topology is what separates a key that
1493
+ * never arrived from a start that had no form to be sent in. The channel is an index on a base and the
1494
+ * administrator is a relation to the signed-in account: neither is an identity of its own.
1495
+ */
1496
+ /**
1497
+ * Which lookup channels a connection could ask for a station on, before it asked.
1498
+ *
1499
+ * A connect that had one channel failed for that channel's reason alone, and a connect that had neither
1500
+ * could not have succeeded. Both are otherwise indistinguishable from a station that is switched off.
1501
+ */
1502
+ 'lookup-channels': (c) => typeof c.local === 'boolean' && typeof c.cloud === 'boolean' ? { local: c.local, cloud: c.cloud } : undefined,
1503
+ 'station-resolved': (c) => {
1504
+ const topology = allowlistedLabel(c.topology, ['attached', 'own']);
1505
+ const stationAdmin = allowlistedLabel(c.stationAdmin, ['self', 'other', 'unstated']);
1506
+ const channel = boundedInteger(c.channel, MAX_STATION_CHANNEL);
1507
+ const stationModel = typeof c.stationModel === 'string' ? boundedText(c.stationModel, MAX_DEVICE_MODEL_LENGTH) : undefined;
1508
+ return topology && stationAdmin && channel !== undefined
1509
+ ? { topology, channel, stationAdmin, ...(stationModel === undefined ? {} : { stationModel }) }
1510
+ : undefined;
1511
+ },
1512
+ /**
1513
+ * Why a device could not be addressed on its station: its record states no channel, or another attached
1514
+ * device states the same one. The call was refused before anything was sent.
1515
+ */
1516
+ 'station-channel-unresolved': (c) => {
1517
+ const issue = allowlistedLabel(c.issue, ['missing', 'shared']);
1518
+ return issue ? { issue } : undefined;
1519
+ },
1182
1520
  /**
1183
1521
  * How long a connection's path has answered nothing, which is the station stating the path is gone.
1184
1522
  *
@@ -1186,6 +1524,19 @@ const LIVE_TRACE_PHASES = {
1186
1524
  * silence longer than that belongs to a session nothing is waiting on.
1187
1525
  */
1188
1526
  'path-stale': retainedInteger('silentMs', MAX_STARTUP_WINDOW_MS),
1527
+ /**
1528
+ * How long a camera's own channel delivered nothing, and whether the station was asked to serve it again.
1529
+ *
1530
+ * Both halves are the record: a station re-tasked to a sibling is repaired by the re-assert, and a pull
1531
+ * nothing is attached to declines it so the station is left with the camera someone is watching. A picture
1532
+ * that stopped advancing while no such record exists stopped for a reason the station's attention does not
1533
+ * reach.
1534
+ */
1535
+ 'channel-silent': (c) => {
1536
+ const silentMs = boundedInteger(c.silentMs, MAX_STARTUP_WINDOW_MS);
1537
+ const outcome = allowlistedLabel(c.outcome, ['declined', 'reasserted']);
1538
+ return silentMs !== undefined && outcome ? { silentMs, outcome } : undefined;
1539
+ },
1189
1540
  'media-command-unsent': (c) => {
1190
1541
  const reason = allowlistedLabel(c.reason, ['level2-key', 'address']);
1191
1542
  return reason ? { reason } : undefined;
@@ -1219,6 +1570,18 @@ function sanitizeSdkLiveStartTrace(value) {
1219
1570
  function opaquePullHandle(value) {
1220
1571
  return typeof value === 'string' && /^(?:pull|station)-\d{1,6}(?::\d{1,3})?$/.test(value) ? value : undefined;
1221
1572
  }
1573
+ /**
1574
+ * One support-case accessory alias, or nothing where the value is not one.
1575
+ *
1576
+ * Matched against the shape an alias has rather than against what a serial looks like, on the same rule as
1577
+ * {@link opaquePullHandle}: a record carries an alias or it carries no accessory at all.
1578
+ */
1579
+ function opaqueAccessoryAlias(value) {
1580
+ return typeof value === 'string' &&
1581
+ /^accessory-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)
1582
+ ? value
1583
+ : undefined;
1584
+ }
1222
1585
  /**
1223
1586
  * Adapts SDK protocol detail to bounded debug output without preserving supplied values.
1224
1587
  *
@@ -1260,9 +1623,7 @@ export function createSdkLogger(target) {
1260
1623
  const subsystem = SDK_SUBSYSTEMS.has(requestedSubsystem ?? '') ? requestedSubsystem : (aliasedSubsystem ?? 'sdk');
1261
1624
  const details = args.slice(0, MAX_SDK_DETAILS).map((value) => {
1262
1625
  if (value instanceof Error) {
1263
- const errorType = ['Error', 'RangeError', 'SessionExpiredError', 'TypeError'].includes(value.name)
1264
- ? value.name
1265
- : 'Error';
1626
+ const errorType = SDK_ERROR_TYPES.includes(value.name) ? value.name : 'Error';
1266
1627
  return { errorType };
1267
1628
  }
1268
1629
  if (typeof value === 'string') {
@@ -1386,6 +1747,8 @@ const REASONS = new Set([
1386
1747
  'source-input-unstable',
1387
1748
  'source-stopped',
1388
1749
  'source-unavailable',
1750
+ 'station-key-unavailable',
1751
+ 'station-unreachable',
1389
1752
  'stored-download-failed',
1390
1753
  'stored-failed',
1391
1754
  'stored-invalid-image',
@@ -1404,6 +1767,7 @@ const HOMEKIT_EVENT_ROUTES = {
1404
1767
  'battery.status': new Set(['battery-alert', 'battery-level']),
1405
1768
  'contact.sensor': new Set(['contact-state']),
1406
1769
  'doorbell.press': new Set(['doorbell-press']),
1770
+ 'doorbell.package': new Set(['package-presence']),
1407
1771
  'lock.mechanism': new Set(['lock-state']),
1408
1772
  'motion.sensor': new Set(['motion-detection']),
1409
1773
  'arming.security-system': new Set(['arming-mode-changed', 'security-system-alarm']),
@@ -1466,6 +1830,13 @@ const ADAPTATION_EVENTS = new Set([
1466
1830
  'exited-while-streaming',
1467
1831
  'output',
1468
1832
  ]);
1833
+ /**
1834
+ * Why a process was started, on the record that reports it starting.
1835
+ *
1836
+ * A replacement carries the identity of the stream the previous process was already sending on, so what it
1837
+ * started mid-flight is judged against what that one left. A first adaptation has nothing behind it.
1838
+ */
1839
+ const ADAPTATION_CAUSES = ['first', 'controller-selection', 'source-configuration'];
1469
1840
  /**
1470
1841
  * Which of those events is a failure, and so the ones a record is levelled `warn` for.
1471
1842
  *
@@ -1602,7 +1973,7 @@ const RUNTIME_CONDITIONS = {
1602
1973
  },
1603
1974
  };
1604
1975
  const HOMEKIT_CONDITIONS = {
1605
- 'recognized-device-not-represented': ['log.homekit.recognizedNotRepresented', 'log.action.openDashboard'],
1976
+ 'recognized-device-not-represented': ['log.homekit.recognizedNotRepresented', 'log.action.notSupportedYet'],
1606
1977
  'battery-capability-unavailable': ['log.homekit.batteryCapabilityUnavailable', 'log.action.waitBattery'],
1607
1978
  'invalid-battery-observation': ['log.homekit.invalidBatteryObservation', 'log.action.waitBatteryObservation'],
1608
1979
  'battery-temperature-alert': ['log.homekit.batteryTemperatureAlert', 'log.action.allowBatteryCooling'],
@@ -1615,7 +1986,7 @@ const HOMEKIT_CONDITIONS = {
1615
1986
  'smart-light-operation-failed': ['log.homekit.lightOperationFailed', 'log.action.retryLight'],
1616
1987
  'smart-light-reconciliation-expired': ['log.homekit.lightReconciliationExpired', 'log.action.checkPhysicalLight'],
1617
1988
  'arming-capability-unavailable': ['log.homekit.armingCapabilityUnavailable', 'log.action.waitArming'],
1618
- 'unsupported-arming-mode': ['log.homekit.unsupportedArmingMode', 'log.action.selectSupportedArmingMode'],
1989
+ 'unsupported-arming-mode': ['log.homekit.unsupportedArmingMode', 'log.action.mapArmingMode'],
1619
1990
  'arming-operation-failed': ['log.homekit.armingOperationFailed', 'log.action.retryArming'],
1620
1991
  'arming-reconciliation-expired': ['log.homekit.armingReconciliationExpired', 'log.action.checkPhysicalArmingMode'],
1621
1992
  'lock-capability-unavailable': ['log.homekit.lockCapabilityUnavailable', 'log.action.waitLock'],
@@ -1649,6 +2020,53 @@ const HOMEKIT_CONDITIONS = {
1649
2020
  'camera-talkback-failed': ['log.homekit.cameraTalkbackFailed', 'log.action.retryTalkback'],
1650
2021
  'camera-talkback-capability-unavailable': ['log.homekit.cameraTalkbackUnavailable', 'log.action.waitTalkback'],
1651
2022
  };
2023
+ /**
2024
+ * The action a HomeKit condition names for a reason whose remedy is not the condition's own, keyed `code:reason`.
2025
+ *
2026
+ * A camera adapter is missing only where no FFmpeg path resolved, and an adaptation fails to spawn only where the
2027
+ * path cannot be run, so both are fixed in the plugin's settings rather than waited out.
2028
+ */
2029
+ const HOMEKIT_REASON_ACTIONS = {
2030
+ 'camera-streaming-capability-unavailable:adapter-missing': 'log.action.setFfmpegPath',
2031
+ 'camera-recording-unavailable:adapter-missing': 'log.action.setFfmpegPath',
2032
+ 'camera-live-session-failed:adaptation-spawn-failed': 'log.action.checkFfmpegPath',
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
+ }
2061
+ /** HomeKit conditions that state a gap nothing in the user's setup closes, so an active one is not a warning. */
2062
+ const INFORMATIONAL_HOMEKIT_CONDITIONS = new Set(['recognized-device-not-represented']);
2063
+ /** The level and action one HomeKit condition is written with, decided once for the console and the record. */
2064
+ function homeKitConditionPresentation(code, active, reason) {
2065
+ return {
2066
+ level: active && !INFORMATIONAL_HOMEKIT_CONDITIONS.has(code) ? 'warn' : 'info',
2067
+ actionKey: HOMEKIT_REASON_ACTIONS[`${code}:${reason}`] ?? HOMEKIT_CONDITIONS[code][1],
2068
+ };
2069
+ }
1652
2070
  function sanitizeStructuredEvent(message) {
1653
2071
  let value;
1654
2072
  try {
@@ -1681,12 +2099,7 @@ function sanitizeStructuredEvent(message) {
1681
2099
  if (!detail || typeof detail !== 'object' || Array.isArray(detail))
1682
2100
  continue;
1683
2101
  const candidate = detail;
1684
- const errorType = allowlistedLabel(candidate.errorType, [
1685
- 'Error',
1686
- 'RangeError',
1687
- 'SessionExpiredError',
1688
- 'TypeError',
1689
- ]);
2102
+ const errorType = allowlistedLabel(candidate.errorType, SDK_ERROR_TYPES);
1690
2103
  if (errorType) {
1691
2104
  details.push({ errorType });
1692
2105
  continue;
@@ -1737,6 +2150,10 @@ function sanitizeStructuredEvent(message) {
1737
2150
  return { scope: 'media-notice', level: notice.level, code: value.code, messageKey: notice.messageKey };
1738
2151
  }
1739
2152
  if (value.scope === 'homekit') {
2153
+ if (value.event === 'station-claim') {
2154
+ const claim = sanitizeStationClaim(value);
2155
+ return claim ? { scope: 'homekit', level: 'debug', ...claim } : undefined;
2156
+ }
1740
2157
  if (value.adapter === 'camera.streaming' && value.event === 'live-video-selected') {
1741
2158
  const selection = sanitizeLiveVideoSelection(value);
1742
2159
  return selection ? { scope: 'homekit', level: 'debug', ...selection } : undefined;
@@ -1752,6 +2169,7 @@ function sanitizeStructuredEvent(message) {
1752
2169
  !HOMEKIT_OBSERVATIONS.has(value.observation)) {
1753
2170
  return undefined;
1754
2171
  }
2172
+ const alias = opaqueAccessoryAlias(value.accessory);
1755
2173
  return {
1756
2174
  scope: 'homekit',
1757
2175
  level: 'debug',
@@ -1761,6 +2179,7 @@ function sanitizeStructuredEvent(message) {
1761
2179
  ...(typeof value.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(value.announcedBy)
1762
2180
  ? { announcedBy: value.announcedBy }
1763
2181
  : {}),
2182
+ ...(alias === undefined ? {} : { accessory: alias }),
1764
2183
  };
1765
2184
  }
1766
2185
  if (value.scope === 'runtime') {
@@ -1809,14 +2228,15 @@ function sanitizeStructuredEvent(message) {
1809
2228
  .filter((alias) => typeof alias === 'string' && /^accessory-[0-9a-f-]{36}$/.test(alias))
1810
2229
  .slice(0, MAX_ACCESSORY_ALIASES)
1811
2230
  : [];
2231
+ const { level: conditionLevel, actionKey } = homeKitConditionPresentation(value.code, value.active, value.reason);
1812
2232
  return {
1813
2233
  scope: 'diagnostic-condition',
1814
- level: value.active ? 'warn' : 'info',
2234
+ level: conditionLevel,
1815
2235
  code: value.code,
1816
2236
  active: value.active,
1817
2237
  reason: value.reason,
1818
2238
  summaryKey: homeKitDefinition[0],
1819
- actionKey: homeKitDefinition[1],
2239
+ actionKey,
1820
2240
  ...(typeof value.capability === 'string' && CAPABILITIES.has(value.capability)
1821
2241
  ? { capability: value.capability }
1822
2242
  : {}),
@@ -1833,7 +2253,7 @@ export function reportRuntimeNotice(target, code, fields = {}) {
1833
2253
  const notice = RUNTIME_NOTICES[code];
1834
2254
  const durationMs = fields.durationMs === undefined ? undefined : Math.max(0, Math.trunc(fields.durationMs));
1835
2255
  const messageKey = durationMs !== undefined && 'durationMessageKey' in notice ? notice.durationMessageKey : notice.messageKey;
1836
- target[notice.level]?.(`[${code}] ${localize(target, messageKey, { durationMs: durationMs ?? 0 })}`);
2256
+ target[notice.level]?.(`[${code}] ${localize(target, messageKey, { durationMs: durationMs ?? 0, finishBy: fields.finishBy ?? '' })}`);
1837
2257
  target.debug?.(JSON.stringify({
1838
2258
  scope: 'runtime-notice',
1839
2259
  level: notice.level,
@@ -1848,14 +2268,17 @@ export function reportRuntimeNotice(target, code, fields = {}) {
1848
2268
  * The file is the authority: the supplied identifier only names which window the caller believes was opened,
1849
2269
  * and a session the file does not hold as active changes nothing and reports nothing. A confirmed pickup emits
1850
2270
  * one operational record, which is the evidence that this process began retaining the window's scopes, taken
1851
- * 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.
1852
2273
  */
1853
2274
  export function armDiagnosticsAuthorization(target, storageRoot, supportCaseId, now = Date.now) {
1854
- if (!isSupportCaseId(supportCaseId) ||
1855
- activeDiagnosticsSession(storageRoot, now())?.supportCaseId !== supportCaseId) {
2275
+ const session = activeDiagnosticsSession(storageRoot, now());
2276
+ if (!isSupportCaseId(supportCaseId) || session?.supportCaseId !== supportCaseId) {
1856
2277
  return false;
1857
2278
  }
1858
- reportRuntimeNotice(target, 'diagnostics-authorization-armed');
2279
+ reportRuntimeNotice(target, 'diagnostics-authorization-armed', {
2280
+ finishBy: new Date(session.expiresAt).toLocaleString(),
2281
+ });
1859
2282
  return true;
1860
2283
  }
1861
2284
  /** Emits the startup notice for discarded settings awaiting acknowledgement. */
@@ -1877,17 +2300,55 @@ export function reportInvalidSnapshotCache(target) {
1877
2300
  target[level](`[${code}] ${localize(target, messageKey)}`);
1878
2301
  target.debug?.(JSON.stringify({ scope: 'media-notice', level, code, messageKey }));
1879
2302
  }
1880
- /** Emits one allowlisted HomeKit event trace only when host debug output is available. */
1881
- export function reportHomeKitEvent(target, trace) {
2303
+ /**
2304
+ * Emits one allowlisted HomeKit event trace only when host debug output is available.
2305
+ *
2306
+ * `aliasFor` resolves the trace's device identity to the support-case alias the conditions about that accessory
2307
+ * already carry. The identity itself never reaches the record: an unresolved one is recorded as no accessory.
2308
+ */
2309
+ /**
2310
+ * Narrows one offered station arbitration decision, or nothing where its own labels do not narrow.
2311
+ *
2312
+ * Read by both halves of the path — {@link reportStationClaim} builds the record and the file sink rebuilds
2313
+ * it — because a record only one of them recognises is written to a console nobody is reading and dropped
2314
+ * before it reaches a support archive.
2315
+ */
2316
+ function sanitizeStationClaim(value) {
2317
+ const action = allowlistedLabel(value.action, STATION_CLAIM_ACTIONS);
2318
+ const claim = allowlistedLabel(value.claim, STATION_CLAIMS);
2319
+ if (!action || !claim) {
2320
+ return undefined;
2321
+ }
2322
+ const against = allowlistedLabel(value.to ?? value.by ?? value.against, STATION_CLAIMS);
2323
+ return { event: 'station-claim', action, claim, ...(against === undefined ? {} : { against }) };
2324
+ }
2325
+ /**
2326
+ * Records one arbitration decision over a station's own session as `homekit-log` evidence.
2327
+ *
2328
+ * A still that stood aside produced no picture of its own and no failure, so without this the deferral is
2329
+ * indistinguishable afterwards from a capture that was never asked for. The record carries the claims and the
2330
+ * decision alone: a station identity is a serial, and the camera holding it is already named by the records
2331
+ * about that camera.
2332
+ */
2333
+ export function reportStationClaim(target, decision) {
2334
+ const claim = sanitizeStationClaim(decision);
2335
+ if (target.debug && claim) {
2336
+ target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...claim }));
2337
+ }
2338
+ }
2339
+ export function reportHomeKitEvent(target, trace, aliasFor) {
2340
+ const alias = typeof trace.serial === 'string' ? aliasFor?.(trace.serial) : undefined;
2341
+ const offered = { ...trace, accessory: alias };
2342
+ const accessory = alias === undefined ? {} : { accessory: alias };
1882
2343
  if (trace.event === 'live-video-selected') {
1883
- const selection = sanitizeLiveVideoSelection(trace);
2344
+ const selection = sanitizeLiveVideoSelection(offered);
1884
2345
  if (target.debug && selection) {
1885
2346
  target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...selection }));
1886
2347
  }
1887
2348
  return;
1888
2349
  }
1889
2350
  if (HOMEKIT_LIVE_REQUEST_EVENTS.has(trace.event)) {
1890
- const lifecycle = sanitizeLiveSessionTrace(trace);
2351
+ const lifecycle = sanitizeLiveSessionTrace(offered);
1891
2352
  if (target.debug && lifecycle) {
1892
2353
  target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...lifecycle }));
1893
2354
  }
@@ -1909,6 +2370,7 @@ export function reportHomeKitEvent(target, trace) {
1909
2370
  ...(typeof trace.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(trace.announcedBy)
1910
2371
  ? { announcedBy: trace.announcedBy }
1911
2372
  : {}),
2373
+ ...accessory,
1912
2374
  }));
1913
2375
  }
1914
2376
  /**
@@ -2015,11 +2477,13 @@ function sanitizeAdaptationNotice(value, level) {
2015
2477
  .filter((line) => line !== undefined)
2016
2478
  .slice(-MAX_ADAPTATION_STDERR_LINES);
2017
2479
  const sourceFragments = nonNegativeInteger(value.sourceFragments);
2480
+ const cause = allowlistedLabel(value.cause, ADAPTATION_CAUSES);
2018
2481
  return {
2019
2482
  scope: 'ffmpeg',
2020
2483
  level,
2021
2484
  role: value.role,
2022
2485
  event: value.event,
2486
+ ...(cause === undefined ? {} : { cause }),
2023
2487
  ...(code === undefined ? {} : { code }),
2024
2488
  ...(signal === undefined ? {} : { signal }),
2025
2489
  ...(sourceFragments === undefined ? {} : { sourceFragments }),
@@ -2030,17 +2494,35 @@ function sanitizeLiveSessionTrace(value) {
2030
2494
  if (value.adapter !== 'camera.streaming') {
2031
2495
  return undefined;
2032
2496
  }
2033
- if (value.event === 'live-session-released' || value.event === 'live-session-streaming') {
2034
- return { adapter: value.adapter, event: value.event };
2497
+ const alias = opaqueAccessoryAlias(value.accessory);
2498
+ const accessory = alias === undefined ? {} : { accessory: alias };
2499
+ if (value.event === 'live-session-released') {
2500
+ const release = allowlistedLabel(value.release, LIVE_SESSION_RELEASES);
2501
+ if (!release) {
2502
+ return undefined;
2503
+ }
2504
+ const reports = nonNegativeInteger(value.reports);
2505
+ const sinceLastReportMs = boundedInteger(value.sinceLastReportMs, MAX_STARTUP_WINDOW_MS);
2506
+ return {
2507
+ adapter: value.adapter,
2508
+ event: value.event,
2509
+ release,
2510
+ ...(reports === undefined ? {} : { reports }),
2511
+ ...(sinceLastReportMs === undefined ? {} : { sinceLastReportMs }),
2512
+ ...accessory,
2513
+ };
2514
+ }
2515
+ if (value.event === 'live-session-streaming') {
2516
+ return { adapter: value.adapter, event: value.event, ...accessory };
2035
2517
  }
2036
2518
  if (value.event === 'live-request-refused') {
2037
2519
  return typeof value.reason === 'string' && REFUSAL_REASONS.has(value.reason)
2038
- ? { adapter: value.adapter, event: value.event, reason: value.reason }
2520
+ ? { adapter: value.adapter, event: value.event, reason: value.reason, ...accessory }
2039
2521
  : undefined;
2040
2522
  }
2041
2523
  if (value.event === 'live-request-unaccounted') {
2042
2524
  return typeof value.afterMs === 'number' && Number.isFinite(value.afterMs)
2043
- ? { adapter: value.adapter, event: value.event, afterMs: Math.round(value.afterMs) }
2525
+ ? { adapter: value.adapter, event: value.event, afterMs: Math.round(value.afterMs), ...accessory }
2044
2526
  : undefined;
2045
2527
  }
2046
2528
  if (value.event !== 'live-session-failed' ||
@@ -2057,6 +2539,7 @@ function sanitizeLiveSessionTrace(value) {
2057
2539
  outcome: value.outcome,
2058
2540
  reason: value.reason,
2059
2541
  stage: value.stage,
2542
+ ...accessory,
2060
2543
  };
2061
2544
  }
2062
2545
  function sanitizeLiveVideoSelection(value) {
@@ -2082,6 +2565,7 @@ function sanitizeLiveVideoSelection(value) {
2082
2565
  !HOMEKIT_LIVE_ADDRESS_VERSIONS.has(addressVersion)) {
2083
2566
  return undefined;
2084
2567
  }
2568
+ const alias = opaqueAccessoryAlias(value.accessory);
2085
2569
  return {
2086
2570
  adapter: 'camera.streaming',
2087
2571
  event: 'live-video-selected',
@@ -2093,6 +2577,7 @@ function sanitizeLiveVideoSelection(value) {
2093
2577
  fps,
2094
2578
  mtu,
2095
2579
  addressVersion,
2580
+ ...(alias === undefined ? {} : { accessory: alias }),
2096
2581
  };
2097
2582
  }
2098
2583
  /** Emits bounded normal-output condition transitions without stable device or account identity. */
@@ -2151,10 +2636,11 @@ export class DiagnosticConditions {
2151
2636
  }
2152
2637
  const uniqueDeviceIds = condition.active ? [...new Set(affectedDeviceIds)].sort() : [];
2153
2638
  const accessoryAliases = uniqueDeviceIds
2154
- .map((identity) => this.accessoryAlias(identity))
2639
+ .map((identity) => this.aliasFor(identity))
2155
2640
  .filter((alias) => alias !== undefined)
2156
2641
  .sort();
2157
- this.write(condition.code, condition.active, condition.reason, definition[0], definition[1], condition.active ? 'warn' : 'info', {
2642
+ const { level, actionKey } = homeKitConditionPresentation(condition.code, condition.active, condition.reason);
2643
+ this.write(condition.code, condition.active, condition.reason, definition[0], actionKey, level, {
2158
2644
  ...(condition.capability === undefined ? {} : { capability: condition.capability }),
2159
2645
  ...(condition.member === undefined ? {} : { member: condition.member }),
2160
2646
  affectedAccessoryCount: uniqueDeviceIds.length,
@@ -2166,9 +2652,19 @@ export class DiagnosticConditions {
2166
2652
  const alias = this.aliases.get(serial);
2167
2653
  return name === undefined ? undefined : alias === undefined ? name : `${name} (${alias})`;
2168
2654
  })
2169
- .filter((named) => named !== undefined));
2655
+ .filter((named) => named !== undefined), uniqueDeviceIds);
2170
2656
  }
2171
- accessoryAlias(identity) {
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);
2660
+ }
2661
+ /**
2662
+ * The support-case alias for one device identity, minted on first use.
2663
+ *
2664
+ * The one place an alias is decided, so every record about a device — a condition, a media trace — carries
2665
+ * the same one, and the owner's console line is what resolves it to a name.
2666
+ */
2667
+ aliasFor(identity) {
2172
2668
  let alias = this.aliases.get(identity);
2173
2669
  if (!alias && this.aliases.size < MAX_ACCESSORY_ALIASES) {
2174
2670
  alias = `accessory-${randomUUID()}`;
@@ -2178,13 +2674,16 @@ export class DiagnosticConditions {
2178
2674
  }
2179
2675
  write(code, active, reason, summaryKey, actionKey, level, fields = {}, conditionKey = code,
2180
2676
  /** Accessory names for the console line only — never retained, see {@link DiagnosticConditions}. */
2181
- names) {
2182
- const fingerprint = JSON.stringify({ active, reason, ...fields });
2677
+ names, serials = []) {
2678
+ const fingerprint = JSON.stringify({ active, reason, ...fields, serials });
2183
2679
  if (active) {
2184
- if (this.active.get(conditionKey) === fingerprint) {
2680
+ if (this.active.get(conditionKey)?.fingerprint === fingerprint) {
2185
2681
  return;
2186
2682
  }
2187
- this.active.set(conditionKey, fingerprint);
2683
+ this.active.set(conditionKey, {
2684
+ fingerprint,
2685
+ condition: { code, summaryKey, actionKey, ...(serials.length === 0 ? {} : { serials: [...serials] }) },
2686
+ });
2188
2687
  }
2189
2688
  else if (!this.active.delete(conditionKey)) {
2190
2689
  return;