@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.3 → 5.0.0-beta.31

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 (88) hide show
  1. package/config.schema.json +48 -0
  2. package/dist/account/temporary-authentication.js +3 -1
  3. package/dist/account/temporary-authentication.js.map +1 -1
  4. package/dist/configuration.js +44 -2
  5. package/dist/configuration.js.map +1 -1
  6. package/dist/device/member-evidence.js +10 -3
  7. package/dist/device/member-evidence.js.map +1 -1
  8. package/dist/device/snapshot.js +1 -1
  9. package/dist/device/snapshot.js.map +1 -1
  10. package/dist/diagnostics.js +694 -83
  11. package/dist/diagnostics.js.map +1 -1
  12. package/dist/homekit/adapters/camera-streaming.js +56 -27
  13. package/dist/homekit/adapters/camera-streaming.js.map +1 -1
  14. package/dist/homekit/adapters/lock.js +45 -16
  15. package/dist/homekit/adapters/lock.js.map +1 -1
  16. package/dist/homekit/adapters/motion.js +1 -1
  17. package/dist/homekit/adapters/motion.js.map +1 -1
  18. package/dist/homekit/adapters/package-sensor.js +63 -0
  19. package/dist/homekit/adapters/package-sensor.js.map +1 -0
  20. package/dist/homekit/adapters/registry.js +2 -0
  21. package/dist/homekit/adapters/registry.js.map +1 -1
  22. package/dist/homekit/adapters/security-system.js +101 -45
  23. package/dist/homekit/adapters/security-system.js.map +1 -1
  24. package/dist/homekit/coverage-matrix.js +1 -3
  25. package/dist/homekit/coverage-matrix.js.map +1 -1
  26. package/dist/homekit/reconciler.js +21 -5
  27. package/dist/homekit/reconciler.js.map +1 -1
  28. package/dist/media/last-successful-image.js +5 -3
  29. package/dist/media/last-successful-image.js.map +1 -1
  30. package/dist/media/live-stream.js +200 -84
  31. package/dist/media/live-stream.js.map +1 -1
  32. package/dist/media/recording.js +11 -5
  33. package/dist/media/recording.js.map +1 -1
  34. package/dist/media/snapshot.js +21 -8
  35. package/dist/media/snapshot.js.map +1 -1
  36. package/dist/media/station-live-sessions.js +47 -43
  37. package/dist/media/station-live-sessions.js.map +1 -1
  38. package/dist/platform.js +27 -7
  39. package/dist/platform.js.map +1 -1
  40. package/dist/runtime/channel.js +116 -4
  41. package/dist/runtime/channel.js.map +1 -1
  42. package/dist/runtime/device-settings.js +88 -0
  43. package/dist/runtime/device-settings.js.map +1 -0
  44. package/dist/runtime/owner.js +12 -0
  45. package/dist/runtime/owner.js.map +1 -1
  46. package/dist/runtime/sdk-client.js +19 -4
  47. package/dist/runtime/sdk-client.js.map +1 -1
  48. package/dist/ui/dashboard.js +32 -14
  49. package/dist/ui/dashboard.js.map +1 -1
  50. package/dist/ui/runtime-channel-client.js +64 -1
  51. package/dist/ui/runtime-channel-client.js.map +1 -1
  52. package/dist/ui/server.js +64 -5
  53. package/dist/ui/server.js.map +1 -1
  54. package/homebridge-ui/public/app.css +359 -88
  55. package/homebridge-ui/public/assets/icons/battery_0.svg +1 -0
  56. package/homebridge-ui/public/assets/icons/battery_1.svg +1 -0
  57. package/homebridge-ui/public/assets/icons/battery_2.svg +1 -0
  58. package/homebridge-ui/public/assets/icons/battery_3.svg +1 -0
  59. package/homebridge-ui/public/assets/icons/battery_4.svg +1 -0
  60. package/homebridge-ui/public/assets/icons/battery_5.svg +1 -0
  61. package/homebridge-ui/public/assets/icons/battery_6.svg +1 -0
  62. package/homebridge-ui/public/assets/icons/battery_full.svg +1 -0
  63. package/homebridge-ui/public/assets/icons/contact_support.svg +1 -0
  64. package/homebridge-ui/public/assets/icons/delete.svg +1 -0
  65. package/homebridge-ui/public/assets/icons/download.svg +1 -0
  66. package/homebridge-ui/public/assets/icons/extension_off.svg +1 -0
  67. package/homebridge-ui/public/assets/icons/info.svg +1 -0
  68. package/homebridge-ui/public/assets/icons/key_off.svg +1 -0
  69. package/homebridge-ui/public/assets/icons/keyboard_arrow_left.svg +1 -0
  70. package/homebridge-ui/public/assets/icons/keyboard_arrow_right.svg +1 -0
  71. package/homebridge-ui/public/assets/icons/keyboard_double_arrow_left.svg +1 -0
  72. package/homebridge-ui/public/assets/icons/keyboard_double_arrow_right.svg +1 -0
  73. package/homebridge-ui/public/assets/icons/movie_off.svg +1 -0
  74. package/homebridge-ui/public/assets/icons/refresh.svg +1 -0
  75. package/homebridge-ui/public/assets/icons/satellite_alt.svg +1 -0
  76. package/homebridge-ui/public/assets/icons/search_off.svg +1 -0
  77. package/homebridge-ui/public/assets/icons/sensors_off.svg +1 -0
  78. package/homebridge-ui/public/assets/icons/settings_photo_camera.svg +1 -0
  79. package/homebridge-ui/public/assets/icons/solar_power.svg +1 -0
  80. package/homebridge-ui/public/assets/icons/videocam_off.svg +1 -0
  81. package/homebridge-ui/public/i18n/en.json +119 -78
  82. package/homebridge-ui/public/i18n/fr.json +119 -78
  83. package/homebridge-ui/public/index.html +115 -106
  84. package/homebridge-ui/public/js/app.js +354 -359
  85. package/homebridge-ui/public/js/dashboard.js +139 -10
  86. package/homebridge-ui/public/js/profile-wizard.js +17 -54
  87. package/i18n/runtime/en.json +8 -3
  88. package/package.json +9 -6
@@ -1,4 +1,4 @@
1
- import { LiveStreamStartError } from '@mega-yfue/eufy-sdk';
1
+ import { LiveStreamStartError, StationKeyUnavailableError, StationUnreachableError } from '@mega-yfue/eufy-sdk';
2
2
  import { createSocket } from 'node:dgram';
3
3
  import { execFile, spawn } from 'node:child_process';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
@@ -34,7 +34,18 @@ function canCode(running, announced) {
34
34
  */
35
35
  const MAX_SOURCE_INPUT_REPLACEMENTS = 3;
36
36
  const INITIAL_RTCP_GRACE_MS = 15_000;
37
- const SOURCE_ACQUISITION_DEADLINE_MS = 10_000;
37
+ /**
38
+ * The longest a session waits for the SDK to hand over a source, after which it cancels the acquisition.
39
+ *
40
+ * Reaching a station is inside this wait: an unconnected station is reconnected within it, and a HomeBase
41
+ * resolves its session before it can start one. The bound is therefore above what reaching a station costs and
42
+ * below both {@link SOURCE_START_BACKSTOP_MS} and the window a started request is owed an outcome within, so
43
+ * the acquisition is what this expiry reports.
44
+ *
45
+ * Expiry cancels the acquisition. A station serves one media channel, and an uncancelled acquisition holds it
46
+ * until the SDK's own wait elapses, which refuses every camera behind that station meanwhile.
47
+ */
48
+ const SOURCE_ACQUISITION_DEADLINE_MS = 25_000;
38
49
  const RETURN_AUDIO_BIND_GRACE_MS = 250;
39
50
  /**
40
51
  * Backstop for a started media session that never produces adapted output. The SDK source owns the
@@ -44,6 +55,20 @@ const RETURN_AUDIO_BIND_GRACE_MS = 250;
44
55
  * reports nothing at all.
45
56
  */
46
57
  export const SOURCE_START_BACKSTOP_MS = 30_000;
58
+ /**
59
+ * How far the RTP sequence space is advanced for each adaptation a session sends on, in packets.
60
+ *
61
+ * One session keeps one SSRC and one SRTP key across a replaced adaptation, and the SRTP packet index is built
62
+ * from the sequence number — so a replacement that started where FFmpeg would have, at a random base, is read
63
+ * by the controller as replay half the time and its packets are discarded under a key that still
64
+ * authenticates. Nothing reports it: the controller goes on acknowledging what it received before, so RTCP
65
+ * liveness holds while the picture never advances again.
66
+ *
67
+ * Advancing instead of restarting keeps the index monotonic. The step is well under half the sequence space,
68
+ * which is what keeps the receiver's own index estimate correct across the wrap it eventually crosses; a gap
69
+ * this size reads as loss, which a controller already tolerates.
70
+ */
71
+ const OUTPUT_SEQUENCE_STEP = 4_096;
47
72
  const SOURCE_ACQUISITION_TIMEOUT = Symbol('source-acquisition-timeout');
48
73
  /**
49
74
  * The bounded reason one SDK source failure reports.
@@ -55,8 +80,11 @@ const SOURCE_ACQUISITION_TIMEOUT = Symbol('source-acquisition-timeout');
55
80
  * from a transport that failed.
56
81
  */
57
82
  function sourceFailure(error) {
58
- if (error instanceof Error && error.name === 'StationBusyError') {
59
- return 'station-busy';
83
+ if (error instanceof StationUnreachableError) {
84
+ return 'station-unreachable';
85
+ }
86
+ if (error instanceof StationKeyUnavailableError) {
87
+ return 'station-key-unavailable';
60
88
  }
61
89
  return error instanceof LiveStreamStartError && error.stage === 'audio-only' ? 'source-audio-only' : 'source-error';
62
90
  }
@@ -152,6 +180,10 @@ export async function resolveFfmpegIdentity(path, source, probe = probeFfmpegVer
152
180
  const version = await probe(path).catch(() => undefined);
153
181
  return { path, source, ...(version === undefined ? {} : { version }) };
154
182
  }
183
+ /** The `libx264` constant rate factor a live adaptation codes at when the negotiated bit rate is ignored. */
184
+ const CONSTANT_QUALITY_CRF = 23;
185
+ /** The live video packet size an administrator can choose for networks that drop the negotiated one. */
186
+ export const SMALL_VIDEO_PACKET_SIZE = 1128;
155
187
  /** Adapts separate SDK elementary streams into independently failing HomeKit SRTP outputs. */
156
188
  export class FfmpegLiveMedia {
157
189
  executable;
@@ -159,12 +191,18 @@ export class FfmpegLiveMedia {
159
191
  createProcess;
160
192
  reservePort;
161
193
  createReturnAudioProcess;
162
- constructor(executable, adaptationDiagnostics, createProcess = spawnLiveMediaProcess, reservePort = reserveMediaPort, createReturnAudioProcess = spawnReturnAudioProcess) {
194
+ maxVideoPacketSize;
195
+ copyVideo;
196
+ constantQuality;
197
+ constructor(executable, adaptationDiagnostics, createProcess = spawnLiveMediaProcess, reservePort = reserveMediaPort, createReturnAudioProcess = spawnReturnAudioProcess, maxVideoPacketSize = Number.POSITIVE_INFINITY, copyVideo = false, constantQuality = false) {
163
198
  this.executable = executable;
164
199
  this.adaptationDiagnostics = adaptationDiagnostics;
165
200
  this.createProcess = createProcess;
166
201
  this.reservePort = reservePort;
167
202
  this.createReturnAudioProcess = createReturnAudioProcess;
203
+ this.maxVideoPacketSize = maxVideoPacketSize;
204
+ this.copyVideo = copyVideo;
205
+ this.constantQuality = constantQuality;
168
206
  }
169
207
  /**
170
208
  * Reserves the negotiated output ports so `SetupEndpoints` can be answered with them, and returns a
@@ -175,6 +213,9 @@ export class FfmpegLiveMedia {
175
213
  async prepare(transport) {
176
214
  const videoPort = await this.reservePort(transport.addressVersion);
177
215
  const targetAddress = transport.addressVersion === 'ipv6' ? `[${transport.targetAddress}]` : transport.targetAddress;
216
+ const maxVideoPacketSize = this.maxVideoPacketSize;
217
+ const copyVideo = this.copyVideo;
218
+ const constantQuality = this.constantQuality;
178
219
  let audioPort;
179
220
  try {
180
221
  audioPort = transport.audio ? await this.reservePort(transport.addressVersion) : undefined;
@@ -184,6 +225,13 @@ export class FfmpegLiveMedia {
184
225
  throw error;
185
226
  }
186
227
  let source;
228
+ /**
229
+ * Cancels the source acquisition in flight, and is held for exactly as long as one is.
230
+ *
231
+ * The station's one media channel is held for the whole of an uncancelled acquisition, so a session that
232
+ * ends during one cancels it rather than leaving it to the SDK's own wait.
233
+ */
234
+ let acquiring;
187
235
  let videoProcess;
188
236
  let audioProcess;
189
237
  let returnAudioProcess;
@@ -194,7 +242,6 @@ export class FfmpegLiveMedia {
194
242
  let negotiated;
195
243
  let stopped = false;
196
244
  let receivedVideoKeyframe = false;
197
- let reconfigurationPending = false;
198
245
  /**
199
246
  * The coded configuration the source last announced, and the one the running adaptation was opened for.
200
247
  *
@@ -213,6 +260,13 @@ export class FfmpegLiveMedia {
213
260
  let videoStartBackstop;
214
261
  let videoFailed = false;
215
262
  let rtcpObserved = false;
263
+ /** How many receiver reports this session was acknowledged by, and when the last one arrived. */
264
+ /** Why the video adaptation now running was started, which the record reporting it carries. */
265
+ let videoCause = 'first';
266
+ /** Where the next adaptation this session sends on starts its RTP sequence, advanced for every one. */
267
+ let videoSequence = 0;
268
+ let rtcpReports = 0;
269
+ let lastRtcpAt;
216
270
  let streaming = false;
217
271
  const stoppingProcesses = new WeakSet();
218
272
  const congested = new Set();
@@ -320,33 +374,43 @@ export class FfmpegLiveMedia {
320
374
  return;
321
375
  }
322
376
  stopped = true;
377
+ acquiring?.abort(new Error('live media session stopped'));
323
378
  clearTimeout(rtcpDeadline);
324
379
  clearTimeout(initialRtcpGrace);
325
380
  clearTimeout(videoStartBackstop);
326
381
  stopTalkback();
327
382
  stopProcess(videoProcess);
328
383
  stopProcess(audioProcess);
329
- if (source) {
330
- source.stop();
331
- transport.onSessionReleased?.();
384
+ source?.stop();
385
+ if (negotiated) {
386
+ transport.onSessionReleased?.(videoFailed ? 'failed' : 'requested', {
387
+ reports: rtcpReports,
388
+ ...(lastRtcpAt === undefined ? {} : { sinceLastReportMs: Date.now() - lastRtcpAt }),
389
+ }, ...(source === undefined ? ['sdk-source-acquisition'] : []));
332
390
  }
333
391
  videoPort.close();
334
392
  audioPort?.close();
335
393
  };
336
- const failVideo = (reason) => {
394
+ const failVideo = (reason, error) => {
337
395
  if (stopped || videoFailed) {
338
396
  return;
339
397
  }
340
398
  videoFailed = true;
341
399
  stop();
400
+ const warmUp = error instanceof LiveStreamStartError;
342
401
  const stage = reason === 'source-acquisition-timeout' || source === undefined
343
402
  ? 'sdk-source-acquisition'
344
403
  : reason === 'rtcp-timeout'
345
404
  ? 'controller-rtcp'
346
- : !receivedVideoKeyframe
405
+ : warmUp || !receivedVideoKeyframe
347
406
  ? 'first-source-keyframe'
348
407
  : 'first-adapted-output';
349
- transport.onSessionOutcome?.({ outcome: 'failed', reason, stage });
408
+ transport.onSessionOutcome?.({
409
+ outcome: 'failed',
410
+ reason,
411
+ stage,
412
+ ...(warmUp ? { sourceStage: error.stage, sourceReason: error.reason } : {}),
413
+ });
350
414
  transport.onVideoFailure?.();
351
415
  };
352
416
  /**
@@ -357,6 +421,8 @@ export class FfmpegLiveMedia {
357
421
  */
358
422
  const resetRtcpDeadline = () => {
359
423
  rtcpObserved = true;
424
+ rtcpReports += 1;
425
+ lastRtcpAt = Date.now();
360
426
  clearTimeout(initialRtcpGrace);
361
427
  clearTimeout(rtcpDeadline);
362
428
  if (!streaming) {
@@ -371,13 +437,9 @@ export class FfmpegLiveMedia {
371
437
  * Adaptation reached the negotiated output, so the session is bounded from here by RTCP liveness
372
438
  * rather than by the start backstop. The initial grace is armed from this point because media may
373
439
  * legitimately start well after the session does.
374
- *
375
- * A selection HomeKit has already replaced does not discharge the deadline for its replacement, however
376
- * much it keeps reporting: FFmpeg reports progress on a timer whether or not new media reaches it, so
377
- * only the adaptation that carries the current selection can say that selection is being served.
378
440
  */
379
- const observeAdaptationProgress = (reporter) => {
380
- if (stopped || (reconfigurationPending && reporter === videoProcess)) {
441
+ const observeAdaptationProgress = () => {
442
+ if (stopped) {
381
443
  return;
382
444
  }
383
445
  clearTimeout(videoStartBackstop);
@@ -406,13 +468,12 @@ export class FfmpegLiveMedia {
406
468
  * Writes one source access unit to the adaptation that is entitled to code it.
407
469
  *
408
470
  * A source configuration the running adaptation was not opened for cannot be coded by it at all, so those
409
- * frames wait for the keyframe that lets a replacement start. A reconfigured HomeKit selection changes
410
- * only the output, so the current process keeps coding and keeps the previous selection on the wire until
411
- * that same keyframe arrives; a controller reconfigures precisely when it is unhappy with what it
412
- * receives, and the source is then often the very thing not producing keyframes.
471
+ * frames wait for the keyframe that lets a replacement start.
413
472
  *
414
473
  * Source-driven replacement is bounded by {@link MAX_SOURCE_INPUT_REPLACEMENTS}, past which the session
415
- * fails rather than spawn again. A controller-driven one is not: the controller bounds how often it asks.
474
+ * fails rather than spawn again. It is the only replacement a session makes: a source that changes what it
475
+ * delivers cannot be coded by the running process at all, whereas a controller's renegotiated selection is
476
+ * a request this session declines in favour of the one it started on.
416
477
  */
417
478
  const writeVideo = (frame) => {
418
479
  if (stopped || !negotiated || !videoConfig) {
@@ -428,29 +489,28 @@ export class FfmpegLiveMedia {
428
489
  if (videoProcess && inputChanged && !frame.keyframe) {
429
490
  return;
430
491
  }
431
- if (videoProcess && frame.keyframe && (inputChanged || reconfigurationPending)) {
432
- if (inputChanged) {
433
- sourceInputReplacements += 1;
434
- }
492
+ if (videoProcess && frame.keyframe && inputChanged) {
493
+ sourceInputReplacements += 1;
435
494
  if (sourceInputReplacements > MAX_SOURCE_INPUT_REPLACEMENTS) {
436
495
  failVideo('source-input-unstable');
437
496
  return;
438
497
  }
439
- reconfigurationPending = false;
498
+ videoCause = 'source-configuration';
440
499
  stopProcess(videoProcess);
441
500
  videoProcess = undefined;
442
501
  }
443
502
  if (!videoProcess) {
444
503
  adaptationConfig = videoConfig;
445
- const child = this.createProcess(this.executable, videoArguments(videoConfig, negotiated.video, targetAddress, transport.video));
504
+ videoSequence = (videoSequence + OUTPUT_SEQUENCE_STEP) & 0xffff;
505
+ const child = this.createProcess(this.executable, videoArguments(videoConfig, negotiated.video, targetAddress, transport.video, videoSequence, copyVideo, constantQuality));
446
506
  videoProcess = child;
447
- adaptationDiagnostics?.report({ role: 'live-video', event: 'started' });
507
+ adaptationDiagnostics?.report({ role: 'live-video', event: 'started', cause: videoCause });
448
508
  const stderr = new AdaptationStderr();
449
509
  let producedOutput = false;
450
510
  child.stderr.on('data', (chunk) => {
451
511
  if (stderr.observe(chunk).some((line) => line.startsWith('progress='))) {
452
512
  producedOutput = true;
453
- observeAdaptationProgress(child);
513
+ observeAdaptationProgress();
454
514
  }
455
515
  });
456
516
  child.stdin.on('error', () => {
@@ -485,6 +545,9 @@ export class FfmpegLiveMedia {
485
545
  audioInputCodec = frame.codec;
486
546
  const child = this.createProcess(this.executable, audioArguments(frame, negotiated.audio, targetAddress, transport.audio));
487
547
  audioProcess = child;
548
+ if (frame.config) {
549
+ writeToAdaptation(child.stdin, Buffer.concat([FLV_FILE_HEADER, flvAacTag(0, frame.config)]));
550
+ }
488
551
  adaptationDiagnostics?.report({ role: 'live-audio', event: 'started' });
489
552
  const stderr = new AdaptationStderr();
490
553
  let producedOutput = false;
@@ -535,7 +598,7 @@ export class FfmpegLiveMedia {
535
598
  writeAudio(frame);
536
599
  return;
537
600
  }
538
- writeToAdaptation(audioProcess.stdin, frame.data);
601
+ writeToAdaptation(audioProcess.stdin, frame.config ? flvAacTag(1, frame.data) : frame.data);
539
602
  };
540
603
  /**
541
604
  * Write one decoded return-audio chunk through the SDK writable that owns ADTS frame recovery,
@@ -668,7 +731,9 @@ export class FfmpegLiveMedia {
668
731
  return;
669
732
  }
670
733
  reportAdaptation('return-audio', producedOutput ? 'exited-while-streaming' : 'exited-before-output', stderr, code, signal);
671
- failTalkback('adaptation-failed');
734
+ failTalkback(stderr.tail().some((line) => line.includes('Error during demuxing: Operation timed out'))
735
+ ? 'no-controller-audio'
736
+ : 'adaptation-failed');
672
737
  });
673
738
  child.stdin.end(returnAudioSdp(audioPort.port, selection, transport.audio, transport.addressVersion));
674
739
  await delay(RETURN_AUDIO_BIND_GRACE_MS);
@@ -684,12 +749,14 @@ export class FfmpegLiveMedia {
684
749
  if (stopped) {
685
750
  throw new Error('live media session is already stopped');
686
751
  }
687
- negotiated = selection;
752
+ negotiated = { ...selection, video: { ...selection.video, mtu: Math.min(selection.video.mtu, maxVideoPacketSize) } };
688
753
  const returnAudioReady = startReturnAudio(camera, selection.audio);
689
754
  let sourcePromise;
690
755
  let acquisitionDeadline;
756
+ const acquisition = new AbortController();
757
+ acquiring = acquisition;
691
758
  try {
692
- sourcePromise = camera.live();
759
+ sourcePromise = camera.live({ signal: acquisition.signal });
693
760
  void sourcePromise.then((lateSource) => {
694
761
  if (stopped && source !== lateSource) {
695
762
  lateSource.stop();
@@ -704,11 +771,13 @@ export class FfmpegLiveMedia {
704
771
  ]);
705
772
  }
706
773
  catch (error) {
707
- failVideo(error === SOURCE_ACQUISITION_TIMEOUT ? 'source-acquisition-timeout' : sourceFailure(error));
774
+ acquisition.abort(new Error('live media source acquisition was cancelled'));
775
+ failVideo(error === SOURCE_ACQUISITION_TIMEOUT ? 'source-acquisition-timeout' : sourceFailure(error), error);
708
776
  throw error === SOURCE_ACQUISITION_TIMEOUT ? new Error('live media source acquisition timed out') : error;
709
777
  }
710
778
  finally {
711
779
  clearTimeout(acquisitionDeadline);
780
+ acquiring = undefined;
712
781
  }
713
782
  if (stopped) {
714
783
  source.stop();
@@ -724,43 +793,25 @@ export class FfmpegLiveMedia {
724
793
  notice.extend();
725
794
  }
726
795
  });
727
- source.on('error', (error) => failVideo(sourceFailure(error)));
796
+ source.on('error', (error) => failVideo(sourceFailure(error), error));
728
797
  source.on('stop', () => failVideo('source-stopped'));
729
798
  await returnAudioReady;
730
799
  },
731
- /**
732
- * Acknowledges a reconfigured selection and applies it at the next source keyframe.
733
- *
734
- * Nothing is torn down here: the current adaptation keeps the previous selection on the wire until the
735
- * replacement has a keyframe to start from. The deferral is still bounded, because a session that never
736
- * applies the selection HomeKit asked for must end and be renegotiated rather than silently serve the
737
- * old one forever.
738
- */
739
- reconfigure(video) {
740
- if (!negotiated) {
741
- return;
742
- }
743
- negotiated = { ...negotiated, video };
744
- reconfigurationPending = videoProcess !== undefined;
745
- clearTimeout(videoStartBackstop);
746
- videoStartBackstop = setTimeout(() => failVideo('no-video-within-backstop'), SOURCE_START_BACKSTOP_MS);
747
- videoStartBackstop.unref?.();
748
- },
749
800
  stop,
750
801
  };
751
802
  }
752
803
  }
753
804
  /**
754
- * Options every adapted elementary stream applies to its own input.
805
+ * Options every adapted input applies to itself: a piped elementary stream, or audio framed as FLV.
755
806
  *
756
- * A piped elementary stream carries no container, so FFmpeg's initial stream analysis is the only thing
757
- * standing between the first written access unit and the first coded frame. The caller already declares the
758
- * format, so that analysis has nothing left to discover and is bounded to its minimum; leaving it at the
807
+ * FFmpeg's initial stream analysis is the only thing standing between the first written access unit and the
808
+ * first coded frame. The caller already declares the format, and an FLV input states its decoder config in its
809
+ * first tag, so that analysis has nothing left to discover and is bounded to its minimum; leaving it at the
759
810
  * default delays first output by seconds and scales that delay with the source keyframe interval.
760
811
  *
761
- * Every such input is read on one clock: when its media arrived. A pipe carries no timeline of its own, so
762
- * arrival is the only timeline there is, and two adapted streams of one session can be presented together
763
- * only while both are read on it.
812
+ * Every such input is read on one clock: when its media arrived. An elementary stream carries no timeline of its
813
+ * own and the FLV tags this plugin writes carry none either, so arrival is the only timeline there is, and two
814
+ * adapted streams of one session can be presented together only while both are read on it.
764
815
  *
765
816
  * Nothing asks FFmpeg to discard or reinterpret what it read. Discarding the analysed packets throws away the
766
817
  * leading keyframe the caller waited for, and a raw A-law demuxer stops emitting timestamps entirely, so both
@@ -787,12 +838,21 @@ function commonArguments(inputFormat, inputOptions = []) {
787
838
  'pipe:0',
788
839
  ];
789
840
  }
790
- function outputArguments(targetAddress, target, payloadType, ssrc, packetSize) {
841
+ /**
842
+ * The RTP output one adaptation sends on: the controller's endpoint, the identity the session negotiated for
843
+ * it, and where its sequence numbering starts.
844
+ *
845
+ * `sequence` is stated wherever a session may send on more than one adaptation, because the identity and the
846
+ * SRTP key outlive the process: see {@link OUTPUT_SEQUENCE_STEP} for what a restarted numbering costs. An
847
+ * output no replacement can follow omits it and keeps FFmpeg's own base.
848
+ */
849
+ function outputArguments(targetAddress, target, payloadType, ssrc, packetSize, sequence) {
791
850
  return [
792
851
  '-payload_type',
793
852
  String(payloadType),
794
853
  '-ssrc',
795
854
  String(ssrc),
855
+ ...(sequence === undefined ? [] : ['-seq', String(sequence)]),
796
856
  '-f',
797
857
  'rtp',
798
858
  '-srtp_out_suite',
@@ -811,11 +871,19 @@ function outputArguments(targetAddress, target, payloadType, ssrc, packetSize) {
811
871
  * every frame it was given.
812
872
  *
813
873
  * A negotiated frame rate is a CEILING, not a cadence, so the rate is bounded rather than pinned. Pinning it
814
- * makes the encoder duplicate frames the source never sent, and each duplicate costs a full encode and a share
815
- * of the negotiated bit rate for a picture carrying nothing new. Measured on a 1600x1200 doorbell delivering
816
- * about 15 fps against a 30 fps selection: 264 of 267 emitted frames were duplicates and the adaptation ran at
817
- * 0.32x real time, so it fell further behind every second and the session died on its backstop with nothing
818
- * watchable reaching the controller.
874
+ * with `-r` makes the encoder interpolate a cadence a bare Annex-B pipe never states, which collapses a session
875
+ * onto one instant.
876
+ *
877
+ * The output is constant-rate, which fills a gap in arrival with a duplicate of the last picture rather than
878
+ * carrying the gap. A duplicate costs a full encode and a share of the negotiated bit rate for a frame carrying
879
+ * nothing new, and a source over this transport gaps as its normal case: measured on the bundled encoder against
880
+ * a 15 fps arrival stalled 1.5 seconds after every second of media, a constant rate emitted 266 frames of which
881
+ * 146 were duplicates, against 120 frames and none when the arrival timestamps were passed through, at the same
882
+ * 0.90x real time either way. What the duplicates buy is a dense presentation timeline: a controller shown the
883
+ * gaps instead displays a picture that visibly jumps at every one of them.
884
+ *
885
+ * `-fpsmax` states that ceiling for a constant-rate output only, and FFmpeg refuses it beside any other
886
+ * frame-rate mode, so the two cannot be combined.
819
887
  *
820
888
  * A negotiated selection states no refresh cadence, so the keyframe interval is plugin policy: `-g` and
821
889
  * `-keyint_min` are the negotiated rate doubled and scene-cut detection is off, which is a refresh every two
@@ -834,9 +902,29 @@ function outputArguments(targetAddress, target, payloadType, ssrc, packetSize) {
834
902
  * coded stream can carry a negotiated Main or High profile; `ultrafast` drops CABAC and codes Constrained
835
903
  * Baseline whatever `-profile:v` asks for. `-tune zerolatency` pins the same `sliced_threads`, `bframes`
836
904
  * and `rc_lookahead` at either preset, so this costs computation rather than frame delay.
905
+ *
906
+ * `copy` sends an H.264 source as the camera coded it, with no encoder in the process. Nothing above then holds:
907
+ * the geometry, frame rate, bit rate, profile and keyframe interval are the camera's own, not the negotiated
908
+ * selection. Only the RTP output is the negotiated one. An H.265 source is transcoded either way.
909
+ *
910
+ * `constantQuality` codes at {@link CONSTANT_QUALITY_CRF} with no rate control, so the negotiated bit rate is not
911
+ * honoured: the output spends what the picture needs. A controller asks 299 kbps for 720p and does not raise it
912
+ * during a session, which the ceiling above codes as a blurred, then blocky, picture.
837
913
  */
838
- function videoArguments(input, selection, targetAddress, target) {
914
+ function videoArguments(input, selection, targetAddress, target, sequence, copy = false, constantQuality = false) {
915
+ if (copy && input.codec === 'h264') {
916
+ return [
917
+ ...commonArguments('h264'),
918
+ '-an',
919
+ '-c:v',
920
+ 'copy',
921
+ ...outputArguments(targetAddress, target, selection.payloadType, selection.ssrc, selection.mtu, sequence),
922
+ ];
923
+ }
839
924
  const budget = videoBudgetInsideCeiling(selection);
925
+ const rate = constantQuality
926
+ ? ['-crf', String(CONSTANT_QUALITY_CRF)]
927
+ : ['-b:v', `${budget}k`, '-maxrate', `${budget}k`, '-bufsize', `${budget}k`];
840
928
  return [
841
929
  ...commonArguments(input.codec === 'h265' ? 'hevc' : input.codec),
842
930
  '-an',
@@ -862,22 +950,51 @@ function videoArguments(input, selection, targetAddress, target) {
862
950
  String(selection.fps * 2),
863
951
  '-sc_threshold',
864
952
  '0',
865
- '-b:v',
866
- `${budget}k`,
867
- '-maxrate',
868
- `${budget}k`,
869
- '-bufsize',
870
- `${budget}k`,
871
- ...outputArguments(targetAddress, target, selection.payloadType, selection.ssrc, selection.mtu),
953
+ ...rate,
954
+ ...outputArguments(targetAddress, target, selection.payloadType, selection.ssrc, selection.mtu, sequence),
872
955
  ];
873
956
  }
957
+ /** FLV signature, version 1, the audio-only flag, header size 9, and the zero size of the tag before the first. */
958
+ const FLV_FILE_HEADER = Buffer.from([0x46, 0x4c, 0x56, 0x01, 0x04, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00]);
874
959
  /**
875
- * Adapts one source audio elementary stream to the negotiated AAC-ELD output.
960
+ * One FLV audio tag of an AAC elementary stream and its trailing size. The body is the AAC sound flags 0xaf, the AAC
961
+ * packet type (0 for the AudioSpecificConfig, 1 for one access unit) and `payload` unchanged. The sound flags fix the
962
+ * 44 kHz, 16-bit, stereo values the format requires; an AAC decoder takes the real parameters from the
963
+ * AudioSpecificConfig. The timestamp, its extension and the stream id are zero: the stream carries no timeline of its
964
+ * own, because its reader stamps every tag with the instant it arrived.
965
+ */
966
+ function flvAacTag(packetType, payload) {
967
+ const header = Buffer.alloc(13);
968
+ header[0] = 8;
969
+ header.writeUIntBE(payload.length + 2, 1, 3);
970
+ header[11] = 0xaf;
971
+ header[12] = packetType;
972
+ const previousTagSize = Buffer.alloc(4);
973
+ previousTagSize.writeUInt32BE(13 + payload.length);
974
+ return Buffer.concat([header, payload, previousTagSize]);
975
+ }
976
+ /**
977
+ * The FFmpeg input format and input options for one source audio stream.
876
978
  *
877
- * The SDK reports no sample rate or channel count, because a station sends neither; 16 kHz mono is the
878
- * assumption every Eufy client applies. Raw A-law carries nothing at all and must be told that assumption,
879
- * while an ADTS input states its own rate in every frame header and rejects the option outright, which fails
880
- * the process before it reads a byte.
979
+ * A stream whose decoder config travels apart from its access units is framed as FLV, which states that config
980
+ * once ahead of them, and is decoded by `libfdk_aac`, which decodes AAC-ELD in the config the SDK declares. Outside
981
+ * such a config the SDK reports no sample rate or channel count, because a station sends neither;
982
+ * 16 kHz mono is the assumption every Eufy client applies. Raw A-law carries nothing at all and must be told that
983
+ * assumption, while an ADTS input states its own rate in every frame header and rejects the option outright, which
984
+ * fails the process before it reads a byte.
985
+ */
986
+ function audioInput(frame) {
987
+ if (frame.config) {
988
+ return ['flv', ['-c:a', 'libfdk_aac']];
989
+ }
990
+ if (frame.codec === 'g711a') {
991
+ return ['alaw', ['-ar', '16k', '-ac', '1']];
992
+ }
993
+ return ['aac', []];
994
+ }
995
+ /**
996
+ * Adapts one source audio elementary stream to the negotiated AAC-ELD output, reading it as {@link audioInput}
997
+ * describes.
881
998
  *
882
999
  * Asynchronous resampling is what makes this output honour the arrival clock its input is read on. An encoder
883
1000
  * advances its output timeline by the samples in each coded frame it is handed, whatever timestamp arrived
@@ -890,13 +1007,12 @@ function videoArguments(input, selection, targetAddress, target) {
890
1007
  * accident of the filter: a live view owes its controller the newest audio, and audio which is already
891
1008
  * seconds stale by the time it arrives cannot be presented beside a picture timestamped on arrival.
892
1009
  *
893
- * `libfdk_aac` selects its transport from the requested output framing, and AAC-ELD cannot be carried in
1010
+ * The `libfdk_aac` encoder selects its transport from the requested output framing, and AAC-ELD cannot be carried in
894
1011
  * ADTS, so without an explicit global header the encoder refuses to initialise at all.
895
1012
  */
896
1013
  function audioArguments(frame, selection, targetAddress, target) {
897
- const rawAlaw = frame.codec === 'g711a';
898
1014
  return [
899
- ...commonArguments(rawAlaw ? 'alaw' : 'aac', rawAlaw ? ['-ar', '16k', '-ac', '1'] : []),
1015
+ ...commonArguments(...audioInput(frame)),
900
1016
  '-vn',
901
1017
  '-c:a',
902
1018
  'libfdk_aac',