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

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 (87) 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 +268 -308
  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 +126 -72
  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 +15 -2
  79. package/homebridge-ui/public/i18n/fr.json +15 -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/i18n/runtime/en.json +1 -0
  84. package/package.json +3 -7
  85. package/homebridge-ui/public/js/legacy-settings.js +0 -37
  86. package/homebridge-ui/public/js/profile-wizard.js +0 -31
  87. package/homebridge-ui/public/js/setup-wizard.js +0 -52
@@ -229,6 +229,7 @@ const SDK_EVENT_KEYS = new Set([
229
229
  'protocol-command',
230
230
  'protocol-unhandled',
231
231
  'realtime-readiness',
232
+ 'rtc-sign-refused',
232
233
  'session-connecting',
233
234
  'session-idle',
234
235
  'session-resumed',
@@ -417,6 +418,10 @@ function boundedText(value, maximumLength) {
417
418
  .trim();
418
419
  return printable === '' ? undefined : printable;
419
420
  }
421
+ /** Whether a value is a plain JSON object, which is the only shape a record crossing a process boundary has. */
422
+ function isRecord(value) {
423
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
424
+ }
420
425
  function fleetPath(storageRoot) {
421
426
  return join(storageRoot, DIAGNOSTICS_DIRECTORY, FLEET_FILE);
422
427
  }
@@ -443,10 +448,9 @@ function readFleet(storageRoot) {
443
448
  if (candidate.version !== 1 || !Array.isArray(candidate.devices)) {
444
449
  return undefined;
445
450
  }
446
- const devices = candidate.devices.flatMap((entry) => {
447
- if (!entry || typeof entry !== 'object' || Array.isArray(entry))
451
+ const devices = candidate.devices.flatMap((row) => {
452
+ if (!isRecord(row))
448
453
  return [];
449
- const row = entry;
450
454
  const accessory = opaqueAccessoryAlias(row.accessory);
451
455
  if (accessory === undefined || typeof row.represented !== 'boolean' || typeof row.attached !== 'boolean') {
452
456
  return [];
@@ -532,19 +536,16 @@ function readHostEnvironment(storageRoot) {
532
536
  * entire purpose of the record and a redacted one answers nothing. Neither is device or account material: the
533
537
  * path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
534
538
  */
535
- function readFfmpegIdentity(value) {
536
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
539
+ function readFfmpegIdentity(recorded) {
540
+ if (!isRecord(recorded)) {
537
541
  return undefined;
538
542
  }
539
- const recorded = value;
540
543
  if (recorded.source !== 'bundled' && recorded.source !== 'configured') {
541
544
  return undefined;
542
545
  }
543
546
  const path = typeof recorded.path === 'string' ? boundedText(recorded.path, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
544
547
  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;
548
+ const encoders = isRecord(recorded.encoders) ? readEncoderQualification(recorded.encoders) : undefined;
548
549
  return path === undefined
549
550
  ? undefined
550
551
  : {
@@ -556,18 +557,23 @@ function readFfmpegIdentity(value) {
556
557
  }
557
558
  /**
558
559
  * 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.
560
+ * build names, so the record is bounded by those three closed sets, and a `bytes` outcome's two integers.
560
561
  */
561
562
  function readEncoderQualification(recorded) {
562
563
  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]) : [];
564
+ const outcomes = isRecord(recorded[name]) ? Object.entries(recorded[name]) : [];
564
565
  return [
565
566
  name,
566
567
  Object.fromEntries(outcomes.flatMap(([selection, value]) => {
567
568
  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) &&
569
+ const outcome = Array.isArray(value) &&
570
+ value.length === 3 &&
571
+ value[0] === 'bytes' &&
572
+ value.slice(1).every(Number.isSafeInteger)
573
+ ? value
574
+ : allowlistedLabel(value, QUALIFICATION_OUTCOMES);
575
+ return allowlistedLabel(profile, HOMEKIT_LIVE_VIDEO_PROFILES) &&
576
+ allowlistedLabel(level, HOMEKIT_LIVE_VIDEO_LEVELS) &&
571
577
  selection === `${profile}@${level}` &&
572
578
  outcome
573
579
  ? [[selection, outcome]]
@@ -1231,8 +1237,8 @@ export class GuidedDiagnostics {
1231
1237
  await this.replaceOwnedFile(join(directory, REPRODUCTION_MARKERS_FILE), `${retained}${marker}`);
1232
1238
  }
1233
1239
  }
1234
- function formatMessage(catalog, key, parameters = {}) {
1235
- const template = catalog[key] ?? EN_MESSAGES[key] ?? key;
1240
+ function formatMessage(key, parameters = {}) {
1241
+ const template = EN_MESSAGES[key] ?? key;
1236
1242
  return template
1237
1243
  .replace(/\{([a-zA-Z0-9]+)\}/g, (placeholder, name) => parameters[name] === undefined ? placeholder : String(parameters[name]))
1238
1244
  .replace(/\s+/g, ' ')
@@ -1240,7 +1246,7 @@ function formatMessage(catalog, key, parameters = {}) {
1240
1246
  }
1241
1247
  function localize(target, key, parameters = {}) {
1242
1248
  const translator = target.localize;
1243
- return translator?.(key, parameters) ?? formatMessage(EN_MESSAGES, key, parameters);
1249
+ return translator?.(key, parameters) ?? formatMessage(key, parameters);
1244
1250
  }
1245
1251
  class JsonLineLog {
1246
1252
  onError;
@@ -1257,11 +1263,8 @@ class JsonLineLog {
1257
1263
  this.directory = join(storageRoot, LOG_DIRECTORY);
1258
1264
  this.path = join(this.directory, LOG_FILE);
1259
1265
  }
1260
- write(message) {
1261
- const payload = sanitizeStructuredEvent(message);
1262
- if (!payload) {
1263
- return;
1264
- }
1266
+ /** Queues one record the sanitizer already rebuilt. */
1267
+ write(payload) {
1265
1268
  const record = this.serialize(payload);
1266
1269
  const bytes = Buffer.byteLength(record);
1267
1270
  if (bytes > MAX_LOG_RECORD_BYTES || this.queuedBytes + bytes > MAX_QUEUED_LOG_BYTES) {
@@ -1269,21 +1272,12 @@ class JsonLineLog {
1269
1272
  this.droppedRecords += 1;
1270
1273
  return;
1271
1274
  }
1272
- const droppedRecords = this.droppedRecords;
1273
- const droppedAt = this.droppedAt;
1274
- this.droppedRecords = 0;
1275
- this.droppedAt = undefined;
1275
+ const dropped = this.drainDropped();
1276
1276
  this.queuedBytes += bytes;
1277
1277
  this.pending = this.pending
1278
1278
  .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
- }
1279
+ if (dropped)
1280
+ await this.append(dropped);
1287
1281
  await this.append(record);
1288
1282
  })
1289
1283
  .catch(() => this.onError())
@@ -1292,22 +1286,21 @@ class JsonLineLog {
1292
1286
  });
1293
1287
  }
1294
1288
  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());
1289
+ const dropped = this.drainDropped();
1290
+ if (dropped) {
1291
+ this.pending = this.pending.then(() => this.append(dropped)).catch(() => this.onError());
1308
1292
  }
1309
1293
  return this.pending;
1310
1294
  }
1295
+ /** The `records-dropped` record for what was refused since the last one, resetting the tally, or nothing. */
1296
+ drainDropped() {
1297
+ const { droppedRecords, droppedAt } = this;
1298
+ this.droppedRecords = 0;
1299
+ this.droppedAt = undefined;
1300
+ return droppedRecords === 0
1301
+ ? undefined
1302
+ : this.serialize({ scope: 'diagnostics', level: 'warn', event: 'records-dropped', droppedRecords }, droppedAt);
1303
+ }
1311
1304
  serialize(payload, timestamp = new Date(this.now()).toISOString()) {
1312
1305
  return `${JSON.stringify({ ...payload, timestamp })}\n`;
1313
1306
  }
@@ -1366,12 +1359,12 @@ class JsonLineLog {
1366
1359
  * Routes human messages to Homebridge and structured events to the plugin JSONL log.
1367
1360
  * File writes preserve accepted order; records beyond the bounded pending queue are counted and reported.
1368
1361
  */
1369
- export function createDiagnosticLogger(target, storageRoot, catalog = EN_MESSAGES, now = Date.now) {
1362
+ export function createDiagnosticLogger(target, storageRoot, now = Date.now) {
1370
1363
  let fileFailureReported = false;
1371
1364
  const reportFileFailure = () => {
1372
1365
  if (!fileFailureReported) {
1373
1366
  fileFailureReported = true;
1374
- target.warn(`[diagnostic-log-write-failed] ${formatMessage(catalog, 'log.diagnosticFileFailed')}`);
1367
+ target.warn(`[diagnostic-log-write-failed] ${formatMessage('log.diagnosticFileFailed')}`);
1375
1368
  }
1376
1369
  };
1377
1370
  const file = storageRoot ? new JsonLineLog(storageRoot, reportFileFailure, now) : undefined;
@@ -1385,7 +1378,7 @@ export function createDiagnosticLogger(target, storageRoot, catalog = EN_MESSAGE
1385
1378
  if (verbose && (!session || !DIAGNOSTICS_PROFILES[session.profile].includes(evidenceClassOf(event)))) {
1386
1379
  return;
1387
1380
  }
1388
- file.write(JSON.stringify(event));
1381
+ file.write(event);
1389
1382
  }
1390
1383
  : undefined;
1391
1384
  return {
@@ -1393,13 +1386,15 @@ export function createDiagnosticLogger(target, storageRoot, catalog = EN_MESSAGE
1393
1386
  error: (message) => target.error(message),
1394
1387
  flush: () => file?.flush() ?? Promise.resolve(),
1395
1388
  info: (message) => target.info(message),
1396
- localize: (key, parameters) => formatMessage(catalog, key, parameters),
1389
+ localize: (key, parameters) => formatMessage(key, parameters),
1397
1390
  warn: (message) => target.warn(message),
1398
1391
  };
1399
1392
  }
1400
1393
  /** The class an SDK message belongs to, or nothing when its text matches none of them. */
1401
1394
  function classifySdkEvent(message) {
1402
1395
  const normalized = message.toLowerCase();
1396
+ if (normalized.includes('rtc sign for') && normalized.includes('refused'))
1397
+ return 'rtc-sign-refused';
1403
1398
  if (normalized.includes('restored persisted session'))
1404
1399
  return 'session-restored';
1405
1400
  if (normalized.includes('retrying') || normalized.includes('reconnecting'))
@@ -1531,6 +1526,7 @@ const LIVE_TRACE_PHASES = {
1531
1526
  'sequence-restart': retainedInteger('dataType', 3),
1532
1527
  'level2-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
1533
1528
  'level2-ready': retainedInteger('cipherId', 65535),
1529
+ 'level2-set-payload-only': retainedInteger('cipherId', 65535),
1534
1530
  'session-connect-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
1535
1531
  'session-connected': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
1536
1532
  'session-unreachable': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
@@ -1622,16 +1618,22 @@ const LIVE_TRACE_PHASES = {
1622
1618
  const reason = allowlistedLabel(c.reason, ['level2-key', 'address']);
1623
1619
  return reason ? { reason } : undefined;
1624
1620
  },
1621
+ /** `errorName` is an upstream class name, so it stays out of the archive; reason and stage say enough. */
1622
+ 'start-failed': (c) => {
1623
+ const reason = allowlistedLabel(c.reason, ['warm-timeout', 'source-error', 'source-ended']);
1624
+ const stage = allowlistedLabel(c.stage, ['awaiting-first-frame', 'audio-only', 'awaiting-keyframe']);
1625
+ const attempts = boundedInteger(c.attempts, 1000);
1626
+ return reason && stage && attempts !== undefined ? { reason, stage, attempts } : undefined;
1627
+ },
1625
1628
  warming: (c) => {
1626
1629
  const retryMs = boundedInteger(c.retryMs, MAX_STARTUP_WINDOW_MS);
1627
1630
  const deadlineMs = boundedInteger(c.deadlineMs, MAX_STARTUP_WINDOW_MS);
1628
1631
  return retryMs === undefined || deadlineMs === undefined ? undefined : { retryMs, deadlineMs };
1629
1632
  },
1630
1633
  };
1631
- function sanitizeSdkLiveStartTrace(value) {
1632
- if (!value || typeof value !== 'object' || Array.isArray(value))
1634
+ function sanitizeSdkLiveStartTrace(candidate) {
1635
+ if (!isRecord(candidate))
1633
1636
  return undefined;
1634
- const candidate = value;
1635
1637
  const phase = String(candidate.phase);
1636
1638
  if (!Object.hasOwn(LIVE_TRACE_PHASES, phase))
1637
1639
  return undefined;
@@ -1664,7 +1666,8 @@ function opaqueAccessoryAlias(value) {
1664
1666
  : undefined;
1665
1667
  }
1666
1668
  /**
1667
- * Narrows one realtime startup outcome to its state and push start counts, or nothing where the state is unknown.
1669
+ * Narrows one realtime startup outcome to its state, the push plane's start counts, and the mqtt and wired P2P
1670
+ * planes' counts nested under their names, or nothing where the state is unknown.
1668
1671
  *
1669
1672
  * Read by both halves of the path — {@link reportRealtimeReadiness} builds the record and the file sink rebuilds it.
1670
1673
  */
@@ -1672,20 +1675,25 @@ function sanitizeRealtimeReadiness(value) {
1672
1675
  const state = allowlistedLabel(value.state, ['ready', 'partial', 'disabled', 'superseded', 'timed-out']);
1673
1676
  if (!state)
1674
1677
  return undefined;
1675
- const counts = Object.fromEntries(['required', 'ready', 'failed', 'pending'].flatMap((key) => {
1676
- const count = nonNegativeInteger(value[key]);
1678
+ const counts = (plane) => Object.fromEntries(['required', 'ready', 'failed', 'pending'].flatMap((key) => {
1679
+ const count = isRecord(plane) ? nonNegativeInteger(plane[key]) : undefined;
1677
1680
  return count === undefined ? [] : [[key, count]];
1678
1681
  }));
1679
- return { event: 'realtime-readiness', state, ...counts };
1682
+ const planes = Object.fromEntries(['mqtt', 'wiredP2p'].flatMap((plane) => {
1683
+ const planeCounts = counts(value[plane]);
1684
+ return Object.keys(planeCounts).length ? [[plane, planeCounts]] : [];
1685
+ }));
1686
+ return { event: 'realtime-readiness', state, ...counts(value), ...planes };
1680
1687
  }
1681
1688
  /**
1682
- * Records how the SDK's realtime startup settled, with the push plane's start counts, as `sdk-log` evidence.
1689
+ * Records how the SDK's realtime startup settled, with each plane's start counts, as `sdk-log` evidence.
1683
1690
  *
1684
- * Carries the state and the counts alone, which separate a push plane that failed or never settled from one that
1685
- * started.
1691
+ * Carries the state and the counts alone, which name the plane — push, mqtt or wired P2P — that failed or never
1692
+ * settled behind a `partial` start.
1686
1693
  */
1687
1694
  export function reportRealtimeReadiness(target, readiness) {
1688
- const record = sanitizeRealtimeReadiness({ ...readiness.push, state: readiness.state });
1695
+ const { state, push, mqtt, wiredP2p } = readiness;
1696
+ const record = sanitizeRealtimeReadiness({ ...push, state, mqtt, wiredP2p });
1689
1697
  if (target?.debug && record) {
1690
1698
  target.debug(JSON.stringify({ scope: 'sdk', level: 'debug', subsystem: 'push', ...record }));
1691
1699
  }
@@ -1856,6 +1864,7 @@ const REASONS = new Set([
1856
1864
  'source-input-unstable',
1857
1865
  'source-stopped',
1858
1866
  'source-unavailable',
1867
+ 'source-warm-timeout',
1859
1868
  'station-key-unavailable',
1860
1869
  'station-unreachable',
1861
1870
  'stored-download-failed',
@@ -1885,7 +1894,7 @@ const HOMEKIT_EVENT_ROUTES = {
1885
1894
  'camera.streaming': new Set(['camera-enabled-changed']),
1886
1895
  'camera.controls': new Set(['camera-enabled-changed']),
1887
1896
  };
1888
- const HOMEKIT_OBSERVATIONS = new Set(['malformed', 'missing', 'valid']);
1897
+ const HOMEKIT_OBSERVATIONS = new Set(['malformed', 'missing', 'valid', 'ignored']);
1889
1898
  /**
1890
1899
  * What announced a change, for an adapter that follows more than one announcement for the same state.
1891
1900
  *
@@ -1893,9 +1902,9 @@ const HOMEKIT_OBSERVATIONS = new Set(['malformed', 'missing', 'valid']);
1893
1902
  * field is omitted where an adapter states no announcement and refused where it states one absent from here.
1894
1903
  */
1895
1904
  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']);
1905
+ const HOMEKIT_LIVE_VIDEO_OPERATIONS = ['start', 'reconfigure'];
1906
+ const HOMEKIT_LIVE_VIDEO_PROFILES = ['baseline', 'main', 'high'];
1907
+ const HOMEKIT_LIVE_VIDEO_LEVELS = ['3.1', '3.2', '4.0'];
1899
1908
  /**
1900
1909
  * The inclusive bounds a controller-selected live geometry and frame rate are retained within.
1901
1910
  *
@@ -1919,13 +1928,13 @@ const MAX_LIVE_VIDEO_MTU = 65_535;
1919
1928
  /** The bit rate ceiling a controller negotiated, in kbps, bounded to the unsigned 16-bit field that carries it. */
1920
1929
  const MAX_LIVE_VIDEO_BIT_RATE = 65_535;
1921
1930
  /** 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([
1931
+ const HOMEKIT_LIVE_ADDRESS_VERSIONS = ['ipv4', 'ipv6'];
1932
+ const HOMEKIT_LIVE_SESSION_STAGES = [
1924
1933
  'sdk-source-acquisition',
1925
1934
  'first-source-keyframe',
1926
1935
  'first-adapted-output',
1927
1936
  'controller-rtcp',
1928
- ]);
1937
+ ];
1929
1938
  /** The SDK's own stage and reason for a live warm-up that ended without a keyframe. */
1930
1939
  const SDK_LIVE_START_STAGES = ['awaiting-first-frame', 'audio-only', 'awaiting-keyframe'];
1931
1940
  const SDK_LIVE_START_REASONS = ['warm-timeout', 'source-error', 'source-ended'];
@@ -1935,7 +1944,16 @@ const SDK_LIVE_START_REASONS = ['warm-timeout', 'source-error', 'source-ended'];
1935
1944
  * A superset of what the media domain can report, because the SDK runs FFmpeg of its own for snapshot
1936
1945
  * decoding and WebRTC containers and forwards its output here under the same evidence class.
1937
1946
  */
1938
- const ADAPTATION_ROLES = new Set(['live-video', 'live-audio', 'return-audio', 'recording', 'sdk']);
1947
+ const ADAPTATION_ROLES = new Set([
1948
+ 'live-video',
1949
+ 'live-audio',
1950
+ 'return-audio',
1951
+ 'recording',
1952
+ 'preview-encode',
1953
+ 'preview-decode',
1954
+ 'preview-raw',
1955
+ 'sdk',
1956
+ ]);
1939
1957
  /** What that process did. `output` is a process reported for what it wrote rather than for how it ended. */
1940
1958
  const ADAPTATION_EVENTS = new Set([
1941
1959
  'started',
@@ -1946,6 +1964,16 @@ const ADAPTATION_EVENTS = new Set([
1946
1964
  'exited-while-streaming',
1947
1965
  'output',
1948
1966
  ]);
1967
+ /** How a settings-page live preview ended. */
1968
+ const PREVIEW_OUTCOMES = ['picture', 'unplayable', 'no-video', 'stopped'];
1969
+ /** The iPhone asks a live preview codes for, and the live video settings it may try. */
1970
+ const PREVIEW_ASKS = ['wifi', 'cellular'];
1971
+ const PREVIEW_SETTINGS = [
1972
+ 'smallVideoPackets',
1973
+ 'liveVideoPassthrough',
1974
+ 'hardwareVideoEncoder',
1975
+ 'ignoreHomeBitRate',
1976
+ ];
1949
1977
  /**
1950
1978
  * Why a process was started, on the record that reports it starting.
1951
1979
  *
@@ -1964,7 +1992,7 @@ const LIVE_VIDEO_ENCODERS = [
1964
1992
  'h264_vaapi',
1965
1993
  'h264_v4l2m2m',
1966
1994
  ];
1967
- /** What qualifying a hardware encoder for one selection found: `qualified`, or the first check it failed. */
1995
+ /** What qualifying a hardware encoder for one selection found, but `bytes`, which is read with its two integers. */
1968
1996
  const QUALIFICATION_OUTCOMES = [
1969
1997
  'qualified',
1970
1998
  'budget',
@@ -1974,7 +2002,6 @@ const QUALIFICATION_OUTCOMES = [
1974
2002
  'level',
1975
2003
  'geometry',
1976
2004
  'frames',
1977
- 'bytes',
1978
2005
  ];
1979
2006
  /** The codec a live video source delivers. */
1980
2007
  const LIVE_VIDEO_SOURCES = ['h264', 'h265', 'av1'];
@@ -2019,74 +2046,32 @@ const ADAPTATION_SIGNALS = new Set([
2019
2046
  const MAX_ADAPTATION_STDERR_LINES = 8;
2020
2047
  const MAX_ADAPTATION_STDERR_LINE_LENGTH = 200;
2021
2048
  /**
2022
- * The media notices this module retains, read by both halves of the path.
2049
+ * The fixed-shape operational notices, each `[level, messageKey, durationMessageKey?]`.
2023
2050
  *
2024
2051
  * Operational rather than verbose, so each is `plugin-log` evidence that every profile declares.
2025
2052
  */
2026
- const MEDIA_NOTICES = {
2027
- 'camera-snapshot-cache-invalid': {
2028
- level: 'warn',
2029
- messageKey: 'log.snapshotCacheInvalid',
2030
- },
2031
- };
2032
2053
  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
- },
2054
+ 'status-publication-failed': ['warn', 'log.notice.statusPublicationFailed'],
2055
+ 'ownership-release-not-finalized': ['error', 'log.notice.ownershipReleaseNotFinalized'],
2056
+ 'ownership-release-failed': ['error', 'log.notice.ownershipReleaseFailed'],
2057
+ 'shutdown-failed': ['error', 'log.notice.shutdownFailed'],
2058
+ 'shutdown-timeout': ['warn', 'log.notice.shutdownTimeout', 'log.notice.shutdownTimeoutWithDuration'],
2059
+ 'ownership-acquisition-failed': ['error', 'log.notice.ownershipAcquisitionFailed'],
2060
+ 'ownership-release-incomplete': ['warn', 'log.notice.ownershipReleaseIncomplete'],
2061
+ 'registry-subscriber-failed': ['warn', 'log.notice.registrySubscriberFailed'],
2062
+ 'event-subscriber-failed': ['warn', 'log.notice.eventSubscriberFailed'],
2063
+ 'state-subscriber-failed': ['warn', 'log.notice.stateSubscriberFailed'],
2064
+ 'channel-serve-failed': ['warn', 'log.notice.channelServeFailed'],
2065
+ 'diagnostics-authorization-armed': ['info', 'log.notice.diagnosticsAuthorizationArmed'],
2066
+ 'stand-down-not-rearmed': ['warn', 'log.notice.standDownNotRearmed'],
2067
+ 'stand-down-account-replaced': ['warn', 'log.notice.standDownAccountReplaced'],
2068
+ 'camera-snapshot-cache-invalid': ['warn', 'log.snapshotCacheInvalid'],
2069
+ 'discarded-v4-settings-unacknowledged': ['warn', 'log.discardedSettings'],
2070
+ };
2071
+ /** The scope a notice is recorded under where it is not `runtime-notice`. */
2072
+ const NOTICE_SCOPES = {
2073
+ 'camera-snapshot-cache-invalid': 'media-notice',
2074
+ 'discarded-v4-settings-unacknowledged': 'configuration-notice',
2090
2075
  };
2091
2076
  const RUNTIME_CONDITIONS = {
2092
2077
  degraded: {
@@ -2181,12 +2166,13 @@ const HOMEKIT_REASON_ACTIONS = {
2181
2166
  'smart-light-operation-failed:timeout': 'log.action.controlTimedOut',
2182
2167
  'camera-control-operation-failed:timeout': 'log.action.controlTimedOut',
2183
2168
  'camera-live-session-failed:station-unreachable': 'log.action.checkStation',
2169
+ 'camera-live-session-failed:source-warm-timeout': 'log.action.retryLiveViewNoPicture',
2184
2170
  'camera-snapshot-unavailable:live-station-unreachable': 'log.action.checkStation',
2185
2171
  'camera-talkback-failed:no-controller-audio': 'log.action.trySmallVideoPackets',
2186
2172
  };
2187
2173
  /** Projects a value onto an active condition, or nothing where the catalog does not write that code with those keys. */
2188
2174
  export function knownCondition(value) {
2189
- if (typeof value !== 'object' || value === null || Array.isArray(value))
2175
+ if (!isRecord(value))
2190
2176
  return undefined;
2191
2177
  const { code, summaryKey, actionKey, serials } = value;
2192
2178
  if (typeof code !== 'string')
@@ -2223,14 +2209,13 @@ function homeKitConditionPresentation(code, active, reason) {
2223
2209
  function sanitizeStructuredEvent(message) {
2224
2210
  let value;
2225
2211
  try {
2226
- const parsed = JSON.parse(message);
2227
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
2228
- return undefined;
2229
- value = parsed;
2212
+ value = JSON.parse(message);
2230
2213
  }
2231
2214
  catch {
2232
2215
  return undefined;
2233
2216
  }
2217
+ if (!isRecord(value))
2218
+ return undefined;
2234
2219
  const level = allowlistedLabel(value.level, ['debug', 'info', 'warn', 'error']);
2235
2220
  if (!level || typeof value.scope !== 'string')
2236
2221
  return undefined;
@@ -2253,18 +2238,17 @@ function sanitizeStructuredEvent(message) {
2253
2238
  }
2254
2239
  const details = [];
2255
2240
  for (const detail of Array.isArray(value.details) ? value.details.slice(0, MAX_SDK_DETAILS) : []) {
2256
- if (!detail || typeof detail !== 'object' || Array.isArray(detail))
2241
+ if (!isRecord(detail))
2257
2242
  continue;
2258
- const candidate = detail;
2259
- const errorType = allowlistedLabel(candidate.errorType, SDK_ERROR_TYPES);
2243
+ const errorType = allowlistedLabel(detail.errorType, SDK_ERROR_TYPES);
2260
2244
  if (errorType) {
2261
2245
  details.push({ errorType });
2262
2246
  continue;
2263
2247
  }
2264
- const type = allowlistedLabel(candidate.type, ['boolean', 'number', 'object', 'string', 'undefined']);
2248
+ const type = allowlistedLabel(detail.type, ['boolean', 'number', 'object', 'string', 'undefined']);
2265
2249
  if (!type)
2266
2250
  continue;
2267
- const length = nonNegativeInteger(candidate.length);
2251
+ const length = nonNegativeInteger(detail.length);
2268
2252
  details.push({ type, ...(length === undefined ? {} : { length }) });
2269
2253
  }
2270
2254
  return {
@@ -2276,72 +2260,14 @@ function sanitizeStructuredEvent(message) {
2276
2260
  ...(value.detailsTruncated === true ? { detailsTruncated: true } : {}),
2277
2261
  };
2278
2262
  }
2279
- if (value.scope === 'runtime-notice') {
2263
+ if (value.scope === 'runtime-notice' || value.scope === 'media-notice' || value.scope === 'configuration-notice') {
2280
2264
  if (typeof value.code !== 'string' || !Object.hasOwn(RUNTIME_NOTICES, value.code))
2281
2265
  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 };
2266
+ const notice = runtimeNoticeRecord(value.code, nonNegativeInteger(value.durationMs));
2267
+ return notice.scope === value.scope ? notice : undefined;
2308
2268
  }
2309
2269
  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
- };
2270
+ return sanitizeHomeKitEvent(value);
2345
2271
  }
2346
2272
  if (value.scope === 'runtime') {
2347
2273
  const event = allowlistedLabel(value.event, ['ready', 'stopped']);
@@ -2352,6 +2278,10 @@ function sanitizeStructuredEvent(message) {
2352
2278
  if (value.scope === 'ffmpeg') {
2353
2279
  return sanitizeAdaptationNotice(value, level);
2354
2280
  }
2281
+ if (value.scope === 'live-preview') {
2282
+ const preview = sanitizeLivePreview(value);
2283
+ return preview ? { scope: 'live-preview', level: 'info', ...preview } : undefined;
2284
+ }
2355
2285
  if (value.scope === 'diagnostic-condition') {
2356
2286
  const homeKitDefinition = typeof value.code === 'string' && Object.hasOwn(HOMEKIT_CONDITIONS, value.code)
2357
2287
  ? HOMEKIT_CONDITIONS[value.code]
@@ -2386,7 +2316,7 @@ function sanitizeStructuredEvent(message) {
2386
2316
  const affectedAccessoryCount = nonNegativeInteger(value.affectedAccessoryCount);
2387
2317
  const accessoryAliases = Array.isArray(value.accessoryAliases)
2388
2318
  ? value.accessoryAliases
2389
- .filter((alias) => typeof alias === 'string' && /^accessory-[0-9a-f-]{36}$/.test(alias))
2319
+ .filter((alias) => opaqueAccessoryAlias(alias) !== undefined)
2390
2320
  .slice(0, MAX_ACCESSORY_ALIASES)
2391
2321
  : [];
2392
2322
  const { level: conditionLevel, actionKey } = homeKitConditionPresentation(value.code, value.active, value.reason);
@@ -2409,19 +2339,22 @@ function sanitizeStructuredEvent(message) {
2409
2339
  }
2410
2340
  return undefined;
2411
2341
  }
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,
2342
+ /** The record one notice is written as, read by both halves of the path. */
2343
+ function runtimeNoticeRecord(code, durationMs) {
2344
+ const [level, messageKey, durationMessageKey] = RUNTIME_NOTICES[code];
2345
+ return {
2346
+ scope: NOTICE_SCOPES[code] ?? 'runtime-notice',
2347
+ level,
2421
2348
  code,
2422
- messageKey,
2349
+ messageKey: durationMs !== undefined && durationMessageKey ? durationMessageKey : messageKey,
2423
2350
  ...(durationMs === undefined ? {} : { durationMs }),
2424
- }));
2351
+ };
2352
+ }
2353
+ /** Emits one fixed-shape operational notice selected by its allowlisted code. */
2354
+ export function reportRuntimeNotice(target, code, fields = {}) {
2355
+ const record = runtimeNoticeRecord(code, fields.durationMs === undefined ? undefined : Math.max(0, Math.trunc(fields.durationMs)));
2356
+ target[record.level]?.(`[${code}] ${localize(target, record.messageKey, { durationMs: record.durationMs ?? 0, finishBy: fields.finishBy ?? '' })}`);
2357
+ target.debug?.(JSON.stringify(record));
2425
2358
  }
2426
2359
  /**
2427
2360
  * Picks up an authorization the persisted session file confirms, reporting whether it did.
@@ -2442,18 +2375,6 @@ export function armDiagnosticsAuthorization(target, storageRoot, supportCaseId,
2442
2375
  });
2443
2376
  return true;
2444
2377
  }
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
2378
  /** States once which encoders code live video on this host: every qualified hardware encoder, then `libx264`. */
2458
2379
  export function reportLiveVideoEncoders(target, encoders) {
2459
2380
  const names = [
@@ -2462,19 +2383,6 @@ export function reportLiveVideoEncoders(target, encoders) {
2462
2383
  ];
2463
2384
  target.info(`[live-video-encoders] ${localize(target, 'log.liveVideoEncoders', { encoders: names.join(', ') })}`);
2464
2385
  }
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
2386
  /**
2479
2387
  * Narrows one offered station arbitration decision, or nothing where its own labels do not narrow.
2480
2388
  *
@@ -2515,49 +2423,63 @@ export function reportStationClaim(target, decision) {
2515
2423
  target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...claim }));
2516
2424
  }
2517
2425
  }
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;
2426
+ /**
2427
+ * Narrows one offered HomeKit adapter fact to its `homekit` record, or nothing where its route does not narrow.
2428
+ *
2429
+ * Read by both halves of the path: {@link reportHomeKitEvent} builds the record and the file sink rebuilds it.
2430
+ */
2431
+ function sanitizeHomeKitEvent(value) {
2432
+ if (value.event === 'event-unrouted') {
2433
+ const unrouted = sanitizeUnroutedEvent(value);
2434
+ return unrouted ? { scope: 'homekit', level: 'debug', ...unrouted } : undefined;
2435
+ }
2436
+ if (value.event === 'station-claim') {
2437
+ const claim = sanitizeStationClaim(value);
2438
+ return claim ? { scope: 'homekit', level: 'debug', ...claim } : undefined;
2439
+ }
2440
+ if (value.adapter === 'camera.streaming' && value.event === 'live-video-selected') {
2441
+ const selection = sanitizeLiveVideoSelection(value);
2442
+ return selection ? { scope: 'homekit', level: 'debug', ...selection } : undefined;
2443
+ }
2444
+ if (value.adapter === 'camera.streaming' && HOMEKIT_LIVE_REQUEST_EVENTS.has(String(value.event))) {
2445
+ const lifecycle = sanitizeLiveSessionTrace(value);
2446
+ return lifecycle ? { scope: 'homekit', level: 'debug', ...lifecycle } : undefined;
2447
+ }
2448
+ if (typeof value.adapter !== 'string' ||
2449
+ typeof value.event !== 'string' ||
2450
+ !HOMEKIT_EVENT_ROUTES[value.adapter]?.has(value.event) ||
2451
+ typeof value.observation !== 'string' ||
2452
+ !HOMEKIT_OBSERVATIONS.has(value.observation)) {
2453
+ return undefined;
2549
2454
  }
2550
- target.debug(JSON.stringify({
2455
+ const alias = opaqueAccessoryAlias(value.accessory);
2456
+ return {
2551
2457
  scope: 'homekit',
2552
2458
  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 }
2459
+ adapter: value.adapter,
2460
+ event: value.event,
2461
+ observation: value.observation,
2462
+ ...(typeof value.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(value.announcedBy)
2463
+ ? { announcedBy: value.announcedBy }
2558
2464
  : {}),
2559
- ...accessory,
2560
- }));
2465
+ ...(typeof value.code === 'number' && Number.isInteger(value.code) && value.code >= 0 && value.code <= 0xffff
2466
+ ? { code: value.code }
2467
+ : {}),
2468
+ ...(alias === undefined ? {} : { accessory: alias }),
2469
+ };
2470
+ }
2471
+ /**
2472
+ * Emits one allowlisted HomeKit event trace only when host debug output is available.
2473
+ *
2474
+ * `aliasFor` resolves the trace's device identity to the support-case alias the conditions about that accessory
2475
+ * already carry. The identity itself never reaches the record: an unresolved one is recorded as no accessory.
2476
+ */
2477
+ export function reportHomeKitEvent(target, trace, aliasFor) {
2478
+ const alias = 'serial' in trace && typeof trace.serial === 'string' ? aliasFor?.(trace.serial) : undefined;
2479
+ const record = sanitizeHomeKitEvent({ ...trace, accessory: alias });
2480
+ if (target.debug && record) {
2481
+ target.debug(JSON.stringify(record));
2482
+ }
2561
2483
  }
2562
2484
  /**
2563
2485
  * Records one FFmpeg adaptation fact as the `ffmpeg-log` evidence the media support profiles declare.
@@ -2577,19 +2499,8 @@ export function reportAdaptationNotice(target, trace) {
2577
2499
  }
2578
2500
  }
2579
2501
  /**
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.
2502
+ * Reduces one FFmpeg stderr line to what a support case may keep, or nothing where nothing is left of it: a
2503
+ * bounded printable line, never a `progress=` one, through {@link redactSensitiveText}.
2593
2504
  */
2594
2505
  function redactAdaptationStderr(line) {
2595
2506
  const printable = boundedText(line, MAX_ADAPTATION_STDERR_LINE_LENGTH);
@@ -2663,6 +2574,8 @@ function sanitizeAdaptationNotice(value, level) {
2663
2574
  .filter((line) => line !== undefined)
2664
2575
  .slice(-MAX_ADAPTATION_STDERR_LINES);
2665
2576
  const sourceFragments = nonNegativeInteger(value.sourceFragments);
2577
+ const sourceWidth = nonNegativeInteger(value.sourceWidth);
2578
+ const sourceHeight = nonNegativeInteger(value.sourceHeight);
2666
2579
  const cause = allowlistedLabel(value.cause, ADAPTATION_CAUSES);
2667
2580
  const encoder = allowlistedLabel(value.encoder, LIVE_VIDEO_ENCODERS);
2668
2581
  const source = allowlistedLabel(value.source, LIVE_VIDEO_SOURCES);
@@ -2674,12 +2587,64 @@ function sanitizeAdaptationNotice(value, level) {
2674
2587
  ...(cause === undefined ? {} : { cause }),
2675
2588
  ...(encoder === undefined ? {} : { encoder }),
2676
2589
  ...(source === undefined ? {} : { source }),
2590
+ ...(sourceWidth === undefined || sourceHeight === undefined ? {} : { sourceWidth, sourceHeight }),
2677
2591
  ...(code === undefined ? {} : { code }),
2678
2592
  ...(signal === undefined ? {} : { signal }),
2679
2593
  ...(sourceFragments === undefined ? {} : { sourceFragments }),
2680
2594
  ...(stderr.length ? { stderr } : {}),
2681
2595
  };
2682
2596
  }
2597
+ /**
2598
+ * Records how one settings-page live preview ended, as a `live-preview` record kept whether or not a support
2599
+ * window is open: it is not one of the {@link VERBOSE_SCOPES}, because it holds only allowlisted labels and
2600
+ * bounded numbers, one per run a user started.
2601
+ */
2602
+ export function reportLivePreview(target, trace) {
2603
+ const { level, ...selection } = trace.selection;
2604
+ const preview = sanitizeLivePreview({ ...trace, ...selection, levelName: level });
2605
+ if (preview)
2606
+ target.debug?.(JSON.stringify({ scope: 'live-preview', level: 'info', ...preview }));
2607
+ }
2608
+ /**
2609
+ * Narrows one offered live preview run, or nothing where its selection or outcome does not narrow.
2610
+ *
2611
+ * Read by both halves of the path, {@link reportLivePreview} and the file sink, so a record only one of them
2612
+ * recognised could not reach a support archive.
2613
+ */
2614
+ function sanitizeLivePreview(value) {
2615
+ const selection = sanitizePreviewSelection(value);
2616
+ const outcome = allowlistedLabel(value.outcome, PREVIEW_OUTCOMES);
2617
+ if (!selection || !outcome)
2618
+ return undefined;
2619
+ const encoder = allowlistedLabel(value.encoder, LIVE_VIDEO_ENCODERS);
2620
+ const source = allowlistedLabel(value.source, LIVE_VIDEO_SOURCES);
2621
+ const kbps = boundedInteger(value.kbps, MAX_LIVE_VIDEO_BIT_RATE);
2622
+ const accessory = opaqueAccessoryAlias(value.accessory);
2623
+ const ask = allowlistedLabel(value.ask, PREVIEW_ASKS);
2624
+ const settings = PREVIEW_SETTINGS.filter((setting) => typeof value[setting] === 'boolean');
2625
+ return {
2626
+ ...selection,
2627
+ ...(encoder === undefined ? {} : { encoder }),
2628
+ ...(source === undefined ? {} : { source }),
2629
+ ...(kbps === undefined ? {} : { kbps }),
2630
+ ...(ask === undefined ? {} : { ask }),
2631
+ ...Object.fromEntries(settings.map((setting) => [setting, value[setting]])),
2632
+ outcome,
2633
+ ...(accessory === undefined ? {} : { accessory }),
2634
+ };
2635
+ }
2636
+ /** A live preview's selection as flat bounded fields, or nothing where any of them falls outside its allowlist. */
2637
+ function sanitizePreviewSelection(candidate) {
2638
+ const fields = {
2639
+ profile: allowlistedLabel(candidate.profile, HOMEKIT_LIVE_VIDEO_PROFILES),
2640
+ levelName: allowlistedLabel(candidate.levelName, HOMEKIT_LIVE_VIDEO_LEVELS),
2641
+ width: boundedInteger(candidate.width, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION),
2642
+ height: boundedInteger(candidate.height, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION),
2643
+ fps: boundedInteger(candidate.fps, MAX_LIVE_VIDEO_FRAME_RATE, MIN_LIVE_VIDEO_FRAME_RATE),
2644
+ maxBitRate: boundedInteger(candidate.maxBitRate, MAX_LIVE_VIDEO_BIT_RATE),
2645
+ };
2646
+ return Object.values(fields).includes(undefined) ? undefined : fields;
2647
+ }
2683
2648
  function sanitizeLiveSessionTrace(value) {
2684
2649
  if (value.adapter !== 'camera.streaming') {
2685
2650
  return undefined;
@@ -2720,8 +2685,7 @@ function sanitizeLiveSessionTrace(value) {
2720
2685
  value.outcome !== 'failed' ||
2721
2686
  typeof value.reason !== 'string' ||
2722
2687
  !REASONS.has(value.reason) ||
2723
- typeof value.stage !== 'string' ||
2724
- !HOMEKIT_LIVE_SESSION_STAGES.has(value.stage)) {
2688
+ !allowlistedLabel(value.stage, HOMEKIT_LIVE_SESSION_STAGES)) {
2725
2689
  return undefined;
2726
2690
  }
2727
2691
  const sourceStage = allowlistedLabel(value.sourceStage, SDK_LIVE_START_STAGES);
@@ -2738,27 +2702,23 @@ function sanitizeLiveSessionTrace(value) {
2738
2702
  };
2739
2703
  }
2740
2704
  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;
2705
+ const operation = allowlistedLabel(value.operation, HOMEKIT_LIVE_VIDEO_OPERATIONS);
2706
+ const profile = allowlistedLabel(value.profile, HOMEKIT_LIVE_VIDEO_PROFILES);
2707
+ const codecLevel = allowlistedLabel(typeof value.levelName === 'string' ? value.levelName : value.level, HOMEKIT_LIVE_VIDEO_LEVELS);
2744
2708
  const width = boundedInteger(value.width, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION);
2745
2709
  const height = boundedInteger(value.height, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION);
2746
2710
  const fps = boundedInteger(value.fps, MAX_LIVE_VIDEO_FRAME_RATE, MIN_LIVE_VIDEO_FRAME_RATE);
2747
2711
  const mtu = boundedInteger(value.mtu, MAX_LIVE_VIDEO_MTU, MIN_LIVE_VIDEO_MTU);
2748
2712
  const maxBitRate = boundedInteger(value.maxBitRate, MAX_LIVE_VIDEO_BIT_RATE, 0);
2749
- const addressVersion = typeof value.addressVersion === 'string' ? value.addressVersion : undefined;
2713
+ const addressVersion = allowlistedLabel(value.addressVersion, HOMEKIT_LIVE_ADDRESS_VERSIONS);
2750
2714
  if (!operation ||
2751
- !HOMEKIT_LIVE_VIDEO_OPERATIONS.has(operation) ||
2752
2715
  !profile ||
2753
- !HOMEKIT_LIVE_VIDEO_PROFILES.has(profile) ||
2754
2716
  !codecLevel ||
2755
- !HOMEKIT_LIVE_VIDEO_LEVELS.has(codecLevel) ||
2756
2717
  width === undefined ||
2757
2718
  height === undefined ||
2758
2719
  fps === undefined ||
2759
2720
  mtu === undefined ||
2760
- !addressVersion ||
2761
- !HOMEKIT_LIVE_ADDRESS_VERSIONS.has(addressVersion)) {
2721
+ !addressVersion) {
2762
2722
  return undefined;
2763
2723
  }
2764
2724
  const alias = opaqueAccessoryAlias(value.accessory);