@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.6 → 5.0.0-beta.8

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.
@@ -35,6 +35,17 @@ export function unconfirmedWriteCondition(property) {
35
35
  }
36
36
  /** Why a live session gave its source back: a stop from outside it, or the session ending itself. */
37
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;
38
49
  /** The bounded reasons a stream request is refused before it reaches the source. */
39
50
  const REFUSAL_REASONS = new Set(['disabled', 'at-capacity', 'cancelled', 'prepare-failed']);
40
51
  /**
@@ -56,7 +67,15 @@ const HOMEKIT_LIVE_REQUEST_EVENTS = new Set([
56
67
  * failure. A station refusing a second camera is one of these identities, because a base serving another of its
57
68
  * cameras is working correctly and must not read as a fault.
58
69
  */
59
- const SDK_ERROR_TYPES = ['Error', 'RangeError', 'SessionExpiredError', 'StationBusyError', 'TypeError'];
70
+ const SDK_ERROR_TYPES = [
71
+ 'Error',
72
+ 'RangeError',
73
+ 'SessionExpiredError',
74
+ 'StationBusyError',
75
+ 'StationKeyUnavailableError',
76
+ 'StationUnreachableError',
77
+ 'TypeError',
78
+ ];
60
79
  /**
61
80
  * The retained fields that stand for a device rather than describing one, which is the privacy class a manifest
62
81
  * declares for them. Each holds a per-case alias: stable within one archive, and resolvable to a device only by
@@ -87,7 +106,12 @@ const DIAGNOSTICS_DIRECTORY = 'diagnostics';
87
106
  const GUIDED_SESSION_FILE = 'session.json';
88
107
  const REPRODUCTION_MARKERS_FILE = 'reproduction-markers.jsonl';
89
108
  const UI_EVENTS_FILE = 'ui-events.jsonl';
90
- const FFMPEG_ENVIRONMENT_FILE = 'ffmpeg.json';
109
+ const HOST_ENVIRONMENT_FILE = 'host.json';
110
+ const FLEET_FILE = 'fleet.json';
111
+ /** The longest device model or adapter key retained, which bounds values this plugin does not choose alone. */
112
+ const MAX_DEVICE_MODEL_LENGTH = 64;
113
+ /** How many HomeKit services one fleet entry names, which is above what any single device is represented by. */
114
+ const MAX_FLEET_SERVICES = 12;
91
115
  /** How much of an FFmpeg path or version banner is kept, which is more than either needs. */
92
116
  const MAX_FFMPEG_IDENTITY_LENGTH = 256;
93
117
  const DEBUG_AUTHORIZATION_MS = 72 * 60 * 60 * 1_000;
@@ -327,54 +351,135 @@ function boundedText(value, maximumLength) {
327
351
  .trim();
328
352
  return printable === '' ? undefined : printable;
329
353
  }
330
- function ffmpegEnvironmentPath(storageRoot) {
331
- return join(storageRoot, DIAGNOSTICS_DIRECTORY, FFMPEG_ENVIRONMENT_FILE);
354
+ function fleetPath(storageRoot) {
355
+ return join(storageRoot, DIAGNOSTICS_DIRECTORY, FLEET_FILE);
332
356
  }
333
357
  /**
334
- * Records which FFmpeg this run resolved, so a support archive states the build a failure came from.
358
+ * Records what the account's devices are and what each becomes, replacing the record whole.
335
359
  *
336
- * It is persisted rather than held in memory because the process that resolves it is not the one that
337
- * assembles an archive, and it is rewritten on every start so a path or binary that changed between runs
338
- * cannot be reported as the one in use. A write that fails is dropped: the archive then declares the
339
- * environment without an FFmpeg identity, which is honest, whereas failing startup over a diagnostic file
340
- * would cost the user their cameras.
360
+ * Written by the process that holds both the inventory and the accessory aliases, because an archive is
361
+ * assembled by another one. Rewritten on every complete inventory, so a device withdrawn or newly recognised
362
+ * cannot be reported as still present. A write that fails is dropped: an archive then declares no fleet, which
363
+ * is honest, whereas failing over a diagnostic file would cost the user their cameras.
341
364
  */
342
- export function recordFfmpegEnvironment(storageRoot, ffmpeg) {
365
+ export function recordFleet(storageRoot, fleet) {
343
366
  try {
344
- const path = ffmpegEnvironmentPath(storageRoot);
367
+ const path = fleetPath(storageRoot);
345
368
  mkdirSync(dirname(path), { mode: 0o700, recursive: true });
346
- writeFileSync(path, `${JSON.stringify({ version: 1, ffmpeg })}\n`, { mode: 0o600 });
369
+ writeFileSync(path, `${JSON.stringify({ version: 1, devices: fleet })}\n`, { mode: 0o600 });
347
370
  }
348
371
  catch { }
349
372
  }
373
+ /** Reads the recorded fleet, keeping each entry whose own fields narrow and dropping each that does not. */
374
+ function readFleet(storageRoot) {
375
+ try {
376
+ const candidate = JSON.parse(readFileSync(fleetPath(storageRoot), 'utf8'));
377
+ if (candidate.version !== 1 || !Array.isArray(candidate.devices)) {
378
+ return undefined;
379
+ }
380
+ const devices = candidate.devices.flatMap((entry) => {
381
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
382
+ return [];
383
+ const row = entry;
384
+ const accessory = opaqueAccessoryAlias(row.accessory);
385
+ if (accessory === undefined || typeof row.represented !== 'boolean' || typeof row.attached !== 'boolean') {
386
+ return [];
387
+ }
388
+ const model = typeof row.model === 'string' ? boundedText(row.model, MAX_DEVICE_MODEL_LENGTH) : undefined;
389
+ const services = (Array.isArray(row.services) ? row.services : [])
390
+ .filter((service) => typeof service === 'string')
391
+ .slice(0, MAX_FLEET_SERVICES)
392
+ .flatMap((service) => {
393
+ const bounded = boundedText(service, MAX_DEVICE_MODEL_LENGTH);
394
+ return bounded === undefined ? [] : [bounded];
395
+ });
396
+ return [
397
+ {
398
+ accessory,
399
+ ...(model === undefined ? {} : { model }),
400
+ represented: row.represented,
401
+ attached: row.attached,
402
+ services,
403
+ },
404
+ ];
405
+ });
406
+ return devices.length === 0 ? undefined : devices.slice(0, MAX_ACCESSORY_ALIASES);
407
+ }
408
+ catch {
409
+ return undefined;
410
+ }
411
+ }
412
+ function hostEnvironmentPath(storageRoot) {
413
+ return join(storageRoot, DIAGNOSTICS_DIRECTORY, HOST_ENVIRONMENT_FILE);
414
+ }
415
+ /** The longest host version string retained, which bounds a value this plugin does not choose. */
416
+ const MAX_HOST_VERSION_LENGTH = 64;
350
417
  /**
351
- * Reads the recorded adaptation binary, refusing a record whose own fields do not narrow.
418
+ * Records what this run is hosted by, so a support archive states the host a failure came from.
352
419
  *
353
- * The path and the banner are kept as written rather than pattern-replaced, because naming the binary is the
354
- * entire purpose of the record and a redacted one answers nothing. Neither is device or account material: the
355
- * path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
420
+ * It is persisted rather than held in memory because the process that knows these facts is not the one that
421
+ * assembles an archive, and it is rewritten on every start so a host or binary that changed between runs
422
+ * cannot be reported as the one in use. Each call replaces the record whole, so a caller states everything it
423
+ * knows rather than accumulating into a file two writers would race for.
424
+ *
425
+ * A write that fails is dropped: the archive then declares the environment without these facts, which is
426
+ * honest, whereas failing startup over a diagnostic file would cost the user their cameras.
356
427
  */
357
- function readFfmpegEnvironment(storageRoot) {
428
+ export function recordHostEnvironment(storageRoot, host) {
358
429
  try {
359
- const candidate = JSON.parse(readFileSync(ffmpegEnvironmentPath(storageRoot), 'utf8'));
360
- const ffmpeg = candidate.ffmpeg;
361
- if (candidate.version !== 1 || !ffmpeg || typeof ffmpeg !== 'object' || Array.isArray(ffmpeg)) {
430
+ const path = hostEnvironmentPath(storageRoot);
431
+ mkdirSync(dirname(path), { mode: 0o700, recursive: true });
432
+ writeFileSync(path, `${JSON.stringify({ version: 2, ...host })}\n`, { mode: 0o600 });
433
+ }
434
+ catch { }
435
+ }
436
+ /**
437
+ * Reads what this run is hosted by, keeping each fact that narrows and dropping each that does not.
438
+ *
439
+ * The facts are independent, so a malformed binary identity does not withhold a host version, and a record
440
+ * stating neither is no record at all.
441
+ */
442
+ function readHostEnvironment(storageRoot) {
443
+ try {
444
+ const candidate = JSON.parse(readFileSync(hostEnvironmentPath(storageRoot), 'utf8'));
445
+ if (candidate.version !== 2) {
362
446
  return undefined;
363
447
  }
364
- const recorded = ffmpeg;
365
- if (typeof recorded.path !== 'string' || (recorded.source !== 'bundled' && recorded.source !== 'configured')) {
448
+ const homebridge = typeof candidate.homebridge === 'string' ? boundedText(candidate.homebridge, MAX_HOST_VERSION_LENGTH) : undefined;
449
+ const ffmpeg = readFfmpegIdentity(candidate.ffmpeg);
450
+ if (homebridge === undefined && ffmpeg === undefined) {
366
451
  return undefined;
367
452
  }
368
- const path = boundedText(recorded.path, MAX_FFMPEG_IDENTITY_LENGTH);
369
- const version = typeof recorded.version === 'string' ? boundedText(recorded.version, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
370
- return path === undefined
371
- ? undefined
372
- : { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
453
+ return {
454
+ ...(homebridge === undefined ? {} : { homebridge }),
455
+ ...(ffmpeg === undefined ? {} : { ffmpeg }),
456
+ };
373
457
  }
374
458
  catch {
375
459
  return undefined;
376
460
  }
377
461
  }
462
+ /**
463
+ * The recorded adaptation binary, or nothing where the record's own fields do not narrow.
464
+ *
465
+ * The path and the banner are kept as written rather than pattern-replaced, because naming the binary is the
466
+ * entire purpose of the record and a redacted one answers nothing. Neither is device or account material: the
467
+ * path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
468
+ */
469
+ function readFfmpegIdentity(value) {
470
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
471
+ return undefined;
472
+ }
473
+ const recorded = value;
474
+ if (recorded.source !== 'bundled' && recorded.source !== 'configured') {
475
+ return undefined;
476
+ }
477
+ const path = typeof recorded.path === 'string' ? boundedText(recorded.path, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
478
+ const version = typeof recorded.version === 'string' ? boundedText(recorded.version, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
479
+ return path === undefined
480
+ ? undefined
481
+ : { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
482
+ }
378
483
  /**
379
484
  * The one log evidence class a retained record belongs to.
380
485
  *
@@ -651,7 +756,8 @@ export class GuidedDiagnostics {
651
756
  }
652
757
  }
653
758
  async collectSupportEvidence(session) {
654
- const ffmpeg = readFfmpegEnvironment(this.storageRoot);
759
+ const host = readHostEnvironment(this.storageRoot);
760
+ const fleet = readFleet(this.storageRoot);
655
761
  const evidence = [
656
762
  ...(session.affectedDevices === undefined
657
763
  ? []
@@ -672,22 +778,45 @@ export class GuidedDiagnostics {
672
778
  ],
673
779
  },
674
780
  ]),
781
+ ...(fleet === undefined
782
+ ? []
783
+ : [
784
+ {
785
+ evidence: 'fleet',
786
+ privacyClass: 'operational',
787
+ contentType: 'application/json',
788
+ content: `${JSON.stringify({ version: 1, devices: fleet })}\n`,
789
+ /**
790
+ * What each device is and becomes, against the alias every other record about it carries. A model
791
+ * and an admission outcome are product facts rather than device identity; the alias is what makes
792
+ * an entry resolvable to a device, and only by the owner whose console paired it with a name.
793
+ */
794
+ fields: [
795
+ { field: 'version', privacyClass: 'operational' },
796
+ { field: 'devices', privacyClass: 'pseudonymous' },
797
+ ],
798
+ },
799
+ ]),
675
800
  {
676
801
  evidence: 'environment',
677
802
  privacyClass: 'operational',
678
803
  contentType: 'application/json',
679
- content: `${JSON.stringify({ version: 2, plugin: PLUGIN_VERSION, sdk: SDK_VERSION, node: process.version, platform: process.platform, arch: process.arch, ...(ffmpeg ? { ffmpeg } : {}) })}\n`,
804
+ 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`,
680
805
  /**
681
806
  * The record is operational and one field is classified above it: a resolved FFmpeg path is an
682
807
  * environment fact, but it can carry the home directory of the account Homebridge runs as, so it is
683
808
  * declared as diagnostic rather than presented alongside the host's own architecture.
809
+ *
810
+ * The Homebridge version is operational and load-bearing: this plugin requires Homebridge 2, and a
811
+ * host that predates it loads the plugin and then fails in ways a media fault cannot be told from.
684
812
  */
685
813
  fields: [
686
814
  ...['version', 'plugin', 'sdk', 'node', 'platform', 'arch'].map((field) => ({
687
815
  field,
688
816
  privacyClass: 'operational',
689
817
  })),
690
- ...(ffmpeg ? [{ field: 'ffmpeg', privacyClass: 'diagnostic' }] : []),
818
+ ...(host?.homebridge ? [{ field: 'homebridge', privacyClass: 'operational' }] : []),
819
+ ...(host?.ffmpeg ? [{ field: 'ffmpeg', privacyClass: 'diagnostic' }] : []),
691
820
  ],
692
821
  },
693
822
  ];
@@ -1223,6 +1352,14 @@ function allowlistedLabel(value, allowed) {
1223
1352
  * like any other field that fails its shape.
1224
1353
  */
1225
1354
  const MAX_STARTUP_WINDOW_MS = 60_000;
1355
+ /** Why a level-2 wait ended without a key, which is the whole of what such an ending states. */
1356
+ const LEVEL2_UNAVAILABLE_REASONS = [
1357
+ 'no-cipher-key',
1358
+ 'derivation-failed',
1359
+ 'not-negotiating',
1360
+ 'session-closed',
1361
+ 'grace-elapsed',
1362
+ ];
1226
1363
  /** The three phases whose only field is the action they report, which the union fixes at `start`. */
1227
1364
  const retainedStart = (candidate) => candidate.action === 'start' ? { action: 'start' } : undefined;
1228
1365
  /** A phase whose only field is one bounded integer, retained under the name the SDK declares for it. */
@@ -1268,7 +1405,49 @@ const LIVE_TRACE_PHASES = {
1268
1405
  'sequence-restart': retainedInteger('dataType', 3),
1269
1406
  'level2-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
1270
1407
  'level2-ready': retainedInteger('cipherId', 65535),
1271
- 'level2-absent': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
1408
+ 'session-connect-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
1409
+ 'session-connected': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
1410
+ 'session-unreachable': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
1411
+ /**
1412
+ * Why a level-2 wait ended without a key, and what it had to name.
1413
+ *
1414
+ * The reason is the record: a wait that ran out states how long it waited, and one answered without waiting
1415
+ * states nothing more, because the negotiation is one-shot per connection. Only a reason reached under a
1416
+ * negotiation has a cipher to name, so both durations are optional and each is retained where it is stated.
1417
+ */
1418
+ 'level2-unavailable': (c) => {
1419
+ const reason = allowlistedLabel(c.reason, LEVEL2_UNAVAILABLE_REASONS);
1420
+ if (!reason) {
1421
+ return undefined;
1422
+ }
1423
+ const cipherId = boundedInteger(c.cipherId, 65535);
1424
+ const waitedMs = boundedInteger(c.waitedMs, MAX_STARTUP_WINDOW_MS);
1425
+ return {
1426
+ reason,
1427
+ ...(cipherId === undefined ? {} : { cipherId }),
1428
+ ...(waitedMs === undefined ? {} : { waitedMs }),
1429
+ };
1430
+ },
1431
+ /** The cipher a station asked for, beside the one whose material answered it. */
1432
+ 'cipher-fallback': (c) => {
1433
+ const cipherId = boundedInteger(c.cipherId, 65535);
1434
+ const answeredCipherId = boundedInteger(c.answeredCipherId, 65535);
1435
+ return cipherId === undefined || answeredCipherId === undefined ? undefined : { cipherId, answeredCipherId };
1436
+ },
1437
+ 'level2-negotiating': retainedInteger('cipherId', 65535),
1438
+ /**
1439
+ * What a call resolved a station as, which decides what its failure means.
1440
+ *
1441
+ * An attached camera's media start has no unencrypted form, so the topology is what separates a key that
1442
+ * never arrived from a start that had no form to be sent in. The channel is an index on a base and the
1443
+ * administrator is a relation to the signed-in account: neither is an identity of its own.
1444
+ */
1445
+ 'station-resolved': (c) => {
1446
+ const topology = allowlistedLabel(c.topology, ['attached', 'own']);
1447
+ const stationAdmin = allowlistedLabel(c.stationAdmin, ['self', 'other', 'unstated']);
1448
+ const channel = boundedInteger(c.channel, MAX_STATION_CHANNEL);
1449
+ return topology && stationAdmin && channel !== undefined ? { topology, channel, stationAdmin } : undefined;
1450
+ },
1272
1451
  /**
1273
1452
  * How long a connection's path has answered nothing, which is the station stating the path is gone.
1274
1453
  *
@@ -1276,6 +1455,19 @@ const LIVE_TRACE_PHASES = {
1276
1455
  * silence longer than that belongs to a session nothing is waiting on.
1277
1456
  */
1278
1457
  'path-stale': retainedInteger('silentMs', MAX_STARTUP_WINDOW_MS),
1458
+ /**
1459
+ * How long a camera's own channel delivered nothing, and whether the station was asked to serve it again.
1460
+ *
1461
+ * Both halves are the record: a station re-tasked to a sibling is repaired by the re-assert, and a pull
1462
+ * nothing is attached to declines it so the station is left with the camera someone is watching. A picture
1463
+ * that stopped advancing while no such record exists stopped for a reason the station's attention does not
1464
+ * reach.
1465
+ */
1466
+ 'channel-silent': (c) => {
1467
+ const silentMs = boundedInteger(c.silentMs, MAX_STARTUP_WINDOW_MS);
1468
+ const outcome = allowlistedLabel(c.outcome, ['declined', 'reasserted']);
1469
+ return silentMs !== undefined && outcome ? { silentMs, outcome } : undefined;
1470
+ },
1279
1471
  'media-command-unsent': (c) => {
1280
1472
  const reason = allowlistedLabel(c.reason, ['level2-key', 'address']);
1281
1473
  return reason ? { reason } : undefined;
@@ -1486,6 +1678,7 @@ const REASONS = new Set([
1486
1678
  'source-input-unstable',
1487
1679
  'source-stopped',
1488
1680
  'source-unavailable',
1681
+ 'station-busy',
1489
1682
  'stored-download-failed',
1490
1683
  'stored-failed',
1491
1684
  'stored-invalid-image',
@@ -1566,6 +1759,13 @@ const ADAPTATION_EVENTS = new Set([
1566
1759
  'exited-while-streaming',
1567
1760
  'output',
1568
1761
  ]);
1762
+ /**
1763
+ * Why a process was started, on the record that reports it starting.
1764
+ *
1765
+ * A replacement carries the identity of the stream the previous process was already sending on, so what it
1766
+ * started mid-flight is judged against what that one left. A first adaptation has nothing behind it.
1767
+ */
1768
+ const ADAPTATION_CAUSES = ['first', 'controller-selection', 'source-configuration'];
1569
1769
  /**
1570
1770
  * Which of those events is a failure, and so the ones a record is levelled `warn` for.
1571
1771
  *
@@ -1832,6 +2032,10 @@ function sanitizeStructuredEvent(message) {
1832
2032
  return { scope: 'media-notice', level: notice.level, code: value.code, messageKey: notice.messageKey };
1833
2033
  }
1834
2034
  if (value.scope === 'homekit') {
2035
+ if (value.event === 'station-claim') {
2036
+ const claim = sanitizeStationClaim(value);
2037
+ return claim ? { scope: 'homekit', level: 'debug', ...claim } : undefined;
2038
+ }
1835
2039
  if (value.adapter === 'camera.streaming' && value.event === 'live-video-selected') {
1836
2040
  const selection = sanitizeLiveVideoSelection(value);
1837
2041
  return selection ? { scope: 'homekit', level: 'debug', ...selection } : undefined;
@@ -1980,6 +2184,36 @@ export function reportInvalidSnapshotCache(target) {
1980
2184
  * `aliasFor` resolves the trace's device identity to the support-case alias the conditions about that accessory
1981
2185
  * already carry. The identity itself never reaches the record: an unresolved one is recorded as no accessory.
1982
2186
  */
2187
+ /**
2188
+ * Narrows one offered station arbitration decision, or nothing where its own labels do not narrow.
2189
+ *
2190
+ * Read by both halves of the path — {@link reportStationClaim} builds the record and the file sink rebuilds
2191
+ * it — because a record only one of them recognises is written to a console nobody is reading and dropped
2192
+ * before it reaches a support archive.
2193
+ */
2194
+ function sanitizeStationClaim(value) {
2195
+ const action = allowlistedLabel(value.action, STATION_CLAIM_ACTIONS);
2196
+ const claim = allowlistedLabel(value.claim, STATION_CLAIMS);
2197
+ if (!action || !claim) {
2198
+ return undefined;
2199
+ }
2200
+ const against = allowlistedLabel(value.displaced ?? value.to ?? value.by ?? value.against, STATION_CLAIMS);
2201
+ return { event: 'station-claim', action, claim, ...(against === undefined ? {} : { against }) };
2202
+ }
2203
+ /**
2204
+ * Records one arbitration decision over a station's single live channel as `homekit-log` evidence.
2205
+ *
2206
+ * A station serves one camera at a time and the SDK refuses a second outright, so a refused live request is
2207
+ * attributable either to this plugin's own policy or to the station, and the two are indistinguishable
2208
+ * afterwards without this. The record carries the claims and the decision alone: a station identity is a
2209
+ * serial, and the camera holding it is already named by the records about that camera.
2210
+ */
2211
+ export function reportStationClaim(target, decision) {
2212
+ const claim = sanitizeStationClaim(decision);
2213
+ if (target.debug && claim) {
2214
+ target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...claim }));
2215
+ }
2216
+ }
1983
2217
  export function reportHomeKitEvent(target, trace, aliasFor) {
1984
2218
  const alias = typeof trace.serial === 'string' ? aliasFor?.(trace.serial) : undefined;
1985
2219
  const offered = { ...trace, accessory: alias };
@@ -2121,11 +2355,13 @@ function sanitizeAdaptationNotice(value, level) {
2121
2355
  .filter((line) => line !== undefined)
2122
2356
  .slice(-MAX_ADAPTATION_STDERR_LINES);
2123
2357
  const sourceFragments = nonNegativeInteger(value.sourceFragments);
2358
+ const cause = allowlistedLabel(value.cause, ADAPTATION_CAUSES);
2124
2359
  return {
2125
2360
  scope: 'ffmpeg',
2126
2361
  level,
2127
2362
  role: value.role,
2128
2363
  event: value.event,
2364
+ ...(cause === undefined ? {} : { cause }),
2129
2365
  ...(code === undefined ? {} : { code }),
2130
2366
  ...(signal === undefined ? {} : { signal }),
2131
2367
  ...(sourceFragments === undefined ? {} : { sourceFragments }),
@@ -2140,7 +2376,19 @@ function sanitizeLiveSessionTrace(value) {
2140
2376
  const accessory = alias === undefined ? {} : { accessory: alias };
2141
2377
  if (value.event === 'live-session-released') {
2142
2378
  const release = allowlistedLabel(value.release, LIVE_SESSION_RELEASES);
2143
- return release ? { adapter: value.adapter, event: value.event, release, ...accessory } : undefined;
2379
+ if (!release) {
2380
+ return undefined;
2381
+ }
2382
+ const reports = nonNegativeInteger(value.reports);
2383
+ const sinceLastReportMs = boundedInteger(value.sinceLastReportMs, MAX_STARTUP_WINDOW_MS);
2384
+ return {
2385
+ adapter: value.adapter,
2386
+ event: value.event,
2387
+ release,
2388
+ ...(reports === undefined ? {} : { reports }),
2389
+ ...(sinceLastReportMs === undefined ? {} : { sinceLastReportMs }),
2390
+ ...accessory,
2391
+ };
2144
2392
  }
2145
2393
  if (value.event === 'live-session-streaming') {
2146
2394
  return { adapter: value.adapter, event: value.event, ...accessory };
@@ -2163,11 +2411,13 @@ function sanitizeLiveSessionTrace(value) {
2163
2411
  !HOMEKIT_LIVE_SESSION_STAGES.has(value.stage)) {
2164
2412
  return undefined;
2165
2413
  }
2414
+ const servingChannel = boundedInteger(value.servingChannel, MAX_STATION_CHANNEL);
2166
2415
  return {
2167
2416
  adapter: value.adapter,
2168
2417
  event: value.event,
2169
2418
  outcome: value.outcome,
2170
2419
  reason: value.reason,
2420
+ ...(servingChannel === undefined ? {} : { servingChannel }),
2171
2421
  stage: value.stage,
2172
2422
  ...accessory,
2173
2423
  };