@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.5 → 5.0.0-beta.7

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.
@@ -33,6 +33,8 @@ 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'];
36
38
  /** The bounded reasons a stream request is refused before it reaches the source. */
37
39
  const REFUSAL_REASONS = new Set(['disabled', 'at-capacity', 'cancelled', 'prepare-failed']);
38
40
  /**
@@ -47,6 +49,28 @@ const HOMEKIT_LIVE_REQUEST_EVENTS = new Set([
47
49
  'live-session-released',
48
50
  'live-session-streaming',
49
51
  ]);
52
+ /**
53
+ * The error identities an SDK record may name, which is all a record carries of one.
54
+ *
55
+ * A message is never among them, so an identity absent here is recorded as `Error` and reads as any other
56
+ * failure. A station refusing a second camera is one of these identities, because a base serving another of its
57
+ * cameras is working correctly and must not read as a fault.
58
+ */
59
+ const SDK_ERROR_TYPES = [
60
+ 'Error',
61
+ 'RangeError',
62
+ 'SessionExpiredError',
63
+ 'StationBusyError',
64
+ 'StationKeyUnavailableError',
65
+ 'StationUnreachableError',
66
+ 'TypeError',
67
+ ];
68
+ /**
69
+ * The retained fields that stand for a device rather than describing one, which is the privacy class a manifest
70
+ * declares for them. Each holds a per-case alias: stable within one archive, and resolvable to a device only by
71
+ * the owner whose console paired it with a name.
72
+ */
73
+ const PSEUDONYMOUS_FIELDS = new Set(['accessory', 'accessoryAliases']);
50
74
  const MAX_SDK_DETAILS = 16;
51
75
  const MAX_LOG_RECORD_BYTES = 64 * 1024;
52
76
  const MAX_CURRENT_LOG_BYTES = 50 * 1024 * 1024;
@@ -71,7 +95,12 @@ const DIAGNOSTICS_DIRECTORY = 'diagnostics';
71
95
  const GUIDED_SESSION_FILE = 'session.json';
72
96
  const REPRODUCTION_MARKERS_FILE = 'reproduction-markers.jsonl';
73
97
  const UI_EVENTS_FILE = 'ui-events.jsonl';
74
- const FFMPEG_ENVIRONMENT_FILE = 'ffmpeg.json';
98
+ const HOST_ENVIRONMENT_FILE = 'host.json';
99
+ const FLEET_FILE = 'fleet.json';
100
+ /** The longest device model or adapter key retained, which bounds values this plugin does not choose alone. */
101
+ const MAX_DEVICE_MODEL_LENGTH = 64;
102
+ /** How many HomeKit services one fleet entry names, which is above what any single device is represented by. */
103
+ const MAX_FLEET_SERVICES = 12;
75
104
  /** How much of an FFmpeg path or version banner is kept, which is more than either needs. */
76
105
  const MAX_FFMPEG_IDENTITY_LENGTH = 256;
77
106
  const DEBUG_AUTHORIZATION_MS = 72 * 60 * 60 * 1_000;
@@ -153,6 +182,15 @@ const SDK_EVENT_KEYS = new Set([
153
182
  'transport-error',
154
183
  ]);
155
184
  const EN_MESSAGES = JSON.parse(readFileSync(new URL('../i18n/runtime/en.json', import.meta.url), 'utf8'));
185
+ /**
186
+ * The reporter's answer as a support archive carries it: every device, or how many they named.
187
+ *
188
+ * The serials themselves stay on the host. They identify one household's hardware, and no evidence class in an
189
+ * archive carries a device identity.
190
+ */
191
+ function countedAffectedDevices(affected) {
192
+ return affected === 'all' ? 'all' : new Set(affected).size;
193
+ }
156
194
  /** The bounded UI events a support session may record, and the only vocabulary the sink accepts. */
157
195
  const DIAGNOSTICS_UI_EVENTS = [
158
196
  'background-started',
@@ -302,54 +340,135 @@ function boundedText(value, maximumLength) {
302
340
  .trim();
303
341
  return printable === '' ? undefined : printable;
304
342
  }
305
- function ffmpegEnvironmentPath(storageRoot) {
306
- return join(storageRoot, DIAGNOSTICS_DIRECTORY, FFMPEG_ENVIRONMENT_FILE);
343
+ function fleetPath(storageRoot) {
344
+ return join(storageRoot, DIAGNOSTICS_DIRECTORY, FLEET_FILE);
307
345
  }
308
346
  /**
309
- * Records which FFmpeg this run resolved, so a support archive states the build a failure came from.
347
+ * Records what the account's devices are and what each becomes, replacing the record whole.
310
348
  *
311
- * It is persisted rather than held in memory because the process that resolves it is not the one that
312
- * assembles an archive, and it is rewritten on every start so a path or binary that changed between runs
313
- * cannot be reported as the one in use. A write that fails is dropped: the archive then declares the
314
- * environment without an FFmpeg identity, which is honest, whereas failing startup over a diagnostic file
315
- * would cost the user their cameras.
349
+ * Written by the process that holds both the inventory and the accessory aliases, because an archive is
350
+ * assembled by another one. Rewritten on every complete inventory, so a device withdrawn or newly recognised
351
+ * cannot be reported as still present. A write that fails is dropped: an archive then declares no fleet, which
352
+ * is honest, whereas failing over a diagnostic file would cost the user their cameras.
316
353
  */
317
- export function recordFfmpegEnvironment(storageRoot, ffmpeg) {
354
+ export function recordFleet(storageRoot, fleet) {
318
355
  try {
319
- const path = ffmpegEnvironmentPath(storageRoot);
356
+ const path = fleetPath(storageRoot);
320
357
  mkdirSync(dirname(path), { mode: 0o700, recursive: true });
321
- writeFileSync(path, `${JSON.stringify({ version: 1, ffmpeg })}\n`, { mode: 0o600 });
358
+ writeFileSync(path, `${JSON.stringify({ version: 1, devices: fleet })}\n`, { mode: 0o600 });
322
359
  }
323
360
  catch { }
324
361
  }
362
+ /** Reads the recorded fleet, keeping each entry whose own fields narrow and dropping each that does not. */
363
+ function readFleet(storageRoot) {
364
+ try {
365
+ const candidate = JSON.parse(readFileSync(fleetPath(storageRoot), 'utf8'));
366
+ if (candidate.version !== 1 || !Array.isArray(candidate.devices)) {
367
+ return undefined;
368
+ }
369
+ const devices = candidate.devices.flatMap((entry) => {
370
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
371
+ return [];
372
+ const row = entry;
373
+ const accessory = opaqueAccessoryAlias(row.accessory);
374
+ if (accessory === undefined || typeof row.represented !== 'boolean' || typeof row.attached !== 'boolean') {
375
+ return [];
376
+ }
377
+ const model = typeof row.model === 'string' ? boundedText(row.model, MAX_DEVICE_MODEL_LENGTH) : undefined;
378
+ const services = (Array.isArray(row.services) ? row.services : [])
379
+ .filter((service) => typeof service === 'string')
380
+ .slice(0, MAX_FLEET_SERVICES)
381
+ .flatMap((service) => {
382
+ const bounded = boundedText(service, MAX_DEVICE_MODEL_LENGTH);
383
+ return bounded === undefined ? [] : [bounded];
384
+ });
385
+ return [
386
+ {
387
+ accessory,
388
+ ...(model === undefined ? {} : { model }),
389
+ represented: row.represented,
390
+ attached: row.attached,
391
+ services,
392
+ },
393
+ ];
394
+ });
395
+ return devices.length === 0 ? undefined : devices.slice(0, MAX_ACCESSORY_ALIASES);
396
+ }
397
+ catch {
398
+ return undefined;
399
+ }
400
+ }
401
+ function hostEnvironmentPath(storageRoot) {
402
+ return join(storageRoot, DIAGNOSTICS_DIRECTORY, HOST_ENVIRONMENT_FILE);
403
+ }
404
+ /** The longest host version string retained, which bounds a value this plugin does not choose. */
405
+ const MAX_HOST_VERSION_LENGTH = 64;
325
406
  /**
326
- * Reads the recorded adaptation binary, refusing a record whose own fields do not narrow.
407
+ * Records what this run is hosted by, so a support archive states the host a failure came from.
327
408
  *
328
- * The path and the banner are kept as written rather than pattern-replaced, because naming the binary is the
329
- * entire purpose of the record and a redacted one answers nothing. Neither is device or account material: the
330
- * path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
409
+ * It is persisted rather than held in memory because the process that knows these facts is not the one that
410
+ * assembles an archive, and it is rewritten on every start so a host or binary that changed between runs
411
+ * cannot be reported as the one in use. Each call replaces the record whole, so a caller states everything it
412
+ * knows rather than accumulating into a file two writers would race for.
413
+ *
414
+ * A write that fails is dropped: the archive then declares the environment without these facts, which is
415
+ * honest, whereas failing startup over a diagnostic file would cost the user their cameras.
331
416
  */
332
- function readFfmpegEnvironment(storageRoot) {
417
+ export function recordHostEnvironment(storageRoot, host) {
333
418
  try {
334
- const candidate = JSON.parse(readFileSync(ffmpegEnvironmentPath(storageRoot), 'utf8'));
335
- const ffmpeg = candidate.ffmpeg;
336
- if (candidate.version !== 1 || !ffmpeg || typeof ffmpeg !== 'object' || Array.isArray(ffmpeg)) {
419
+ const path = hostEnvironmentPath(storageRoot);
420
+ mkdirSync(dirname(path), { mode: 0o700, recursive: true });
421
+ writeFileSync(path, `${JSON.stringify({ version: 2, ...host })}\n`, { mode: 0o600 });
422
+ }
423
+ catch { }
424
+ }
425
+ /**
426
+ * Reads what this run is hosted by, keeping each fact that narrows and dropping each that does not.
427
+ *
428
+ * The facts are independent, so a malformed binary identity does not withhold a host version, and a record
429
+ * stating neither is no record at all.
430
+ */
431
+ function readHostEnvironment(storageRoot) {
432
+ try {
433
+ const candidate = JSON.parse(readFileSync(hostEnvironmentPath(storageRoot), 'utf8'));
434
+ if (candidate.version !== 2) {
337
435
  return undefined;
338
436
  }
339
- const recorded = ffmpeg;
340
- if (typeof recorded.path !== 'string' || (recorded.source !== 'bundled' && recorded.source !== 'configured')) {
437
+ const homebridge = typeof candidate.homebridge === 'string' ? boundedText(candidate.homebridge, MAX_HOST_VERSION_LENGTH) : undefined;
438
+ const ffmpeg = readFfmpegIdentity(candidate.ffmpeg);
439
+ if (homebridge === undefined && ffmpeg === undefined) {
341
440
  return undefined;
342
441
  }
343
- const path = boundedText(recorded.path, MAX_FFMPEG_IDENTITY_LENGTH);
344
- const version = typeof recorded.version === 'string' ? boundedText(recorded.version, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
345
- return path === undefined
346
- ? undefined
347
- : { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
442
+ return {
443
+ ...(homebridge === undefined ? {} : { homebridge }),
444
+ ...(ffmpeg === undefined ? {} : { ffmpeg }),
445
+ };
348
446
  }
349
447
  catch {
350
448
  return undefined;
351
449
  }
352
450
  }
451
+ /**
452
+ * The recorded adaptation binary, or nothing where the record's own fields do not narrow.
453
+ *
454
+ * The path and the banner are kept as written rather than pattern-replaced, because naming the binary is the
455
+ * entire purpose of the record and a redacted one answers nothing. Neither is device or account material: the
456
+ * path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
457
+ */
458
+ function readFfmpegIdentity(value) {
459
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
460
+ return undefined;
461
+ }
462
+ const recorded = value;
463
+ if (recorded.source !== 'bundled' && recorded.source !== 'configured') {
464
+ return undefined;
465
+ }
466
+ const path = typeof recorded.path === 'string' ? boundedText(recorded.path, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
467
+ const version = typeof recorded.version === 'string' ? boundedText(recorded.version, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
468
+ return path === undefined
469
+ ? undefined
470
+ : { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
471
+ }
353
472
  /**
354
473
  * The one log evidence class a retained record belongs to.
355
474
  *
@@ -626,7 +745,8 @@ export class GuidedDiagnostics {
626
745
  }
627
746
  }
628
747
  async collectSupportEvidence(session) {
629
- const ffmpeg = readFfmpegEnvironment(this.storageRoot);
748
+ const host = readHostEnvironment(this.storageRoot);
749
+ const fleet = readFleet(this.storageRoot);
630
750
  const evidence = [
631
751
  ...(session.affectedDevices === undefined
632
752
  ? []
@@ -635,15 +755,34 @@ export class GuidedDiagnostics {
635
755
  evidence: 'reporter-statement',
636
756
  privacyClass: 'diagnostic',
637
757
  contentType: 'application/json',
638
- content: `${JSON.stringify({ version: 1, affectedDevices: session.affectedDevices })}\n`,
758
+ content: `${JSON.stringify({ version: 2, affectedDevices: countedAffectedDevices(session.affectedDevices) })}\n`,
639
759
  /**
640
- * What the reporter answered, not what the plugin observed. A serial identifies one household's
641
- * hardware, so the record is classified above the environment it sits beside and stays inside
642
- * the archive.
760
+ * What the reporter answered, not what the plugin observed. A serial is a device identity and no
761
+ * evidence class carries one, so the answer travels as its scope: every device, or how many were
762
+ * named. Which ones is the reporter's to say in their own words.
643
763
  */
644
764
  fields: [
645
765
  { field: 'version', privacyClass: 'operational' },
646
- { field: 'affectedDevices', privacyClass: 'pseudonymous' },
766
+ { field: 'affectedDevices', privacyClass: 'operational' },
767
+ ],
768
+ },
769
+ ]),
770
+ ...(fleet === undefined
771
+ ? []
772
+ : [
773
+ {
774
+ evidence: 'fleet',
775
+ privacyClass: 'operational',
776
+ contentType: 'application/json',
777
+ content: `${JSON.stringify({ version: 1, devices: fleet })}\n`,
778
+ /**
779
+ * What each device is and becomes, against the alias every other record about it carries. A model
780
+ * and an admission outcome are product facts rather than device identity; the alias is what makes
781
+ * an entry resolvable to a device, and only by the owner whose console paired it with a name.
782
+ */
783
+ fields: [
784
+ { field: 'version', privacyClass: 'operational' },
785
+ { field: 'devices', privacyClass: 'pseudonymous' },
647
786
  ],
648
787
  },
649
788
  ]),
@@ -651,18 +790,22 @@ export class GuidedDiagnostics {
651
790
  evidence: 'environment',
652
791
  privacyClass: 'operational',
653
792
  contentType: 'application/json',
654
- content: `${JSON.stringify({ version: 2, plugin: PLUGIN_VERSION, sdk: SDK_VERSION, node: process.version, platform: process.platform, arch: process.arch, ...(ffmpeg ? { ffmpeg } : {}) })}\n`,
793
+ 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`,
655
794
  /**
656
795
  * The record is operational and one field is classified above it: a resolved FFmpeg path is an
657
796
  * environment fact, but it can carry the home directory of the account Homebridge runs as, so it is
658
797
  * declared as diagnostic rather than presented alongside the host's own architecture.
798
+ *
799
+ * The Homebridge version is operational and load-bearing: this plugin requires Homebridge 2, and a
800
+ * host that predates it loads the plugin and then fails in ways a media fault cannot be told from.
659
801
  */
660
802
  fields: [
661
803
  ...['version', 'plugin', 'sdk', 'node', 'platform', 'arch'].map((field) => ({
662
804
  field,
663
805
  privacyClass: 'operational',
664
806
  })),
665
- ...(ffmpeg ? [{ field: 'ffmpeg', privacyClass: 'diagnostic' }] : []),
807
+ ...(host?.homebridge ? [{ field: 'homebridge', privacyClass: 'operational' }] : []),
808
+ ...(host?.ffmpeg ? [{ field: 'ffmpeg', privacyClass: 'diagnostic' }] : []),
666
809
  ],
667
810
  },
668
811
  ];
@@ -713,7 +856,11 @@ export class GuidedDiagnostics {
713
856
  ...(carried.retainedFrom === undefined ? {} : { retainedFrom: carried.retainedFrom }),
714
857
  fields: [...new Set(carried.records.flatMap((record) => Object.keys(record)))].sort().map((field) => ({
715
858
  field,
716
- privacyClass: field === 'accessoryAliases' ? 'pseudonymous' : field === 'timestamp' ? 'operational' : 'diagnostic',
859
+ privacyClass: PSEUDONYMOUS_FIELDS.has(field)
860
+ ? 'pseudonymous'
861
+ : field === 'timestamp'
862
+ ? 'operational'
863
+ : 'diagnostic',
717
864
  })),
718
865
  });
719
866
  }
@@ -1280,6 +1427,18 @@ function sanitizeSdkLiveStartTrace(value) {
1280
1427
  function opaquePullHandle(value) {
1281
1428
  return typeof value === 'string' && /^(?:pull|station)-\d{1,6}(?::\d{1,3})?$/.test(value) ? value : undefined;
1282
1429
  }
1430
+ /**
1431
+ * One support-case accessory alias, or nothing where the value is not one.
1432
+ *
1433
+ * Matched against the shape an alias has rather than against what a serial looks like, on the same rule as
1434
+ * {@link opaquePullHandle}: a record carries an alias or it carries no accessory at all.
1435
+ */
1436
+ function opaqueAccessoryAlias(value) {
1437
+ return typeof value === 'string' &&
1438
+ /^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)
1439
+ ? value
1440
+ : undefined;
1441
+ }
1283
1442
  /**
1284
1443
  * Adapts SDK protocol detail to bounded debug output without preserving supplied values.
1285
1444
  *
@@ -1321,9 +1480,7 @@ export function createSdkLogger(target) {
1321
1480
  const subsystem = SDK_SUBSYSTEMS.has(requestedSubsystem ?? '') ? requestedSubsystem : (aliasedSubsystem ?? 'sdk');
1322
1481
  const details = args.slice(0, MAX_SDK_DETAILS).map((value) => {
1323
1482
  if (value instanceof Error) {
1324
- const errorType = ['Error', 'RangeError', 'SessionExpiredError', 'TypeError'].includes(value.name)
1325
- ? value.name
1326
- : 'Error';
1483
+ const errorType = SDK_ERROR_TYPES.includes(value.name) ? value.name : 'Error';
1327
1484
  return { errorType };
1328
1485
  }
1329
1486
  if (typeof value === 'string') {
@@ -1742,12 +1899,7 @@ function sanitizeStructuredEvent(message) {
1742
1899
  if (!detail || typeof detail !== 'object' || Array.isArray(detail))
1743
1900
  continue;
1744
1901
  const candidate = detail;
1745
- const errorType = allowlistedLabel(candidate.errorType, [
1746
- 'Error',
1747
- 'RangeError',
1748
- 'SessionExpiredError',
1749
- 'TypeError',
1750
- ]);
1902
+ const errorType = allowlistedLabel(candidate.errorType, SDK_ERROR_TYPES);
1751
1903
  if (errorType) {
1752
1904
  details.push({ errorType });
1753
1905
  continue;
@@ -1813,6 +1965,7 @@ function sanitizeStructuredEvent(message) {
1813
1965
  !HOMEKIT_OBSERVATIONS.has(value.observation)) {
1814
1966
  return undefined;
1815
1967
  }
1968
+ const alias = opaqueAccessoryAlias(value.accessory);
1816
1969
  return {
1817
1970
  scope: 'homekit',
1818
1971
  level: 'debug',
@@ -1822,6 +1975,7 @@ function sanitizeStructuredEvent(message) {
1822
1975
  ...(typeof value.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(value.announcedBy)
1823
1976
  ? { announcedBy: value.announcedBy }
1824
1977
  : {}),
1978
+ ...(alias === undefined ? {} : { accessory: alias }),
1825
1979
  };
1826
1980
  }
1827
1981
  if (value.scope === 'runtime') {
@@ -1938,17 +2092,25 @@ export function reportInvalidSnapshotCache(target) {
1938
2092
  target[level](`[${code}] ${localize(target, messageKey)}`);
1939
2093
  target.debug?.(JSON.stringify({ scope: 'media-notice', level, code, messageKey }));
1940
2094
  }
1941
- /** Emits one allowlisted HomeKit event trace only when host debug output is available. */
1942
- export function reportHomeKitEvent(target, trace) {
2095
+ /**
2096
+ * Emits one allowlisted HomeKit event trace only when host debug output is available.
2097
+ *
2098
+ * `aliasFor` resolves the trace's device identity to the support-case alias the conditions about that accessory
2099
+ * already carry. The identity itself never reaches the record: an unresolved one is recorded as no accessory.
2100
+ */
2101
+ export function reportHomeKitEvent(target, trace, aliasFor) {
2102
+ const alias = typeof trace.serial === 'string' ? aliasFor?.(trace.serial) : undefined;
2103
+ const offered = { ...trace, accessory: alias };
2104
+ const accessory = alias === undefined ? {} : { accessory: alias };
1943
2105
  if (trace.event === 'live-video-selected') {
1944
- const selection = sanitizeLiveVideoSelection(trace);
2106
+ const selection = sanitizeLiveVideoSelection(offered);
1945
2107
  if (target.debug && selection) {
1946
2108
  target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...selection }));
1947
2109
  }
1948
2110
  return;
1949
2111
  }
1950
2112
  if (HOMEKIT_LIVE_REQUEST_EVENTS.has(trace.event)) {
1951
- const lifecycle = sanitizeLiveSessionTrace(trace);
2113
+ const lifecycle = sanitizeLiveSessionTrace(offered);
1952
2114
  if (target.debug && lifecycle) {
1953
2115
  target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...lifecycle }));
1954
2116
  }
@@ -1970,6 +2132,7 @@ export function reportHomeKitEvent(target, trace) {
1970
2132
  ...(typeof trace.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(trace.announcedBy)
1971
2133
  ? { announcedBy: trace.announcedBy }
1972
2134
  : {}),
2135
+ ...accessory,
1973
2136
  }));
1974
2137
  }
1975
2138
  /**
@@ -2091,17 +2254,23 @@ function sanitizeLiveSessionTrace(value) {
2091
2254
  if (value.adapter !== 'camera.streaming') {
2092
2255
  return undefined;
2093
2256
  }
2094
- if (value.event === 'live-session-released' || value.event === 'live-session-streaming') {
2095
- return { adapter: value.adapter, event: value.event };
2257
+ const alias = opaqueAccessoryAlias(value.accessory);
2258
+ const accessory = alias === undefined ? {} : { accessory: alias };
2259
+ if (value.event === 'live-session-released') {
2260
+ const release = allowlistedLabel(value.release, LIVE_SESSION_RELEASES);
2261
+ return release ? { adapter: value.adapter, event: value.event, release, ...accessory } : undefined;
2262
+ }
2263
+ if (value.event === 'live-session-streaming') {
2264
+ return { adapter: value.adapter, event: value.event, ...accessory };
2096
2265
  }
2097
2266
  if (value.event === 'live-request-refused') {
2098
2267
  return typeof value.reason === 'string' && REFUSAL_REASONS.has(value.reason)
2099
- ? { adapter: value.adapter, event: value.event, reason: value.reason }
2268
+ ? { adapter: value.adapter, event: value.event, reason: value.reason, ...accessory }
2100
2269
  : undefined;
2101
2270
  }
2102
2271
  if (value.event === 'live-request-unaccounted') {
2103
2272
  return typeof value.afterMs === 'number' && Number.isFinite(value.afterMs)
2104
- ? { adapter: value.adapter, event: value.event, afterMs: Math.round(value.afterMs) }
2273
+ ? { adapter: value.adapter, event: value.event, afterMs: Math.round(value.afterMs), ...accessory }
2105
2274
  : undefined;
2106
2275
  }
2107
2276
  if (value.event !== 'live-session-failed' ||
@@ -2118,6 +2287,7 @@ function sanitizeLiveSessionTrace(value) {
2118
2287
  outcome: value.outcome,
2119
2288
  reason: value.reason,
2120
2289
  stage: value.stage,
2290
+ ...accessory,
2121
2291
  };
2122
2292
  }
2123
2293
  function sanitizeLiveVideoSelection(value) {
@@ -2143,6 +2313,7 @@ function sanitizeLiveVideoSelection(value) {
2143
2313
  !HOMEKIT_LIVE_ADDRESS_VERSIONS.has(addressVersion)) {
2144
2314
  return undefined;
2145
2315
  }
2316
+ const alias = opaqueAccessoryAlias(value.accessory);
2146
2317
  return {
2147
2318
  adapter: 'camera.streaming',
2148
2319
  event: 'live-video-selected',
@@ -2154,6 +2325,7 @@ function sanitizeLiveVideoSelection(value) {
2154
2325
  fps,
2155
2326
  mtu,
2156
2327
  addressVersion,
2328
+ ...(alias === undefined ? {} : { accessory: alias }),
2157
2329
  };
2158
2330
  }
2159
2331
  /** Emits bounded normal-output condition transitions without stable device or account identity. */
@@ -2212,7 +2384,7 @@ export class DiagnosticConditions {
2212
2384
  }
2213
2385
  const uniqueDeviceIds = condition.active ? [...new Set(affectedDeviceIds)].sort() : [];
2214
2386
  const accessoryAliases = uniqueDeviceIds
2215
- .map((identity) => this.accessoryAlias(identity))
2387
+ .map((identity) => this.aliasFor(identity))
2216
2388
  .filter((alias) => alias !== undefined)
2217
2389
  .sort();
2218
2390
  this.write(condition.code, condition.active, condition.reason, definition[0], definition[1], condition.active ? 'warn' : 'info', {
@@ -2229,7 +2401,13 @@ export class DiagnosticConditions {
2229
2401
  })
2230
2402
  .filter((named) => named !== undefined));
2231
2403
  }
2232
- accessoryAlias(identity) {
2404
+ /**
2405
+ * The support-case alias for one device identity, minted on first use.
2406
+ *
2407
+ * The one place an alias is decided, so every record about a device — a condition, a media trace — carries
2408
+ * the same one, and the owner's console line is what resolves it to a name.
2409
+ */
2410
+ aliasFor(identity) {
2233
2411
  let alias = this.aliases.get(identity);
2234
2412
  if (!alias && this.aliases.size < MAX_ACCESSORY_ALIASES) {
2235
2413
  alias = `accessory-${randomUUID()}`;