@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.35 → 5.0.0-beta.36

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 (86) hide show
  1. package/CONTRIBUTING.md +9 -17
  2. package/config.schema.json +6 -0
  3. package/dist/account/ownership.js +16 -16
  4. package/dist/account/ownership.js.map +1 -1
  5. package/dist/account/persistence.js +24 -74
  6. package/dist/account/persistence.js.map +1 -1
  7. package/dist/account/temporary-authentication.js +2 -2
  8. package/dist/account/temporary-authentication.js.map +1 -1
  9. package/dist/configuration.js +18 -25
  10. package/dist/configuration.js.map +1 -1
  11. package/dist/device/member-evidence.js +1 -1
  12. package/dist/device/member-evidence.js.map +1 -1
  13. package/dist/device/member-trust.js +14 -32
  14. package/dist/device/member-trust.js.map +1 -1
  15. package/dist/device/snapshot.js +5 -17
  16. package/dist/device/snapshot.js.map +1 -1
  17. package/dist/diagnostics.js +246 -300
  18. package/dist/diagnostics.js.map +1 -1
  19. package/dist/homekit/adapter.js +4 -1
  20. package/dist/homekit/adapter.js.map +1 -1
  21. package/dist/homekit/adapters/battery.js +3 -3
  22. package/dist/homekit/adapters/battery.js.map +1 -1
  23. package/dist/homekit/adapters/camera-controls.js +15 -37
  24. package/dist/homekit/adapters/camera-controls.js.map +1 -1
  25. package/dist/homekit/adapters/camera-streaming.js +9 -19
  26. package/dist/homekit/adapters/camera-streaming.js.map +1 -1
  27. package/dist/homekit/adapters/contact.js +5 -5
  28. package/dist/homekit/adapters/contact.js.map +1 -1
  29. package/dist/homekit/adapters/doorbell.js +3 -15
  30. package/dist/homekit/adapters/doorbell.js.map +1 -1
  31. package/dist/homekit/adapters/lock.js +15 -214
  32. package/dist/homekit/adapters/lock.js.map +1 -1
  33. package/dist/homekit/adapters/motion.js +2 -2
  34. package/dist/homekit/adapters/motion.js.map +1 -1
  35. package/dist/homekit/adapters/package-sensor.js +2 -2
  36. package/dist/homekit/adapters/package-sensor.js.map +1 -1
  37. package/dist/homekit/adapters/security-system.js +11 -211
  38. package/dist/homekit/adapters/security-system.js.map +1 -1
  39. package/dist/homekit/adapters/siren.js +5 -33
  40. package/dist/homekit/adapters/siren.js.map +1 -1
  41. package/dist/homekit/adapters/smart-light.js +19 -217
  42. package/dist/homekit/adapters/smart-light.js.map +1 -1
  43. package/dist/homekit/coverage-matrix.js +8 -65
  44. package/dist/homekit/coverage-matrix.js.map +1 -1
  45. package/dist/homekit/device-control.js +249 -13
  46. package/dist/homekit/device-control.js.map +1 -1
  47. package/dist/homekit/reconciler.js +5 -5
  48. package/dist/homekit/reconciler.js.map +1 -1
  49. package/dist/media/last-successful-image.js +3 -20
  50. package/dist/media/last-successful-image.js.map +1 -1
  51. package/dist/media/live-preview.js +163 -42
  52. package/dist/media/live-preview.js.map +1 -1
  53. package/dist/media/live-stream.js +108 -67
  54. package/dist/media/live-stream.js.map +1 -1
  55. package/dist/media/recording.js +37 -120
  56. package/dist/media/recording.js.map +1 -1
  57. package/dist/media/snapshot.js +1 -9
  58. package/dist/media/snapshot.js.map +1 -1
  59. package/dist/media/station-live-sessions.js +0 -15
  60. package/dist/media/station-live-sessions.js.map +1 -1
  61. package/dist/platform.js +10 -10
  62. package/dist/platform.js.map +1 -1
  63. package/dist/runtime/channel.js +91 -69
  64. package/dist/runtime/channel.js.map +1 -1
  65. package/dist/runtime/owner.js +6 -6
  66. package/dist/runtime/owner.js.map +1 -1
  67. package/dist/runtime/sdk-client.js +3 -14
  68. package/dist/runtime/sdk-client.js.map +1 -1
  69. package/dist/runtime/tracker.js +8 -38
  70. package/dist/runtime/tracker.js.map +1 -1
  71. package/dist/storage.js +1 -1
  72. package/dist/storage.js.map +1 -1
  73. package/dist/ui/runtime-channel-client.js +145 -282
  74. package/dist/ui/runtime-channel-client.js.map +1 -1
  75. package/dist/ui/server.js +39 -53
  76. package/dist/ui/server.js.map +1 -1
  77. package/homebridge-ui/public/app.css +150 -78
  78. package/homebridge-ui/public/i18n/en.json +14 -2
  79. package/homebridge-ui/public/i18n/fr.json +14 -2
  80. package/homebridge-ui/public/index.html +59 -10
  81. package/homebridge-ui/public/js/app.js +206 -42
  82. package/homebridge-ui/public/js/dashboard.js +4 -19
  83. package/package.json +3 -7
  84. package/homebridge-ui/public/js/legacy-settings.js +0 -37
  85. package/homebridge-ui/public/js/profile-wizard.js +0 -31
  86. package/homebridge-ui/public/js/setup-wizard.js +0 -52
@@ -417,6 +417,10 @@ function boundedText(value, maximumLength) {
417
417
  .trim();
418
418
  return printable === '' ? undefined : printable;
419
419
  }
420
+ /** Whether a value is a plain JSON object, which is the only shape a record crossing a process boundary has. */
421
+ function isRecord(value) {
422
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
423
+ }
420
424
  function fleetPath(storageRoot) {
421
425
  return join(storageRoot, DIAGNOSTICS_DIRECTORY, FLEET_FILE);
422
426
  }
@@ -443,10 +447,9 @@ function readFleet(storageRoot) {
443
447
  if (candidate.version !== 1 || !Array.isArray(candidate.devices)) {
444
448
  return undefined;
445
449
  }
446
- const devices = candidate.devices.flatMap((entry) => {
447
- if (!entry || typeof entry !== 'object' || Array.isArray(entry))
450
+ const devices = candidate.devices.flatMap((row) => {
451
+ if (!isRecord(row))
448
452
  return [];
449
- const row = entry;
450
453
  const accessory = opaqueAccessoryAlias(row.accessory);
451
454
  if (accessory === undefined || typeof row.represented !== 'boolean' || typeof row.attached !== 'boolean') {
452
455
  return [];
@@ -532,19 +535,16 @@ function readHostEnvironment(storageRoot) {
532
535
  * entire purpose of the record and a redacted one answers nothing. Neither is device or account material: the
533
536
  * path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
534
537
  */
535
- function readFfmpegIdentity(value) {
536
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
538
+ function readFfmpegIdentity(recorded) {
539
+ if (!isRecord(recorded)) {
537
540
  return undefined;
538
541
  }
539
- const recorded = value;
540
542
  if (recorded.source !== 'bundled' && recorded.source !== 'configured') {
541
543
  return undefined;
542
544
  }
543
545
  const path = typeof recorded.path === 'string' ? boundedText(recorded.path, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
544
546
  const version = typeof recorded.version === 'string' ? boundedText(recorded.version, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
545
- const encoders = recorded.encoders && typeof recorded.encoders === 'object' && !Array.isArray(recorded.encoders)
546
- ? readEncoderQualification(recorded.encoders)
547
- : undefined;
547
+ const encoders = isRecord(recorded.encoders) ? readEncoderQualification(recorded.encoders) : undefined;
548
548
  return path === undefined
549
549
  ? undefined
550
550
  : {
@@ -556,18 +556,23 @@ function readFfmpegIdentity(value) {
556
556
  }
557
557
  /**
558
558
  * The recorded encoder qualification, keeping only encoder names, `profile@level` selections and outcomes this
559
- * build names, so the record is bounded by those three closed sets.
559
+ * build names, so the record is bounded by those three closed sets, and a `bytes` outcome's two integers.
560
560
  */
561
561
  function readEncoderQualification(recorded) {
562
562
  return Object.fromEntries(LIVE_VIDEO_ENCODERS.filter((name) => Object.hasOwn(recorded, name)).map((name) => {
563
- const outcomes = recorded[name] && typeof recorded[name] === 'object' ? Object.entries(recorded[name]) : [];
563
+ const outcomes = isRecord(recorded[name]) ? Object.entries(recorded[name]) : [];
564
564
  return [
565
565
  name,
566
566
  Object.fromEntries(outcomes.flatMap(([selection, value]) => {
567
567
  const [profile, level] = selection.split('@');
568
- const outcome = allowlistedLabel(value, QUALIFICATION_OUTCOMES);
569
- return HOMEKIT_LIVE_VIDEO_PROFILES.has(profile) &&
570
- HOMEKIT_LIVE_VIDEO_LEVELS.has(level) &&
568
+ const outcome = Array.isArray(value) &&
569
+ value.length === 3 &&
570
+ value[0] === 'bytes' &&
571
+ value.slice(1).every(Number.isSafeInteger)
572
+ ? value
573
+ : allowlistedLabel(value, QUALIFICATION_OUTCOMES);
574
+ return allowlistedLabel(profile, HOMEKIT_LIVE_VIDEO_PROFILES) &&
575
+ allowlistedLabel(level, HOMEKIT_LIVE_VIDEO_LEVELS) &&
571
576
  selection === `${profile}@${level}` &&
572
577
  outcome
573
578
  ? [[selection, outcome]]
@@ -1231,8 +1236,8 @@ export class GuidedDiagnostics {
1231
1236
  await this.replaceOwnedFile(join(directory, REPRODUCTION_MARKERS_FILE), `${retained}${marker}`);
1232
1237
  }
1233
1238
  }
1234
- function formatMessage(catalog, key, parameters = {}) {
1235
- const template = catalog[key] ?? EN_MESSAGES[key] ?? key;
1239
+ function formatMessage(key, parameters = {}) {
1240
+ const template = EN_MESSAGES[key] ?? key;
1236
1241
  return template
1237
1242
  .replace(/\{([a-zA-Z0-9]+)\}/g, (placeholder, name) => parameters[name] === undefined ? placeholder : String(parameters[name]))
1238
1243
  .replace(/\s+/g, ' ')
@@ -1240,7 +1245,7 @@ function formatMessage(catalog, key, parameters = {}) {
1240
1245
  }
1241
1246
  function localize(target, key, parameters = {}) {
1242
1247
  const translator = target.localize;
1243
- return translator?.(key, parameters) ?? formatMessage(EN_MESSAGES, key, parameters);
1248
+ return translator?.(key, parameters) ?? formatMessage(key, parameters);
1244
1249
  }
1245
1250
  class JsonLineLog {
1246
1251
  onError;
@@ -1257,11 +1262,8 @@ class JsonLineLog {
1257
1262
  this.directory = join(storageRoot, LOG_DIRECTORY);
1258
1263
  this.path = join(this.directory, LOG_FILE);
1259
1264
  }
1260
- write(message) {
1261
- const payload = sanitizeStructuredEvent(message);
1262
- if (!payload) {
1263
- return;
1264
- }
1265
+ /** Queues one record the sanitizer already rebuilt. */
1266
+ write(payload) {
1265
1267
  const record = this.serialize(payload);
1266
1268
  const bytes = Buffer.byteLength(record);
1267
1269
  if (bytes > MAX_LOG_RECORD_BYTES || this.queuedBytes + bytes > MAX_QUEUED_LOG_BYTES) {
@@ -1269,21 +1271,12 @@ class JsonLineLog {
1269
1271
  this.droppedRecords += 1;
1270
1272
  return;
1271
1273
  }
1272
- const droppedRecords = this.droppedRecords;
1273
- const droppedAt = this.droppedAt;
1274
- this.droppedRecords = 0;
1275
- this.droppedAt = undefined;
1274
+ const dropped = this.drainDropped();
1276
1275
  this.queuedBytes += bytes;
1277
1276
  this.pending = this.pending
1278
1277
  .then(async () => {
1279
- if (droppedRecords > 0) {
1280
- await this.append(this.serialize({
1281
- scope: 'diagnostics',
1282
- level: 'warn',
1283
- event: 'records-dropped',
1284
- droppedRecords,
1285
- }, droppedAt));
1286
- }
1278
+ if (dropped)
1279
+ await this.append(dropped);
1287
1280
  await this.append(record);
1288
1281
  })
1289
1282
  .catch(() => this.onError())
@@ -1292,22 +1285,21 @@ class JsonLineLog {
1292
1285
  });
1293
1286
  }
1294
1287
  flush() {
1295
- const droppedRecords = this.droppedRecords;
1296
- const droppedAt = this.droppedAt;
1297
- this.droppedRecords = 0;
1298
- this.droppedAt = undefined;
1299
- if (droppedRecords > 0) {
1300
- this.pending = this.pending
1301
- .then(() => this.append(this.serialize({
1302
- scope: 'diagnostics',
1303
- level: 'warn',
1304
- event: 'records-dropped',
1305
- droppedRecords,
1306
- }, droppedAt)))
1307
- .catch(() => this.onError());
1288
+ const dropped = this.drainDropped();
1289
+ if (dropped) {
1290
+ this.pending = this.pending.then(() => this.append(dropped)).catch(() => this.onError());
1308
1291
  }
1309
1292
  return this.pending;
1310
1293
  }
1294
+ /** The `records-dropped` record for what was refused since the last one, resetting the tally, or nothing. */
1295
+ drainDropped() {
1296
+ const { droppedRecords, droppedAt } = this;
1297
+ this.droppedRecords = 0;
1298
+ this.droppedAt = undefined;
1299
+ return droppedRecords === 0
1300
+ ? undefined
1301
+ : this.serialize({ scope: 'diagnostics', level: 'warn', event: 'records-dropped', droppedRecords }, droppedAt);
1302
+ }
1311
1303
  serialize(payload, timestamp = new Date(this.now()).toISOString()) {
1312
1304
  return `${JSON.stringify({ ...payload, timestamp })}\n`;
1313
1305
  }
@@ -1366,12 +1358,12 @@ class JsonLineLog {
1366
1358
  * Routes human messages to Homebridge and structured events to the plugin JSONL log.
1367
1359
  * File writes preserve accepted order; records beyond the bounded pending queue are counted and reported.
1368
1360
  */
1369
- export function createDiagnosticLogger(target, storageRoot, catalog = EN_MESSAGES, now = Date.now) {
1361
+ export function createDiagnosticLogger(target, storageRoot, now = Date.now) {
1370
1362
  let fileFailureReported = false;
1371
1363
  const reportFileFailure = () => {
1372
1364
  if (!fileFailureReported) {
1373
1365
  fileFailureReported = true;
1374
- target.warn(`[diagnostic-log-write-failed] ${formatMessage(catalog, 'log.diagnosticFileFailed')}`);
1366
+ target.warn(`[diagnostic-log-write-failed] ${formatMessage('log.diagnosticFileFailed')}`);
1375
1367
  }
1376
1368
  };
1377
1369
  const file = storageRoot ? new JsonLineLog(storageRoot, reportFileFailure, now) : undefined;
@@ -1385,7 +1377,7 @@ export function createDiagnosticLogger(target, storageRoot, catalog = EN_MESSAGE
1385
1377
  if (verbose && (!session || !DIAGNOSTICS_PROFILES[session.profile].includes(evidenceClassOf(event)))) {
1386
1378
  return;
1387
1379
  }
1388
- file.write(JSON.stringify(event));
1380
+ file.write(event);
1389
1381
  }
1390
1382
  : undefined;
1391
1383
  return {
@@ -1393,7 +1385,7 @@ export function createDiagnosticLogger(target, storageRoot, catalog = EN_MESSAGE
1393
1385
  error: (message) => target.error(message),
1394
1386
  flush: () => file?.flush() ?? Promise.resolve(),
1395
1387
  info: (message) => target.info(message),
1396
- localize: (key, parameters) => formatMessage(catalog, key, parameters),
1388
+ localize: (key, parameters) => formatMessage(key, parameters),
1397
1389
  warn: (message) => target.warn(message),
1398
1390
  };
1399
1391
  }
@@ -1531,6 +1523,7 @@ const LIVE_TRACE_PHASES = {
1531
1523
  'sequence-restart': retainedInteger('dataType', 3),
1532
1524
  'level2-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
1533
1525
  'level2-ready': retainedInteger('cipherId', 65535),
1526
+ 'level2-set-payload-only': retainedInteger('cipherId', 65535),
1534
1527
  'session-connect-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
1535
1528
  'session-connected': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
1536
1529
  'session-unreachable': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
@@ -1622,16 +1615,22 @@ const LIVE_TRACE_PHASES = {
1622
1615
  const reason = allowlistedLabel(c.reason, ['level2-key', 'address']);
1623
1616
  return reason ? { reason } : undefined;
1624
1617
  },
1618
+ /** `errorName` is an upstream class name, so it stays out of the archive; reason and stage say enough. */
1619
+ 'start-failed': (c) => {
1620
+ const reason = allowlistedLabel(c.reason, ['warm-timeout', 'source-error', 'source-ended']);
1621
+ const stage = allowlistedLabel(c.stage, ['awaiting-first-frame', 'audio-only', 'awaiting-keyframe']);
1622
+ const attempts = boundedInteger(c.attempts, 1000);
1623
+ return reason && stage && attempts !== undefined ? { reason, stage, attempts } : undefined;
1624
+ },
1625
1625
  warming: (c) => {
1626
1626
  const retryMs = boundedInteger(c.retryMs, MAX_STARTUP_WINDOW_MS);
1627
1627
  const deadlineMs = boundedInteger(c.deadlineMs, MAX_STARTUP_WINDOW_MS);
1628
1628
  return retryMs === undefined || deadlineMs === undefined ? undefined : { retryMs, deadlineMs };
1629
1629
  },
1630
1630
  };
1631
- function sanitizeSdkLiveStartTrace(value) {
1632
- if (!value || typeof value !== 'object' || Array.isArray(value))
1631
+ function sanitizeSdkLiveStartTrace(candidate) {
1632
+ if (!isRecord(candidate))
1633
1633
  return undefined;
1634
- const candidate = value;
1635
1634
  const phase = String(candidate.phase);
1636
1635
  if (!Object.hasOwn(LIVE_TRACE_PHASES, phase))
1637
1636
  return undefined;
@@ -1885,7 +1884,7 @@ const HOMEKIT_EVENT_ROUTES = {
1885
1884
  'camera.streaming': new Set(['camera-enabled-changed']),
1886
1885
  'camera.controls': new Set(['camera-enabled-changed']),
1887
1886
  };
1888
- const HOMEKIT_OBSERVATIONS = new Set(['malformed', 'missing', 'valid']);
1887
+ const HOMEKIT_OBSERVATIONS = new Set(['malformed', 'missing', 'valid', 'ignored']);
1889
1888
  /**
1890
1889
  * What announced a change, for an adapter that follows more than one announcement for the same state.
1891
1890
  *
@@ -1893,9 +1892,9 @@ const HOMEKIT_OBSERVATIONS = new Set(['malformed', 'missing', 'valid']);
1893
1892
  * field is omitted where an adapter states no announcement and refused where it states one absent from here.
1894
1893
  */
1895
1894
  const HOMEKIT_ANNOUNCEMENTS = new Set(['write', 'poll']);
1896
- const HOMEKIT_LIVE_VIDEO_OPERATIONS = new Set(['start', 'reconfigure']);
1897
- const HOMEKIT_LIVE_VIDEO_PROFILES = new Set(['baseline', 'main', 'high']);
1898
- const HOMEKIT_LIVE_VIDEO_LEVELS = new Set(['3.1', '3.2', '4.0']);
1895
+ const HOMEKIT_LIVE_VIDEO_OPERATIONS = ['start', 'reconfigure'];
1896
+ const HOMEKIT_LIVE_VIDEO_PROFILES = ['baseline', 'main', 'high'];
1897
+ const HOMEKIT_LIVE_VIDEO_LEVELS = ['3.1', '3.2', '4.0'];
1899
1898
  /**
1900
1899
  * The inclusive bounds a controller-selected live geometry and frame rate are retained within.
1901
1900
  *
@@ -1919,13 +1918,13 @@ const MAX_LIVE_VIDEO_MTU = 65_535;
1919
1918
  /** The bit rate ceiling a controller negotiated, in kbps, bounded to the unsigned 16-bit field that carries it. */
1920
1919
  const MAX_LIVE_VIDEO_BIT_RATE = 65_535;
1921
1920
  /** The two address families HomeKit negotiates a destination in. */
1922
- const HOMEKIT_LIVE_ADDRESS_VERSIONS = new Set(['ipv4', 'ipv6']);
1923
- const HOMEKIT_LIVE_SESSION_STAGES = new Set([
1921
+ const HOMEKIT_LIVE_ADDRESS_VERSIONS = ['ipv4', 'ipv6'];
1922
+ const HOMEKIT_LIVE_SESSION_STAGES = [
1924
1923
  'sdk-source-acquisition',
1925
1924
  'first-source-keyframe',
1926
1925
  'first-adapted-output',
1927
1926
  'controller-rtcp',
1928
- ]);
1927
+ ];
1929
1928
  /** The SDK's own stage and reason for a live warm-up that ended without a keyframe. */
1930
1929
  const SDK_LIVE_START_STAGES = ['awaiting-first-frame', 'audio-only', 'awaiting-keyframe'];
1931
1930
  const SDK_LIVE_START_REASONS = ['warm-timeout', 'source-error', 'source-ended'];
@@ -1935,7 +1934,16 @@ const SDK_LIVE_START_REASONS = ['warm-timeout', 'source-error', 'source-ended'];
1935
1934
  * A superset of what the media domain can report, because the SDK runs FFmpeg of its own for snapshot
1936
1935
  * decoding and WebRTC containers and forwards its output here under the same evidence class.
1937
1936
  */
1938
- const ADAPTATION_ROLES = new Set(['live-video', 'live-audio', 'return-audio', 'recording', 'sdk']);
1937
+ const ADAPTATION_ROLES = new Set([
1938
+ 'live-video',
1939
+ 'live-audio',
1940
+ 'return-audio',
1941
+ 'recording',
1942
+ 'preview-encode',
1943
+ 'preview-decode',
1944
+ 'preview-raw',
1945
+ 'sdk',
1946
+ ]);
1939
1947
  /** What that process did. `output` is a process reported for what it wrote rather than for how it ended. */
1940
1948
  const ADAPTATION_EVENTS = new Set([
1941
1949
  'started',
@@ -1946,6 +1954,16 @@ const ADAPTATION_EVENTS = new Set([
1946
1954
  'exited-while-streaming',
1947
1955
  'output',
1948
1956
  ]);
1957
+ /** How a settings-page live preview ended. */
1958
+ const PREVIEW_OUTCOMES = ['picture', 'unplayable', 'no-video', 'stopped'];
1959
+ /** The iPhone asks a live preview codes for, and the live video settings it may try. */
1960
+ const PREVIEW_ASKS = ['wifi', 'cellular'];
1961
+ const PREVIEW_SETTINGS = [
1962
+ 'smallVideoPackets',
1963
+ 'liveVideoPassthrough',
1964
+ 'hardwareVideoEncoder',
1965
+ 'ignoreHomeBitRate',
1966
+ ];
1949
1967
  /**
1950
1968
  * Why a process was started, on the record that reports it starting.
1951
1969
  *
@@ -1964,7 +1982,7 @@ const LIVE_VIDEO_ENCODERS = [
1964
1982
  'h264_vaapi',
1965
1983
  'h264_v4l2m2m',
1966
1984
  ];
1967
- /** What qualifying a hardware encoder for one selection found: `qualified`, or the first check it failed. */
1985
+ /** What qualifying a hardware encoder for one selection found, but `bytes`, which is read with its two integers. */
1968
1986
  const QUALIFICATION_OUTCOMES = [
1969
1987
  'qualified',
1970
1988
  'budget',
@@ -1974,7 +1992,6 @@ const QUALIFICATION_OUTCOMES = [
1974
1992
  'level',
1975
1993
  'geometry',
1976
1994
  'frames',
1977
- 'bytes',
1978
1995
  ];
1979
1996
  /** The codec a live video source delivers. */
1980
1997
  const LIVE_VIDEO_SOURCES = ['h264', 'h265', 'av1'];
@@ -2019,74 +2036,32 @@ const ADAPTATION_SIGNALS = new Set([
2019
2036
  const MAX_ADAPTATION_STDERR_LINES = 8;
2020
2037
  const MAX_ADAPTATION_STDERR_LINE_LENGTH = 200;
2021
2038
  /**
2022
- * The media notices this module retains, read by both halves of the path.
2039
+ * The fixed-shape operational notices, each `[level, messageKey, durationMessageKey?]`.
2023
2040
  *
2024
2041
  * Operational rather than verbose, so each is `plugin-log` evidence that every profile declares.
2025
2042
  */
2026
- const MEDIA_NOTICES = {
2027
- 'camera-snapshot-cache-invalid': {
2028
- level: 'warn',
2029
- messageKey: 'log.snapshotCacheInvalid',
2030
- },
2031
- };
2032
2043
  const RUNTIME_NOTICES = {
2033
- 'status-publication-failed': {
2034
- level: 'warn',
2035
- messageKey: 'log.notice.statusPublicationFailed',
2036
- },
2037
- 'ownership-release-not-finalized': {
2038
- level: 'error',
2039
- messageKey: 'log.notice.ownershipReleaseNotFinalized',
2040
- },
2041
- 'ownership-release-failed': {
2042
- level: 'error',
2043
- messageKey: 'log.notice.ownershipReleaseFailed',
2044
- },
2045
- 'shutdown-failed': {
2046
- level: 'error',
2047
- messageKey: 'log.notice.shutdownFailed',
2048
- },
2049
- 'shutdown-timeout': {
2050
- level: 'warn',
2051
- messageKey: 'log.notice.shutdownTimeout',
2052
- durationMessageKey: 'log.notice.shutdownTimeoutWithDuration',
2053
- },
2054
- 'ownership-acquisition-failed': {
2055
- level: 'error',
2056
- messageKey: 'log.notice.ownershipAcquisitionFailed',
2057
- },
2058
- 'ownership-release-incomplete': {
2059
- level: 'warn',
2060
- messageKey: 'log.notice.ownershipReleaseIncomplete',
2061
- },
2062
- 'registry-subscriber-failed': {
2063
- level: 'warn',
2064
- messageKey: 'log.notice.registrySubscriberFailed',
2065
- },
2066
- 'event-subscriber-failed': {
2067
- level: 'warn',
2068
- messageKey: 'log.notice.eventSubscriberFailed',
2069
- },
2070
- 'state-subscriber-failed': {
2071
- level: 'warn',
2072
- messageKey: 'log.notice.stateSubscriberFailed',
2073
- },
2074
- 'channel-serve-failed': {
2075
- level: 'warn',
2076
- messageKey: 'log.notice.channelServeFailed',
2077
- },
2078
- 'diagnostics-authorization-armed': {
2079
- level: 'info',
2080
- messageKey: 'log.notice.diagnosticsAuthorizationArmed',
2081
- },
2082
- 'stand-down-not-rearmed': {
2083
- level: 'warn',
2084
- messageKey: 'log.notice.standDownNotRearmed',
2085
- },
2086
- 'stand-down-account-replaced': {
2087
- level: 'warn',
2088
- messageKey: 'log.notice.standDownAccountReplaced',
2089
- },
2044
+ 'status-publication-failed': ['warn', 'log.notice.statusPublicationFailed'],
2045
+ 'ownership-release-not-finalized': ['error', 'log.notice.ownershipReleaseNotFinalized'],
2046
+ 'ownership-release-failed': ['error', 'log.notice.ownershipReleaseFailed'],
2047
+ 'shutdown-failed': ['error', 'log.notice.shutdownFailed'],
2048
+ 'shutdown-timeout': ['warn', 'log.notice.shutdownTimeout', 'log.notice.shutdownTimeoutWithDuration'],
2049
+ 'ownership-acquisition-failed': ['error', 'log.notice.ownershipAcquisitionFailed'],
2050
+ 'ownership-release-incomplete': ['warn', 'log.notice.ownershipReleaseIncomplete'],
2051
+ 'registry-subscriber-failed': ['warn', 'log.notice.registrySubscriberFailed'],
2052
+ 'event-subscriber-failed': ['warn', 'log.notice.eventSubscriberFailed'],
2053
+ 'state-subscriber-failed': ['warn', 'log.notice.stateSubscriberFailed'],
2054
+ 'channel-serve-failed': ['warn', 'log.notice.channelServeFailed'],
2055
+ 'diagnostics-authorization-armed': ['info', 'log.notice.diagnosticsAuthorizationArmed'],
2056
+ 'stand-down-not-rearmed': ['warn', 'log.notice.standDownNotRearmed'],
2057
+ 'stand-down-account-replaced': ['warn', 'log.notice.standDownAccountReplaced'],
2058
+ 'camera-snapshot-cache-invalid': ['warn', 'log.snapshotCacheInvalid'],
2059
+ 'discarded-v4-settings-unacknowledged': ['warn', 'log.discardedSettings'],
2060
+ };
2061
+ /** The scope a notice is recorded under where it is not `runtime-notice`. */
2062
+ const NOTICE_SCOPES = {
2063
+ 'camera-snapshot-cache-invalid': 'media-notice',
2064
+ 'discarded-v4-settings-unacknowledged': 'configuration-notice',
2090
2065
  };
2091
2066
  const RUNTIME_CONDITIONS = {
2092
2067
  degraded: {
@@ -2186,7 +2161,7 @@ const HOMEKIT_REASON_ACTIONS = {
2186
2161
  };
2187
2162
  /** Projects a value onto an active condition, or nothing where the catalog does not write that code with those keys. */
2188
2163
  export function knownCondition(value) {
2189
- if (typeof value !== 'object' || value === null || Array.isArray(value))
2164
+ if (!isRecord(value))
2190
2165
  return undefined;
2191
2166
  const { code, summaryKey, actionKey, serials } = value;
2192
2167
  if (typeof code !== 'string')
@@ -2223,14 +2198,13 @@ function homeKitConditionPresentation(code, active, reason) {
2223
2198
  function sanitizeStructuredEvent(message) {
2224
2199
  let value;
2225
2200
  try {
2226
- const parsed = JSON.parse(message);
2227
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
2228
- return undefined;
2229
- value = parsed;
2201
+ value = JSON.parse(message);
2230
2202
  }
2231
2203
  catch {
2232
2204
  return undefined;
2233
2205
  }
2206
+ if (!isRecord(value))
2207
+ return undefined;
2234
2208
  const level = allowlistedLabel(value.level, ['debug', 'info', 'warn', 'error']);
2235
2209
  if (!level || typeof value.scope !== 'string')
2236
2210
  return undefined;
@@ -2253,18 +2227,17 @@ function sanitizeStructuredEvent(message) {
2253
2227
  }
2254
2228
  const details = [];
2255
2229
  for (const detail of Array.isArray(value.details) ? value.details.slice(0, MAX_SDK_DETAILS) : []) {
2256
- if (!detail || typeof detail !== 'object' || Array.isArray(detail))
2230
+ if (!isRecord(detail))
2257
2231
  continue;
2258
- const candidate = detail;
2259
- const errorType = allowlistedLabel(candidate.errorType, SDK_ERROR_TYPES);
2232
+ const errorType = allowlistedLabel(detail.errorType, SDK_ERROR_TYPES);
2260
2233
  if (errorType) {
2261
2234
  details.push({ errorType });
2262
2235
  continue;
2263
2236
  }
2264
- const type = allowlistedLabel(candidate.type, ['boolean', 'number', 'object', 'string', 'undefined']);
2237
+ const type = allowlistedLabel(detail.type, ['boolean', 'number', 'object', 'string', 'undefined']);
2265
2238
  if (!type)
2266
2239
  continue;
2267
- const length = nonNegativeInteger(candidate.length);
2240
+ const length = nonNegativeInteger(detail.length);
2268
2241
  details.push({ type, ...(length === undefined ? {} : { length }) });
2269
2242
  }
2270
2243
  return {
@@ -2276,72 +2249,14 @@ function sanitizeStructuredEvent(message) {
2276
2249
  ...(value.detailsTruncated === true ? { detailsTruncated: true } : {}),
2277
2250
  };
2278
2251
  }
2279
- if (value.scope === 'runtime-notice') {
2252
+ if (value.scope === 'runtime-notice' || value.scope === 'media-notice' || value.scope === 'configuration-notice') {
2280
2253
  if (typeof value.code !== 'string' || !Object.hasOwn(RUNTIME_NOTICES, value.code))
2281
2254
  return undefined;
2282
- const notice = RUNTIME_NOTICES[value.code];
2283
- const durationMs = nonNegativeInteger(value.durationMs);
2284
- const messageKey = durationMs !== undefined && 'durationMessageKey' in notice ? notice.durationMessageKey : notice.messageKey;
2285
- return {
2286
- scope: 'runtime-notice',
2287
- level: notice.level,
2288
- code: value.code,
2289
- messageKey,
2290
- ...(durationMs === undefined ? {} : { durationMs }),
2291
- };
2292
- }
2293
- if (value.scope === 'configuration-notice') {
2294
- if (value.code !== 'discarded-v4-settings-unacknowledged')
2295
- return undefined;
2296
- return {
2297
- scope: 'configuration-notice',
2298
- level: 'warn',
2299
- code: value.code,
2300
- messageKey: 'log.discardedSettings',
2301
- };
2302
- }
2303
- if (value.scope === 'media-notice') {
2304
- if (typeof value.code !== 'string' || !Object.hasOwn(MEDIA_NOTICES, value.code))
2305
- return undefined;
2306
- const notice = MEDIA_NOTICES[value.code];
2307
- return { scope: 'media-notice', level: notice.level, code: value.code, messageKey: notice.messageKey };
2255
+ const notice = runtimeNoticeRecord(value.code, nonNegativeInteger(value.durationMs));
2256
+ return notice.scope === value.scope ? notice : undefined;
2308
2257
  }
2309
2258
  if (value.scope === 'homekit') {
2310
- if (value.event === 'event-unrouted') {
2311
- const unrouted = sanitizeUnroutedEvent(value);
2312
- return unrouted ? { scope: 'homekit', level: 'debug', ...unrouted } : undefined;
2313
- }
2314
- if (value.event === 'station-claim') {
2315
- const claim = sanitizeStationClaim(value);
2316
- return claim ? { scope: 'homekit', level: 'debug', ...claim } : undefined;
2317
- }
2318
- if (value.adapter === 'camera.streaming' && value.event === 'live-video-selected') {
2319
- const selection = sanitizeLiveVideoSelection(value);
2320
- return selection ? { scope: 'homekit', level: 'debug', ...selection } : undefined;
2321
- }
2322
- if (value.adapter === 'camera.streaming' && HOMEKIT_LIVE_REQUEST_EVENTS.has(String(value.event))) {
2323
- const lifecycle = sanitizeLiveSessionTrace(value);
2324
- return lifecycle ? { scope: 'homekit', level: 'debug', ...lifecycle } : undefined;
2325
- }
2326
- if (typeof value.adapter !== 'string' ||
2327
- typeof value.event !== 'string' ||
2328
- !HOMEKIT_EVENT_ROUTES[value.adapter]?.has(value.event) ||
2329
- typeof value.observation !== 'string' ||
2330
- !HOMEKIT_OBSERVATIONS.has(value.observation)) {
2331
- return undefined;
2332
- }
2333
- const alias = opaqueAccessoryAlias(value.accessory);
2334
- return {
2335
- scope: 'homekit',
2336
- level: 'debug',
2337
- adapter: value.adapter,
2338
- event: value.event,
2339
- observation: value.observation,
2340
- ...(typeof value.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(value.announcedBy)
2341
- ? { announcedBy: value.announcedBy }
2342
- : {}),
2343
- ...(alias === undefined ? {} : { accessory: alias }),
2344
- };
2259
+ return sanitizeHomeKitEvent(value);
2345
2260
  }
2346
2261
  if (value.scope === 'runtime') {
2347
2262
  const event = allowlistedLabel(value.event, ['ready', 'stopped']);
@@ -2352,6 +2267,10 @@ function sanitizeStructuredEvent(message) {
2352
2267
  if (value.scope === 'ffmpeg') {
2353
2268
  return sanitizeAdaptationNotice(value, level);
2354
2269
  }
2270
+ if (value.scope === 'live-preview') {
2271
+ const preview = sanitizeLivePreview(value);
2272
+ return preview ? { scope: 'live-preview', level: 'info', ...preview } : undefined;
2273
+ }
2355
2274
  if (value.scope === 'diagnostic-condition') {
2356
2275
  const homeKitDefinition = typeof value.code === 'string' && Object.hasOwn(HOMEKIT_CONDITIONS, value.code)
2357
2276
  ? HOMEKIT_CONDITIONS[value.code]
@@ -2386,7 +2305,7 @@ function sanitizeStructuredEvent(message) {
2386
2305
  const affectedAccessoryCount = nonNegativeInteger(value.affectedAccessoryCount);
2387
2306
  const accessoryAliases = Array.isArray(value.accessoryAliases)
2388
2307
  ? value.accessoryAliases
2389
- .filter((alias) => typeof alias === 'string' && /^accessory-[0-9a-f-]{36}$/.test(alias))
2308
+ .filter((alias) => opaqueAccessoryAlias(alias) !== undefined)
2390
2309
  .slice(0, MAX_ACCESSORY_ALIASES)
2391
2310
  : [];
2392
2311
  const { level: conditionLevel, actionKey } = homeKitConditionPresentation(value.code, value.active, value.reason);
@@ -2409,19 +2328,22 @@ function sanitizeStructuredEvent(message) {
2409
2328
  }
2410
2329
  return undefined;
2411
2330
  }
2412
- /** Emits one fixed-shape operational notice selected by its allowlisted runtime code. */
2413
- export function reportRuntimeNotice(target, code, fields = {}) {
2414
- const notice = RUNTIME_NOTICES[code];
2415
- const durationMs = fields.durationMs === undefined ? undefined : Math.max(0, Math.trunc(fields.durationMs));
2416
- const messageKey = durationMs !== undefined && 'durationMessageKey' in notice ? notice.durationMessageKey : notice.messageKey;
2417
- target[notice.level]?.(`[${code}] ${localize(target, messageKey, { durationMs: durationMs ?? 0, finishBy: fields.finishBy ?? '' })}`);
2418
- target.debug?.(JSON.stringify({
2419
- scope: 'runtime-notice',
2420
- level: notice.level,
2331
+ /** The record one notice is written as, read by both halves of the path. */
2332
+ function runtimeNoticeRecord(code, durationMs) {
2333
+ const [level, messageKey, durationMessageKey] = RUNTIME_NOTICES[code];
2334
+ return {
2335
+ scope: NOTICE_SCOPES[code] ?? 'runtime-notice',
2336
+ level,
2421
2337
  code,
2422
- messageKey,
2338
+ messageKey: durationMs !== undefined && durationMessageKey ? durationMessageKey : messageKey,
2423
2339
  ...(durationMs === undefined ? {} : { durationMs }),
2424
- }));
2340
+ };
2341
+ }
2342
+ /** Emits one fixed-shape operational notice selected by its allowlisted code. */
2343
+ export function reportRuntimeNotice(target, code, fields = {}) {
2344
+ const record = runtimeNoticeRecord(code, fields.durationMs === undefined ? undefined : Math.max(0, Math.trunc(fields.durationMs)));
2345
+ target[record.level]?.(`[${code}] ${localize(target, record.messageKey, { durationMs: record.durationMs ?? 0, finishBy: fields.finishBy ?? '' })}`);
2346
+ target.debug?.(JSON.stringify(record));
2425
2347
  }
2426
2348
  /**
2427
2349
  * Picks up an authorization the persisted session file confirms, reporting whether it did.
@@ -2442,18 +2364,6 @@ export function armDiagnosticsAuthorization(target, storageRoot, supportCaseId,
2442
2364
  });
2443
2365
  return true;
2444
2366
  }
2445
- /** Emits the startup notice for discarded settings awaiting acknowledgement. */
2446
- export function reportDiscardedV4Settings(target) {
2447
- const code = 'discarded-v4-settings-unacknowledged';
2448
- const messageKey = 'log.discardedSettings';
2449
- target.warn(`[${code}] ${localize(target, messageKey)}`);
2450
- target.debug?.(JSON.stringify({
2451
- scope: 'configuration-notice',
2452
- level: 'warn',
2453
- code,
2454
- messageKey,
2455
- }));
2456
- }
2457
2367
  /** States once which encoders code live video on this host: every qualified hardware encoder, then `libx264`. */
2458
2368
  export function reportLiveVideoEncoders(target, encoders) {
2459
2369
  const names = [
@@ -2462,19 +2372,6 @@ export function reportLiveVideoEncoders(target, encoders) {
2462
2372
  ];
2463
2373
  target.info(`[live-video-encoders] ${localize(target, 'log.liveVideoEncoders', { encoders: names.join(', ') })}`);
2464
2374
  }
2465
- /** Emits one identity-free warning after an invalid retained camera image is discarded. */
2466
- export function reportInvalidSnapshotCache(target) {
2467
- const code = 'camera-snapshot-cache-invalid';
2468
- const { level, messageKey } = MEDIA_NOTICES[code];
2469
- target[level](`[${code}] ${localize(target, messageKey)}`);
2470
- target.debug?.(JSON.stringify({ scope: 'media-notice', level, code, messageKey }));
2471
- }
2472
- /**
2473
- * Emits one allowlisted HomeKit event trace only when host debug output is available.
2474
- *
2475
- * `aliasFor` resolves the trace's device identity to the support-case alias the conditions about that accessory
2476
- * already carry. The identity itself never reaches the record: an unresolved one is recorded as no accessory.
2477
- */
2478
2375
  /**
2479
2376
  * Narrows one offered station arbitration decision, or nothing where its own labels do not narrow.
2480
2377
  *
@@ -2515,49 +2412,63 @@ export function reportStationClaim(target, decision) {
2515
2412
  target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...claim }));
2516
2413
  }
2517
2414
  }
2518
- export function reportHomeKitEvent(target, trace, aliasFor) {
2519
- if (!('adapter' in trace)) {
2520
- const unrouted = sanitizeUnroutedEvent(trace);
2521
- if (target.debug && unrouted) {
2522
- target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...unrouted }));
2523
- }
2524
- return;
2525
- }
2526
- const alias = typeof trace.serial === 'string' ? aliasFor?.(trace.serial) : undefined;
2527
- const offered = { ...trace, accessory: alias };
2528
- const accessory = alias === undefined ? {} : { accessory: alias };
2529
- if (trace.event === 'live-video-selected') {
2530
- const selection = sanitizeLiveVideoSelection(offered);
2531
- if (target.debug && selection) {
2532
- target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...selection }));
2533
- }
2534
- return;
2535
- }
2536
- if (HOMEKIT_LIVE_REQUEST_EVENTS.has(trace.event)) {
2537
- const lifecycle = sanitizeLiveSessionTrace(offered);
2538
- if (target.debug && lifecycle) {
2539
- target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...lifecycle }));
2540
- }
2541
- return;
2542
- }
2543
- if (!target.debug ||
2544
- !('observation' in trace) ||
2545
- !HOMEKIT_EVENT_ROUTES[trace.adapter]?.has(trace.event) ||
2546
- typeof trace.observation !== 'string' ||
2547
- !HOMEKIT_OBSERVATIONS.has(trace.observation)) {
2548
- return;
2415
+ /**
2416
+ * Narrows one offered HomeKit adapter fact to its `homekit` record, or nothing where its route does not narrow.
2417
+ *
2418
+ * Read by both halves of the path: {@link reportHomeKitEvent} builds the record and the file sink rebuilds it.
2419
+ */
2420
+ function sanitizeHomeKitEvent(value) {
2421
+ if (value.event === 'event-unrouted') {
2422
+ const unrouted = sanitizeUnroutedEvent(value);
2423
+ return unrouted ? { scope: 'homekit', level: 'debug', ...unrouted } : undefined;
2424
+ }
2425
+ if (value.event === 'station-claim') {
2426
+ const claim = sanitizeStationClaim(value);
2427
+ return claim ? { scope: 'homekit', level: 'debug', ...claim } : undefined;
2428
+ }
2429
+ if (value.adapter === 'camera.streaming' && value.event === 'live-video-selected') {
2430
+ const selection = sanitizeLiveVideoSelection(value);
2431
+ return selection ? { scope: 'homekit', level: 'debug', ...selection } : undefined;
2432
+ }
2433
+ if (value.adapter === 'camera.streaming' && HOMEKIT_LIVE_REQUEST_EVENTS.has(String(value.event))) {
2434
+ const lifecycle = sanitizeLiveSessionTrace(value);
2435
+ return lifecycle ? { scope: 'homekit', level: 'debug', ...lifecycle } : undefined;
2436
+ }
2437
+ if (typeof value.adapter !== 'string' ||
2438
+ typeof value.event !== 'string' ||
2439
+ !HOMEKIT_EVENT_ROUTES[value.adapter]?.has(value.event) ||
2440
+ typeof value.observation !== 'string' ||
2441
+ !HOMEKIT_OBSERVATIONS.has(value.observation)) {
2442
+ return undefined;
2549
2443
  }
2550
- target.debug(JSON.stringify({
2444
+ const alias = opaqueAccessoryAlias(value.accessory);
2445
+ return {
2551
2446
  scope: 'homekit',
2552
2447
  level: 'debug',
2553
- adapter: trace.adapter,
2554
- event: trace.event,
2555
- observation: trace.observation,
2556
- ...(typeof trace.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(trace.announcedBy)
2557
- ? { announcedBy: trace.announcedBy }
2448
+ adapter: value.adapter,
2449
+ event: value.event,
2450
+ observation: value.observation,
2451
+ ...(typeof value.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(value.announcedBy)
2452
+ ? { announcedBy: value.announcedBy }
2558
2453
  : {}),
2559
- ...accessory,
2560
- }));
2454
+ ...(typeof value.code === 'number' && Number.isInteger(value.code) && value.code >= 0 && value.code <= 0xffff
2455
+ ? { code: value.code }
2456
+ : {}),
2457
+ ...(alias === undefined ? {} : { accessory: alias }),
2458
+ };
2459
+ }
2460
+ /**
2461
+ * Emits one allowlisted HomeKit event trace only when host debug output is available.
2462
+ *
2463
+ * `aliasFor` resolves the trace's device identity to the support-case alias the conditions about that accessory
2464
+ * already carry. The identity itself never reaches the record: an unresolved one is recorded as no accessory.
2465
+ */
2466
+ export function reportHomeKitEvent(target, trace, aliasFor) {
2467
+ const alias = 'serial' in trace && typeof trace.serial === 'string' ? aliasFor?.(trace.serial) : undefined;
2468
+ const record = sanitizeHomeKitEvent({ ...trace, accessory: alias });
2469
+ if (target.debug && record) {
2470
+ target.debug(JSON.stringify(record));
2471
+ }
2561
2472
  }
2562
2473
  /**
2563
2474
  * Records one FFmpeg adaptation fact as the `ffmpeg-log` evidence the media support profiles declare.
@@ -2577,19 +2488,8 @@ export function reportAdaptationNotice(target, trace) {
2577
2488
  }
2578
2489
  }
2579
2490
  /**
2580
- * Reduces one FFmpeg stderr line to what a support case may keep, or nothing where nothing is left of it.
2581
- *
2582
- * The line is the only place an encoder-level cause is stated outright, and it is also the only place this
2583
- * plugin's own argument list can be echoed back: the output URL carries base64 SRTP key material, the
2584
- * controller address sits beside it, and an SDK snapshot filename carries a device serial. Each of those is
2585
- * replaced by what it is rather than searched for afterwards, because a redaction that depends on
2586
- * recognising a secret fails on the first message shape nobody predicted.
2587
- *
2588
- * The key material is replaced before the path rules run, and not after. Base64 includes `/`, so a path rule
2589
- * applied first splits a key into sub-runs too short for any length threshold to catch — measured on random
2590
- * 30-byte keys, roughly one line in twenty then kept an eight-character fragment verbatim. The cost of this
2591
- * order is that a long path made only of letters and separators is labelled as redacted rather than as a
2592
- * path, which loses a label and never a secret.
2491
+ * Reduces one FFmpeg stderr line to what a support case may keep, or nothing where nothing is left of it: a
2492
+ * bounded printable line, never a `progress=` one, through {@link redactSensitiveText}.
2593
2493
  */
2594
2494
  function redactAdaptationStderr(line) {
2595
2495
  const printable = boundedText(line, MAX_ADAPTATION_STDERR_LINE_LENGTH);
@@ -2680,6 +2580,57 @@ function sanitizeAdaptationNotice(value, level) {
2680
2580
  ...(stderr.length ? { stderr } : {}),
2681
2581
  };
2682
2582
  }
2583
+ /**
2584
+ * Records how one settings-page live preview ended, as a `live-preview` record kept whether or not a support
2585
+ * window is open: it is not one of the {@link VERBOSE_SCOPES}, because it holds only allowlisted labels and
2586
+ * bounded numbers, one per run a user started.
2587
+ */
2588
+ export function reportLivePreview(target, trace) {
2589
+ const { level, ...selection } = trace.selection;
2590
+ const preview = sanitizeLivePreview({ ...trace, ...selection, levelName: level });
2591
+ if (preview)
2592
+ target.debug?.(JSON.stringify({ scope: 'live-preview', level: 'info', ...preview }));
2593
+ }
2594
+ /**
2595
+ * Narrows one offered live preview run, or nothing where its selection or outcome does not narrow.
2596
+ *
2597
+ * Read by both halves of the path, {@link reportLivePreview} and the file sink, so a record only one of them
2598
+ * recognised could not reach a support archive.
2599
+ */
2600
+ function sanitizeLivePreview(value) {
2601
+ const selection = sanitizePreviewSelection(value);
2602
+ const outcome = allowlistedLabel(value.outcome, PREVIEW_OUTCOMES);
2603
+ if (!selection || !outcome)
2604
+ return undefined;
2605
+ const encoder = allowlistedLabel(value.encoder, LIVE_VIDEO_ENCODERS);
2606
+ const source = allowlistedLabel(value.source, LIVE_VIDEO_SOURCES);
2607
+ const kbps = boundedInteger(value.kbps, MAX_LIVE_VIDEO_BIT_RATE);
2608
+ const accessory = opaqueAccessoryAlias(value.accessory);
2609
+ const ask = allowlistedLabel(value.ask, PREVIEW_ASKS);
2610
+ const settings = PREVIEW_SETTINGS.filter((setting) => typeof value[setting] === 'boolean');
2611
+ return {
2612
+ ...selection,
2613
+ ...(encoder === undefined ? {} : { encoder }),
2614
+ ...(source === undefined ? {} : { source }),
2615
+ ...(kbps === undefined ? {} : { kbps }),
2616
+ ...(ask === undefined ? {} : { ask }),
2617
+ ...Object.fromEntries(settings.map((setting) => [setting, value[setting]])),
2618
+ outcome,
2619
+ ...(accessory === undefined ? {} : { accessory }),
2620
+ };
2621
+ }
2622
+ /** A live preview's selection as flat bounded fields, or nothing where any of them falls outside its allowlist. */
2623
+ function sanitizePreviewSelection(candidate) {
2624
+ const fields = {
2625
+ profile: allowlistedLabel(candidate.profile, HOMEKIT_LIVE_VIDEO_PROFILES),
2626
+ levelName: allowlistedLabel(candidate.levelName, HOMEKIT_LIVE_VIDEO_LEVELS),
2627
+ width: boundedInteger(candidate.width, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION),
2628
+ height: boundedInteger(candidate.height, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION),
2629
+ fps: boundedInteger(candidate.fps, MAX_LIVE_VIDEO_FRAME_RATE, MIN_LIVE_VIDEO_FRAME_RATE),
2630
+ maxBitRate: boundedInteger(candidate.maxBitRate, MAX_LIVE_VIDEO_BIT_RATE),
2631
+ };
2632
+ return Object.values(fields).includes(undefined) ? undefined : fields;
2633
+ }
2683
2634
  function sanitizeLiveSessionTrace(value) {
2684
2635
  if (value.adapter !== 'camera.streaming') {
2685
2636
  return undefined;
@@ -2720,8 +2671,7 @@ function sanitizeLiveSessionTrace(value) {
2720
2671
  value.outcome !== 'failed' ||
2721
2672
  typeof value.reason !== 'string' ||
2722
2673
  !REASONS.has(value.reason) ||
2723
- typeof value.stage !== 'string' ||
2724
- !HOMEKIT_LIVE_SESSION_STAGES.has(value.stage)) {
2674
+ !allowlistedLabel(value.stage, HOMEKIT_LIVE_SESSION_STAGES)) {
2725
2675
  return undefined;
2726
2676
  }
2727
2677
  const sourceStage = allowlistedLabel(value.sourceStage, SDK_LIVE_START_STAGES);
@@ -2738,27 +2688,23 @@ function sanitizeLiveSessionTrace(value) {
2738
2688
  };
2739
2689
  }
2740
2690
  function sanitizeLiveVideoSelection(value) {
2741
- const operation = typeof value.operation === 'string' ? value.operation : undefined;
2742
- const profile = typeof value.profile === 'string' ? value.profile : undefined;
2743
- const codecLevel = typeof value.levelName === 'string' ? value.levelName : typeof value.level === 'string' ? value.level : undefined;
2691
+ const operation = allowlistedLabel(value.operation, HOMEKIT_LIVE_VIDEO_OPERATIONS);
2692
+ const profile = allowlistedLabel(value.profile, HOMEKIT_LIVE_VIDEO_PROFILES);
2693
+ const codecLevel = allowlistedLabel(typeof value.levelName === 'string' ? value.levelName : value.level, HOMEKIT_LIVE_VIDEO_LEVELS);
2744
2694
  const width = boundedInteger(value.width, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION);
2745
2695
  const height = boundedInteger(value.height, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION);
2746
2696
  const fps = boundedInteger(value.fps, MAX_LIVE_VIDEO_FRAME_RATE, MIN_LIVE_VIDEO_FRAME_RATE);
2747
2697
  const mtu = boundedInteger(value.mtu, MAX_LIVE_VIDEO_MTU, MIN_LIVE_VIDEO_MTU);
2748
2698
  const maxBitRate = boundedInteger(value.maxBitRate, MAX_LIVE_VIDEO_BIT_RATE, 0);
2749
- const addressVersion = typeof value.addressVersion === 'string' ? value.addressVersion : undefined;
2699
+ const addressVersion = allowlistedLabel(value.addressVersion, HOMEKIT_LIVE_ADDRESS_VERSIONS);
2750
2700
  if (!operation ||
2751
- !HOMEKIT_LIVE_VIDEO_OPERATIONS.has(operation) ||
2752
2701
  !profile ||
2753
- !HOMEKIT_LIVE_VIDEO_PROFILES.has(profile) ||
2754
2702
  !codecLevel ||
2755
- !HOMEKIT_LIVE_VIDEO_LEVELS.has(codecLevel) ||
2756
2703
  width === undefined ||
2757
2704
  height === undefined ||
2758
2705
  fps === undefined ||
2759
2706
  mtu === undefined ||
2760
- !addressVersion ||
2761
- !HOMEKIT_LIVE_ADDRESS_VERSIONS.has(addressVersion)) {
2707
+ !addressVersion) {
2762
2708
  return undefined;
2763
2709
  }
2764
2710
  const alias = opaqueAccessoryAlias(value.accessory);