homebridge-nest-accfactory 0.4.3 → 0.4.4-beta.2

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.
@@ -112,7 +112,7 @@ export default class HomeKitDevice extends EventEmitter {
112
112
  static EVEHOME = undefined; // HomeKitHistory object
113
113
  static LOGGER = undefined; // Logging object
114
114
  static TYPE = 'base'; // String naming type of device
115
- static VERSION = '2026.05.26'; // Code version
115
+ static VERSION = '2026.08.18'; // Code version
116
116
 
117
117
  // Backend types
118
118
  static HOMEBRIDGE = 'homebridge';
@@ -313,7 +313,7 @@ export default class HomeKitDevice extends EventEmitter {
313
313
 
314
314
  // If using HAP-NodeJS library, publish accessory on local network
315
315
  if (this.accessory !== undefined && this.backend === HomeKitDevice.HAP_NODEJS) {
316
- this.accessory.publish({
316
+ await this.accessory.publish({
317
317
  username: this.accessory.username,
318
318
  pincode: this.accessory.pincode,
319
319
  category: this.accessory.category,
@@ -623,7 +623,15 @@ export default class HomeKitDevice extends EventEmitter {
623
623
  }
624
624
 
625
625
  if (this.accessory !== undefined && this.#platform === undefined) {
626
- this.accessory.unpublish();
626
+ try {
627
+ await this.accessory.unpublish();
628
+ } catch (error) {
629
+ this?.log?.warn?.(
630
+ 'Failed to unpublish standalone accessory "%s": %s',
631
+ this.deviceData.description,
632
+ String(error?.stack || error),
633
+ );
634
+ }
627
635
  }
628
636
 
629
637
  this.deviceData = {};
@@ -727,18 +735,20 @@ export default class HomeKitDevice extends EventEmitter {
727
735
  }
728
736
 
729
737
  if (skipHistory === false) {
730
- this.historyService.addHistory(
738
+ let historyResult = await this.historyService.addHistory(
731
739
  target,
732
740
  entry,
733
741
  Number.isFinite(Number(options?.timegap)) === true ? Number(options.timegap) : undefined,
734
742
  );
743
+
744
+ if (historyResult !== false) {
745
+ // Notify hooks only after the entry was accepted by the history service.
746
+ await callLifecycleHook('onHistory', target, entry, options);
747
+ await callLifecycleHook(['handler for HISTORY', handler], target, entry, options);
748
+ }
735
749
  }
736
750
  }
737
751
 
738
- // Call the onHistory method and after any static handler registered via HomeKitDevice.message(uuid, type, handler)
739
- await callLifecycleHook('onHistory', target, entry, options);
740
- await callLifecycleHook(['handler for HISTORY', handler], target, entry, options);
741
-
742
752
  handled = true;
743
753
  }
744
754
 
@@ -893,8 +903,14 @@ export default class HomeKitDevice extends EventEmitter {
893
903
 
894
904
  // delay + interval => fire once after delay, then repeat
895
905
  entry.timeout = setTimeout(() => {
896
- fire();
897
906
  entry.timeout = undefined;
907
+ fire();
908
+
909
+ // A synchronous first callback may remove or replace this timer. Do not
910
+ // create an interval that is no longer owned by the timer registry.
911
+ if (entry.cancelled === true || this.#timers.get(timerHandle) !== entry) {
912
+ return;
913
+ }
898
914
 
899
915
  entry.intervalHandle = setInterval(() => {
900
916
  fire();
@@ -1132,10 +1148,9 @@ export default class HomeKitDevice extends EventEmitter {
1132
1148
 
1133
1149
  return typeof name === 'string'
1134
1150
  ? name
1135
- .replace(/[^\p{L}\p{N}\p{Zs}\u2019'&!._:;()\/,-]/gu, '')
1136
- .replace(/^[^\p{L}\p{N}]+/gu, '')
1137
- .replace(/[^\p{L}\p{N}]+$/gu, '')
1138
- .trim()
1151
+ .replace(/[^\p{L}\p{N}\p{Zs}\u2019'&!._:;()\/,-]/gu, '')
1152
+ .replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '')
1153
+ .trim() || 'Unknown Device'
1139
1154
  : name;
1140
1155
  }
1141
1156
 
package/dist/ffmpeg.js CHANGED
@@ -29,7 +29,7 @@
29
29
  // - Logging and debugging are handled by the calling module
30
30
  // - Binary validation and capability checks are performed during initialisation
31
31
  //
32
- // Code version 2026.05.06
32
+ // Code version 2026.09.05
33
33
  // Mark Hulskamp
34
34
  'use strict';
35
35
 
@@ -298,6 +298,30 @@ export default class FFmpeg {
298
298
 
299
299
  // Linux: prioritise nvenc > qsv > vaapi > v4l2m2m, only if required devices exist
300
300
  else if (platform === 'linux') {
301
+ // Pi codec nodes are dynamically numbered; identify the encoder rather than a camera or decoder.
302
+ try {
303
+ if (fs.readFileSync('/sys/firmware/devicetree/base/model', 'utf8').startsWith('Raspberry Pi') === true) {
304
+ hasVideo = false;
305
+ hasVideo = fs.readdirSync('/sys/class/video4linux').some((device) => {
306
+ if (/^video\d+$/.test(device) === false) {
307
+ return false;
308
+ }
309
+ try {
310
+ if (fs.readFileSync('/sys/class/video4linux/' + device + '/name', 'utf8').trim() !== 'bcm2835-codec-encode') {
311
+ return false;
312
+ }
313
+ fs.accessSync('/dev/' + device, fs.constants.R_OK | fs.constants.W_OK);
314
+ return true;
315
+ } catch {
316
+ // A missing or inaccessible node is not available to this process.
317
+ return false;
318
+ }
319
+ });
320
+ }
321
+ } catch {
322
+ // Missing host metadata keeps the generic check; missing Pi codec metadata leaves acceleration disabled.
323
+ }
324
+
301
325
  let linuxEncoders = [
302
326
  { key: 'h264_nvenc', device: hasDri },
303
327
  { key: 'h264_qsv', device: hasIntelQSV },
package/dist/streamer.js CHANGED
@@ -67,7 +67,7 @@
67
67
  // - MediaTimeline owns retained buffer/index stats
68
68
  // - Streamer owns per-output write/drop/playout stats
69
69
  //
70
- // Code version 2026.05.18
70
+ // Code version 2026.08.20
71
71
  // Mark Hulskamp
72
72
  'use strict';
73
73
 
@@ -119,6 +119,9 @@ const OUTPUT_PLAYOUT_POLICY = {
119
119
  requireKeyFrameStart: true,
120
120
  allowAudioBeforeKeyFrame: false,
121
121
  playoutDelayMs: 200,
122
+ minPlayoutDelayMs: 200,
123
+ maxPlayoutDelayMs: 200,
124
+ playoutAdjustStepMs: 20,
122
125
  maxLagBehindLiveMs: 1200,
123
126
  dueTolerance: 12,
124
127
  dueSlack: 20,
@@ -893,30 +896,33 @@ export default class Streamer {
893
896
  // - preserves continuity while draining retained media
894
897
  policy: { ...(OUTPUT_PLAYOUT_POLICY[type] ?? OUTPUT_PLAYOUT_POLICY.live) },
895
898
 
896
- // Debug / instrumentation stats (used for tuning pacing behaviour).
897
- stats: {
898
- startedAt: Date.now(),
899
- firstWriteAt: undefined,
900
- firstVideoWriteAt: undefined,
901
- firstAudioWriteAt: undefined,
902
- writes: { total: 0, video: 0, audio: 0 },
903
- drops: { videoBeforeKeyframe: 0, audioBeforeKeyframe: 0, bufferTrimmed: 0 },
904
- diagnostics: {
905
- lastVideoWriteAt: undefined,
906
- lastAudioWriteAt: undefined,
907
- maxVideoWriteGapMs: 0,
908
- maxAudioWriteGapMs: 0,
909
- audioWriteGapsOver100Ms: 0,
910
- audioWriteGapsOver250Ms: 0,
911
- audioWriteGapsOver500Ms: 0,
912
- audioBlockedBehindVideo: 0,
913
- videoBackpressureStops: 0,
914
- maxAudioQueuedBytes: 0,
915
- maxBlockedAudioLagMs: 0,
916
- lastAudioDiagnosticWriteAt: undefined,
917
- lastAudioDiagnosticLogTime: undefined,
918
- },
919
- },
899
+ // Debug instrumentation is not allocated for normal streaming.
900
+ stats:
901
+ this.supportDump === true
902
+ ? {
903
+ startedAt: Date.now(),
904
+ firstWriteAt: undefined,
905
+ firstVideoWriteAt: undefined,
906
+ firstAudioWriteAt: undefined,
907
+ writes: { total: 0, video: 0, audio: 0 },
908
+ drops: { videoBeforeKeyframe: 0, audioBeforeKeyframe: 0, bufferTrimmed: 0 },
909
+ diagnostics: {
910
+ lastVideoWriteAt: undefined,
911
+ lastAudioWriteAt: undefined,
912
+ maxVideoWriteGapMs: 0,
913
+ maxAudioWriteGapMs: 0,
914
+ audioWriteGapsOver100Ms: 0,
915
+ audioWriteGapsOver250Ms: 0,
916
+ audioWriteGapsOver500Ms: 0,
917
+ audioBlockedBehindVideo: 0,
918
+ videoBackpressureStops: 0,
919
+ maxAudioQueuedBytes: 0,
920
+ maxBlockedAudioLagMs: 0,
921
+ lastAudioDiagnosticWriteAt: undefined,
922
+ lastAudioDiagnosticLogTime: undefined,
923
+ },
924
+ }
925
+ : undefined,
920
926
  };
921
927
 
922
928
  // Attach talkback handling for live streams.
@@ -1210,6 +1216,10 @@ export default class Streamer {
1210
1216
  }
1211
1217
 
1212
1218
  #statsWrite(output, type, dateNow) {
1219
+ if (this.supportDump !== true) {
1220
+ return;
1221
+ }
1222
+
1213
1223
  let outputStats = output?.stats;
1214
1224
  let outputWrites = outputStats?.writes;
1215
1225
  let diagnostics = outputStats?.diagnostics;
@@ -1221,7 +1231,6 @@ export default class Streamer {
1221
1231
  gapMs = dateNow - diagnostics.lastAudioDiagnosticWriteAt;
1222
1232
 
1223
1233
  if (
1224
- this.supportDump === true &&
1225
1234
  gapMs >= STREAMER_AUDIO_GAP_LOG_MS &&
1226
1235
  (typeof diagnostics.lastAudioDiagnosticLogTime !== 'number' ||
1227
1236
  dateNow - diagnostics.lastAudioDiagnosticLogTime >= STREAMER_AUDIO_GAP_LOG_INTERVAL_MS)
@@ -1247,10 +1256,6 @@ export default class Streamer {
1247
1256
  diagnostics.lastAudioDiagnosticWriteAt = dateNow;
1248
1257
  }
1249
1258
 
1250
- if (this.supportDump !== true) {
1251
- return;
1252
- }
1253
-
1254
1259
  if (typeof outputStats !== 'object' || outputStats === null) {
1255
1260
  return;
1256
1261
  }
@@ -1410,7 +1415,6 @@ export default class Streamer {
1410
1415
  let includeAudio = false;
1411
1416
  let isH264Output = false;
1412
1417
  let isLiveOutput = false;
1413
- let isRecordOutput = false;
1414
1418
 
1415
1419
  let lastSPS = undefined;
1416
1420
  let lastPPS = undefined;
@@ -1477,16 +1481,9 @@ export default class Streamer {
1477
1481
  includeAudio = output.includeAudio === true && outputAudio !== null;
1478
1482
  isH264Output = this.codecs?.video === StreamTransport.CODEC_TYPE.H264;
1479
1483
  isLiveOutput = output.type === Streamer.STREAM_TYPE.LIVE;
1480
- isRecordOutput = output.type === Streamer.STREAM_TYPE.RECORD;
1481
-
1482
- // Resolve output policy.
1483
- // Allows per-output tuning (live vs record) while falling back to sensible defaults.
1484
- policy =
1485
- typeof output?.policy === 'object' && output.policy !== null
1486
- ? output.policy
1487
- : (OUTPUT_PLAYOUT_POLICY[output?.type] ?? OUTPUT_PLAYOUT_POLICY.live);
1488
1484
 
1489
- // Extract policy values with safety checks.
1485
+ // Output policy is normalised once when the output is created.
1486
+ policy = output.policy;
1490
1487
  //
1491
1488
  // Even when live policy does not normally require keyframe startup, force
1492
1489
  // decoder-safe startup whenever this output has not yet seen a real keyframe.
@@ -1497,50 +1494,18 @@ export default class Streamer {
1497
1494
  allowAudioBeforeKeyFrame = policy.allowAudioBeforeKeyFrame === true;
1498
1495
 
1499
1496
  playoutDelayMs = Number.isFinite(output.playoutDelayMs) === true ? output.playoutDelayMs : policy.playoutDelayMs;
1500
- playoutDelayMs = Number.isFinite(playoutDelayMs) === true && playoutDelayMs >= 0 ? playoutDelayMs : 120;
1501
- minPlayoutDelayMs =
1502
- Number.isFinite(policy.minPlayoutDelayMs) === true && policy.minPlayoutDelayMs >= 0 ? policy.minPlayoutDelayMs : playoutDelayMs;
1503
- maxPlayoutDelayMs =
1504
- Number.isFinite(policy.maxPlayoutDelayMs) === true && policy.maxPlayoutDelayMs >= minPlayoutDelayMs
1505
- ? policy.maxPlayoutDelayMs
1506
- : Math.max(minPlayoutDelayMs, playoutDelayMs);
1507
- playoutAdjustStepMs =
1508
- Number.isFinite(policy.playoutAdjustStepMs) === true && policy.playoutAdjustStepMs > 0 ? policy.playoutAdjustStepMs : 20;
1497
+ minPlayoutDelayMs = policy.minPlayoutDelayMs;
1498
+ maxPlayoutDelayMs = policy.maxPlayoutDelayMs;
1499
+ playoutAdjustStepMs = policy.playoutAdjustStepMs;
1509
1500
  playoutDelayMs = Math.min(maxPlayoutDelayMs, Math.max(minPlayoutDelayMs, playoutDelayMs));
1510
- maxLagBehindLiveMs =
1511
- Number.isFinite(policy.maxLagBehindLiveMs) === true && policy.maxLagBehindLiveMs > 0 ? policy.maxLagBehindLiveMs : 750;
1512
- dueTolerance = Number.isFinite(policy.dueTolerance) === true && policy.dueTolerance >= 0 ? policy.dueTolerance : 10;
1513
- dueSlack = Number.isFinite(policy.dueSlack) === true && policy.dueSlack >= 0 ? policy.dueSlack : 10;
1514
- catchupExitThresholdMs =
1515
- Number.isFinite(policy.catchupExitThresholdMs) === true && policy.catchupExitThresholdMs >= 0 ? policy.catchupExitThresholdMs : 250;
1516
-
1517
- catchupAudioBurstLimit =
1518
- Number.isFinite(policy.catchupAudioBurstLimit) === true && policy.catchupAudioBurstLimit > 0
1519
- ? policy.catchupAudioBurstLimit
1520
- : isRecordOutput === true
1521
- ? 4
1522
- : 2;
1523
-
1524
- catchupVideoBurstLimit =
1525
- Number.isFinite(policy.catchupVideoBurstLimit) === true && policy.catchupVideoBurstLimit > 0
1526
- ? policy.catchupVideoBurstLimit
1527
- : isRecordOutput === true
1528
- ? 8
1529
- : 4;
1530
-
1531
- normalAudioBurstLimit =
1532
- Number.isFinite(policy.normalAudioBurstLimit) === true && policy.normalAudioBurstLimit > 0
1533
- ? policy.normalAudioBurstLimit
1534
- : isRecordOutput === true
1535
- ? 4
1536
- : 2;
1537
-
1538
- normalVideoBurstLimit =
1539
- Number.isFinite(policy.normalVideoBurstLimit) === true && policy.normalVideoBurstLimit > 0
1540
- ? policy.normalVideoBurstLimit
1541
- : isRecordOutput === true
1542
- ? 3
1543
- : 2;
1501
+ maxLagBehindLiveMs = policy.maxLagBehindLiveMs;
1502
+ dueTolerance = policy.dueTolerance;
1503
+ dueSlack = policy.dueSlack;
1504
+ catchupExitThresholdMs = policy.catchupExitThresholdMs;
1505
+ catchupAudioBurstLimit = policy.catchupAudioBurstLimit;
1506
+ catchupVideoBurstLimit = policy.catchupVideoBurstLimit;
1507
+ normalAudioBurstLimit = policy.normalAudioBurstLimit;
1508
+ normalVideoBurstLimit = policy.normalVideoBurstLimit;
1544
1509
 
1545
1510
  // Cached clean SPS/PPS for H264 keyframe bootstrap.
1546
1511
  // Transport media itself remains Annex-B and is written directly.
@@ -1635,6 +1600,11 @@ export default class Streamer {
1635
1600
  }
1636
1601
  }
1637
1602
 
1603
+ // Keep the unconsumed media candidate between loop iterations. Only the
1604
+ // selected media cursor advances, so resolving both again wastes a search.
1605
+ videoItem = state.videoCursor < timelineEnd ? timeline.nextVideoFrom(state.videoCursor) : undefined;
1606
+ audioItem = includeAudio === true && state.audioCursor < timelineEnd ? timeline.nextAudioFrom(state.audioCursor) : undefined;
1607
+
1638
1608
  // Main processing loop.
1639
1609
  // MediaTimeline gives us indexed video/audio lookup so we no longer walk
1640
1610
  // the mixed shared buffer looking for the next item of each type.
@@ -1648,9 +1618,6 @@ export default class Streamer {
1648
1618
  break;
1649
1619
  }
1650
1620
 
1651
- videoItem = state.videoCursor < timelineEnd ? timeline.nextVideoFrom(state.videoCursor) : undefined;
1652
- audioItem = includeAudio === true && state.audioCursor < timelineEnd ? timeline.nextAudioFrom(state.audioCursor) : undefined;
1653
-
1654
1621
  dueVideo = false;
1655
1622
  dueAudio = false;
1656
1623
  capVideo = false;
@@ -1809,6 +1776,7 @@ export default class Streamer {
1809
1776
  this.#statsDrop(output, Streamer.MEDIA_TYPE.VIDEO);
1810
1777
  state.videoCursor = nextCursor;
1811
1778
  processed++;
1779
+ videoItem = state.videoCursor < timelineEnd ? timeline.nextVideoFrom(state.videoCursor) : undefined;
1812
1780
  continue;
1813
1781
  }
1814
1782
 
@@ -1876,6 +1844,7 @@ export default class Streamer {
1876
1844
  break;
1877
1845
  }
1878
1846
 
1847
+ videoItem = state.videoCursor < timelineEnd ? timeline.nextVideoFrom(state.videoCursor) : undefined;
1879
1848
  continue;
1880
1849
  }
1881
1850
 
@@ -1884,6 +1853,7 @@ export default class Streamer {
1884
1853
  if (includeAudio !== true || outputAudio === null) {
1885
1854
  state.audioCursor = nextCursor;
1886
1855
  processed++;
1856
+ audioItem = state.audioCursor < timelineEnd ? timeline.nextAudioFrom(state.audioCursor) : undefined;
1887
1857
  continue;
1888
1858
  }
1889
1859
 
@@ -1893,14 +1863,14 @@ export default class Streamer {
1893
1863
  this.#statsDrop(output, Streamer.MEDIA_TYPE.AUDIO);
1894
1864
  state.audioCursor = nextCursor;
1895
1865
  processed++;
1866
+ audioItem = state.audioCursor < timelineEnd ? timeline.nextAudioFrom(state.audioCursor) : undefined;
1896
1867
  continue;
1897
1868
  }
1898
1869
 
1899
- queuedBytes =
1900
- (Number.isFinite(outputAudio.writableLength) === true ? outputAudio.writableLength : 0) +
1901
- (Number.isFinite(outputAudio.readableLength) === true ? outputAudio.readableLength : 0);
1902
-
1903
1870
  if (this.supportDump === true && typeof output?.stats?.diagnostics === 'object' && output.stats.diagnostics !== null) {
1871
+ queuedBytes =
1872
+ (Number.isFinite(outputAudio.writableLength) === true ? outputAudio.writableLength : 0) +
1873
+ (Number.isFinite(outputAudio.readableLength) === true ? outputAudio.readableLength : 0);
1904
1874
  output.stats.diagnostics.maxAudioQueuedBytes = Math.max(output.stats.diagnostics.maxAudioQueuedBytes ?? 0, queuedBytes);
1905
1875
  }
1906
1876
 
@@ -1916,14 +1886,15 @@ export default class Streamer {
1916
1886
  state.audioCursor = nextCursor;
1917
1887
  processed++;
1918
1888
 
1919
- queuedBytes =
1920
- (Number.isFinite(outputAudio.writableLength) === true ? outputAudio.writableLength : 0) +
1921
- (Number.isFinite(outputAudio.readableLength) === true ? outputAudio.readableLength : 0);
1922
-
1923
1889
  if (this.supportDump === true && typeof output?.stats?.diagnostics === 'object' && output.stats.diagnostics !== null) {
1890
+ queuedBytes =
1891
+ (Number.isFinite(outputAudio.writableLength) === true ? outputAudio.writableLength : 0) +
1892
+ (Number.isFinite(outputAudio.readableLength) === true ? outputAudio.readableLength : 0);
1924
1893
  output.stats.diagnostics.maxAudioQueuedBytes = Math.max(output.stats.diagnostics.maxAudioQueuedBytes ?? 0, queuedBytes);
1925
1894
  }
1926
1895
 
1896
+ audioItem = state.audioCursor < timelineEnd ? timeline.nextAudioFrom(state.audioCursor) : undefined;
1897
+
1927
1898
  continue;
1928
1899
  }
1929
1900
  }
@@ -1959,11 +1930,9 @@ export default class Streamer {
1959
1930
 
1960
1931
  #processOutput(dateNow, budgetMs) {
1961
1932
  let timeline = this.#timeline;
1962
- let itemsLength = 0;
1963
1933
  let hasOutputs = false;
1964
1934
  let cutoffTime = 0;
1965
1935
  let latestItemTime = undefined;
1966
- let trimCount = 0;
1967
1936
  let oldestProtectedCursor = this.#itemIndex;
1968
1937
  let fallbackFrame = undefined;
1969
1938
  let output = undefined;
@@ -1972,11 +1941,10 @@ export default class Streamer {
1972
1941
  return;
1973
1942
  }
1974
1943
 
1975
- itemsLength = typeof timeline.size === 'number' ? timeline.size : 0;
1976
1944
  hasOutputs = this.#outputs.size !== 0;
1977
1945
 
1978
1946
  // Determine if timeline contains items older than retention window.
1979
- if (itemsLength !== 0) {
1947
+ if (timeline.size !== 0) {
1980
1948
  latestItemTime = timeline.latestTime();
1981
1949
  latestItemTime = Number.isFinite(latestItemTime) === true ? latestItemTime : dateNow;
1982
1950
  cutoffTime = latestItemTime - this.#bufferDuration;
@@ -1986,11 +1954,7 @@ export default class Streamer {
1986
1954
  oldestProtectedCursor = timeline.protectedStart(this.#outputs);
1987
1955
 
1988
1956
  // Trim expired media while respecting the oldest protected output cursor.
1989
- trimCount = timeline.trim(cutoffTime, oldestProtectedCursor);
1990
-
1991
- if (trimCount !== 0) {
1992
- itemsLength -= trimCount;
1993
- }
1957
+ timeline.trim(cutoffTime, oldestProtectedCursor);
1994
1958
  }
1995
1959
 
1996
1960
  // No outputs attached means we only need retention maintenance this tick.
@@ -66,7 +66,7 @@
66
66
  // - CLOSING
67
67
  // - CLOSED
68
68
  //
69
- // Code version 2026.05.18
69
+ // Code version 2026.08.20
70
70
  // Mark Hulskamp
71
71
  'use strict';
72
72
 
@@ -497,12 +497,12 @@ export default class StreamTransport {
497
497
  for (let packet of group.packets) {
498
498
  if (typeof expectedSequence === 'number' && packet.sequenceNumber !== expectedSequence) {
499
499
  group.hasSequenceGap = true;
500
- break;
501
500
  }
502
501
 
503
502
  expectedSequence = (packet.sequenceNumber + 1) & jitter.sequenceMask;
504
503
  }
505
504
 
505
+ group.lastSequence = group.packets.at(-1)?.sequenceNumber;
506
506
  group.packetsSorted = true;
507
507
 
508
508
  return group.hasSequenceGap === true;
@@ -1340,9 +1340,10 @@ export default class StreamTransport {
1340
1340
  rtpTimestamp: rtpTimestamp,
1341
1341
  firstReceivedAt: receivedAt,
1342
1342
  lastReceivedAt: receivedAt,
1343
+ lastSequence: undefined,
1343
1344
  markerSeen: false,
1344
1345
  hasSequenceGap: false,
1345
- packetsSorted: false,
1346
+ packetsSorted: true,
1346
1347
  sequences: new Set(),
1347
1348
  packets: [],
1348
1349
  };
@@ -1362,7 +1363,17 @@ export default class StreamTransport {
1362
1363
  group.firstReceivedAt = Math.min(group.firstReceivedAt, receivedAt);
1363
1364
  group.lastReceivedAt = Math.max(group.lastReceivedAt, receivedAt);
1364
1365
  group.markerSeen = group.markerSeen === true || packetInfo.marker === true;
1365
- group.packetsSorted = false;
1366
+
1367
+ // Preserve the sorted fast path for the overwhelmingly common case where
1368
+ // RTP packets arrive in sequence. Re-sort only after actual reordering.
1369
+ if (
1370
+ group.packets.length !== 0 &&
1371
+ (group.packetsSorted !== true ||
1372
+ typeof group.lastSequence !== 'number' ||
1373
+ sequenceNumber !== ((group.lastSequence + 1) & jitter.sequenceMask))
1374
+ ) {
1375
+ group.packetsSorted = false;
1376
+ }
1366
1377
 
1367
1378
  // Protocol-specific grouped metadata can be supplied in packetInfo.group.
1368
1379
  // Boolean flags accumulate across packets so later fragments cannot clear
@@ -1380,6 +1391,9 @@ export default class StreamTransport {
1380
1391
  receivedAt: receivedAt,
1381
1392
  });
1382
1393
  group.sequences.add(sequenceNumber);
1394
+ if (group.packetsSorted === true) {
1395
+ group.lastSequence = sequenceNumber;
1396
+ }
1383
1397
  jitter.packetCount++;
1384
1398
 
1385
1399
  while (jitter.packetCount > jitter.maxPackets && jitter.queue.size > 0) {
package/dist/webrtc.js CHANGED
@@ -50,7 +50,7 @@
50
50
  // - Output playout timing, catch-up, and live latency policy are owned by Streamer
51
51
  // - Incomplete keyframes and pathological access units are dropped/recovered locally rather than blocking the plugin process
52
52
  //
53
- // Code version 2026.05.20
53
+ // Code version 2026.08.20
54
54
  // Mark Hulskamp
55
55
  'use strict';
56
56
 
@@ -102,8 +102,9 @@ const VIDEO_RTP_REORDER_DELAY_MS = 250; // Hold video RTP briefly so reordered f
102
102
  const VIDEO_RTP_REORDER_MAX_PACKETS = 4096; // Bound video RTP queue while allowing a fragmented 4MiB keyframe plus packet overhead
103
103
  const VIDEO_RTP_DRAIN_MAX_GROUPS = 8; // Bound synchronous video jitter release work per callback
104
104
  const VIDEO_RTP_DRAIN_MAX_PACKETS = 128; // Bound synchronous H264 assembly work per callback
105
- const STARTUP_KEYFRAME_PLI_INTERVAL_MS = 1500; // Retry startup keyframe requests while waiting for first decodable IDR
106
- const STARTUP_KEYFRAME_PLI_MAX_MS = 12000; // Bound startup PLI retries so slow sources do not spam RTCP forever
105
+ const STARTUP_KEYFRAME_REQUEST_INTERVAL_MS = 1500; // Retry startup keyframe requests while waiting for first decodable IDR
106
+ const STARTUP_KEYFRAME_REQUEST_MAX_MS = 12000; // Bound startup retries so slow sources do not spam RTCP forever
107
+ const VIDEO_REMB_BITRATE = 4_000_000; // Raise the sender's conservative startup estimate while keyframes are requested
107
108
 
108
109
  // WebRTC object
109
110
  export default class WebRTC extends StreamTransport {
@@ -201,7 +202,9 @@ export default class WebRTC extends StreamTransport {
201
202
  return;
202
203
  }
203
204
 
204
- let homeFoyerResponse = await this.#grpcTransport.command(GOOGLE_HOME_FOYER_PREFIX, 'CameraService', 'SendCameraViewIntent', {
205
+ // Wake the camera while local certificate, SDP, and ICE candidate work runs.
206
+ // These operations are independent until JoinStream sends the completed offer.
207
+ let viewIntentPromise = this.#grpcTransport.command(GOOGLE_HOME_FOYER_PREFIX, 'CameraService', 'SendCameraViewIntent', {
205
208
  request: {
206
209
  googleDeviceId: {
207
210
  value: this.#googleHomeDeviceUUID,
@@ -210,16 +213,6 @@ export default class WebRTC extends StreamTransport {
210
213
  },
211
214
  });
212
215
 
213
- if (this.#closeInProgress === true || this.#peerConnection !== undefined) {
214
- return;
215
- }
216
-
217
- if (homeFoyerResponse?.status !== 0) {
218
- this?.log?.debug?.('Request to start camera viewing was not accepted for uuid "%s"', this.uuid);
219
- this.setState(StreamTransport.STATE.CLOSED, { reason: 'view-intent-failed' });
220
- return;
221
- }
222
-
223
216
  // Create our local WebRTC peer connection and advertise the codecs we support.
224
217
  // We receive H264 video and Opus audio from the camera, then convert that into
225
218
  // Streamer media items for live view and recording.
@@ -275,6 +268,29 @@ export default class WebRTC extends StreamTransport {
275
268
  // If accepted, we will get an SDP answer back plus a streamId for later extend/end/talkback calls.
276
269
  let webRTCOffer = await peerConnection.createOffer();
277
270
  await peerConnection.setLocalDescription(webRTCOffer);
271
+ let localOfferSdp = peerConnection.localDescription?.sdp ?? webRTCOffer.sdp;
272
+
273
+ // Both overlapped operations must complete successfully before joining.
274
+ let homeFoyerResponse = await viewIntentPromise;
275
+
276
+ if (this.#peerConnection !== peerConnection || this.#closeInProgress === true) {
277
+ try {
278
+ await peerConnection?.close?.();
279
+ } catch {
280
+ // Empty
281
+ }
282
+ return;
283
+ }
284
+
285
+ if (homeFoyerResponse?.status !== 0) {
286
+ await peerConnection?.close?.();
287
+ this.#peerConnection = undefined;
288
+ this.#videoTransceiver = undefined;
289
+ this.#audioTransceiver = undefined;
290
+ this?.log?.debug?.('Request to start camera viewing was not accepted for uuid "%s"', this.uuid);
291
+ this.setState(StreamTransport.STATE.CLOSED, { reason: 'view-intent-failed' });
292
+ return;
293
+ }
278
294
 
279
295
  homeFoyerResponse = await this.#grpcTransport.command(GOOGLE_HOME_FOYER_PREFIX, 'CameraService', 'JoinStream', {
280
296
  command: 'offer',
@@ -283,7 +299,9 @@ export default class WebRTC extends StreamTransport {
283
299
  streamContext: 'STREAM_CONTEXT_DEFAULT',
284
300
  // Request highest possible resolution; actual delivered resolution may be lower.
285
301
  requestedVideoResolution: 'VIDEO_RESOLUTION_FULL_HIGH',
286
- sdp: webRTCOffer.sdp,
302
+ // setLocalDescription() gathers ICE candidates and updates the peer's SDP.
303
+ // The earlier createOffer() value does not contain those candidates.
304
+ sdp: localOfferSdp,
287
305
  });
288
306
 
289
307
  if (this.#peerConnection !== peerConnection) {
@@ -830,7 +848,7 @@ export default class WebRTC extends StreamTransport {
830
848
  // Start bounded startup keyframe requests. The first call may happen
831
849
  // before the RTP SSRC is known, so retries continue until the first IDR.
832
850
  this.#startStartupKeyframeTimer();
833
- this.#sendVideoPLI();
851
+ this.#requestVideoKeyframe();
834
852
 
835
853
  this.#refreshStallTimer();
836
854
  return;
@@ -851,32 +869,19 @@ export default class WebRTC extends StreamTransport {
851
869
  }
852
870
 
853
871
  #ensurePlaybackVideoTrack() {
854
- // Build or repair the per-session video state once, keeping packet hot paths
855
- // focused on RTP/H264 work instead of repeated object-shape checks.
856
- if (typeof this.#tracks !== 'object' || this.#tracks === null) {
857
- this.#tracks = {};
858
- }
859
-
860
- if (typeof this.#tracks.video !== 'object' || this.#tracks.video === null) {
861
- this.#tracks.video = {};
862
- }
863
-
872
+ // onTrack calls this before subscribing RTP callbacks, so packet handlers
873
+ // can use stable session state without repairing object shapes per packet.
864
874
  let video = this.#tracks.video;
865
875
 
866
- video.id = typeof video.id === 'number' ? video.id : RTP_H264_VIDEO_PAYLOAD_TYPE;
867
- video.rtxId = typeof video.rtxId === 'number' ? video.rtxId : RTP_H264_VIDEO_RTX_PAYLOAD_TYPE;
868
- video.rtxSsrc = typeof video.rtxSsrc === 'number' ? video.rtxSsrc : undefined;
869
- video.codec = typeof video.codec === 'string' ? video.codec : StreamTransport.CODEC_TYPE.H264;
870
- video.sampleRate = typeof video.sampleRate === 'number' ? video.sampleRate : 90000;
871
- video.lastPLITime = typeof video.lastPLITime === 'number' ? video.lastPLITime : undefined;
872
- video.keyframeRequestInFlight = typeof video.keyframeRequestInFlight === 'boolean' ? video.keyframeRequestInFlight : false;
873
- video.lastKeyframeEventTime = typeof video.lastKeyframeEventTime === 'number' ? video.lastKeyframeEventTime : undefined;
874
-
875
- video.rtp = typeof video.rtp === 'object' && video.rtp !== null ? video.rtp : { lastSequence: undefined, lastTimestamp: undefined };
876
- video.output = typeof video.output === 'object' && video.output !== null ? video.output : { lastTimestamp: undefined };
877
- video.health = typeof video.health === 'object' && video.health !== null ? video.health : this.getMediaState('video');
878
- video.deltaAudit =
879
- typeof video.deltaAudit === 'object' && video.deltaAudit !== null ? video.deltaAudit : { hasAcceptedKeyframe: false };
876
+ video.id ??= RTP_H264_VIDEO_PAYLOAD_TYPE;
877
+ video.rtxId ??= RTP_H264_VIDEO_RTX_PAYLOAD_TYPE;
878
+ video.codec ??= StreamTransport.CODEC_TYPE.H264;
879
+ video.sampleRate ??= 90000;
880
+ video.keyframeRequestInFlight ??= false;
881
+ video.rtp ??= { lastSequence: undefined, lastTimestamp: undefined };
882
+ video.output ??= { lastTimestamp: undefined };
883
+ video.health ??= this.getMediaState('video');
884
+ video.deltaAudit ??= { hasAcceptedKeyframe: false };
880
885
 
881
886
  if (typeof video.jitter !== 'object' || video.jitter === null || video.jitter.groupByTimestamp !== true) {
882
887
  video.jitter = this.createJitterBuffer({
@@ -894,27 +899,16 @@ export default class WebRTC extends StreamTransport {
894
899
  }
895
900
 
896
901
  #ensurePlaybackAudioTrack() {
897
- // Build or repair the per-session audio state once so packet handling only
898
- // deals with RTP timing, Opus decode, and decoded-frame emission.
899
- if (typeof this.#tracks !== 'object' || this.#tracks === null) {
900
- this.#tracks = {};
901
- }
902
-
903
- if (typeof this.#tracks.audio !== 'object' || this.#tracks.audio === null) {
904
- this.#tracks.audio = {};
905
- }
906
-
902
+ // Audio follows the same onTrack-before-packets invariant as video.
907
903
  let audio = this.#tracks.audio;
908
904
 
909
- audio.id = typeof audio.id === 'number' ? audio.id : RTP_OPUS_AUDIO_PAYLOAD_TYPE;
910
- audio.codec = typeof audio.codec === 'string' ? audio.codec : StreamTransport.CODEC_TYPE.OPUS;
911
- audio.sampleRate = typeof audio.sampleRate === 'number' ? audio.sampleRate : 48000;
912
- audio.channels = typeof audio.channels === 'number' ? audio.channels : 2;
913
- audio.packetTime = typeof audio.packetTime === 'number' ? audio.packetTime : 20;
914
- audio.rtp = typeof audio.rtp === 'object' && audio.rtp !== null ? audio.rtp : { lastSequence: undefined, lastTimestamp: undefined };
915
- audio.output = typeof audio.output === 'object' && audio.output !== null ? audio.output : { lastTimestamp: undefined };
916
- audio.lastDecodeFallbackLogTime = typeof audio.lastDecodeFallbackLogTime === 'number' ? audio.lastDecodeFallbackLogTime : undefined;
917
- audio.lastDecodeErrorLogTime = typeof audio.lastDecodeErrorLogTime === 'number' ? audio.lastDecodeErrorLogTime : undefined;
905
+ audio.id ??= RTP_OPUS_AUDIO_PAYLOAD_TYPE;
906
+ audio.codec ??= StreamTransport.CODEC_TYPE.OPUS;
907
+ audio.sampleRate ??= 48000;
908
+ audio.channels ??= 2;
909
+ audio.packetTime ??= 20;
910
+ audio.rtp ??= { lastSequence: undefined, lastTimestamp: undefined };
911
+ audio.output ??= { lastTimestamp: undefined };
918
912
 
919
913
  if (typeof audio.jitter !== 'object' || audio.jitter === null || audio.jitter.groupByTimestamp === true) {
920
914
  audio.jitter = this.createJitterBuffer({
@@ -1038,12 +1032,14 @@ export default class WebRTC extends StreamTransport {
1038
1032
  );
1039
1033
  }
1040
1034
 
1041
- this.#sendVideoPLI();
1042
- this.markMediaIssue('video', 'jitter-keyframe-incomplete');
1043
- this.clearJitterBuffer(jitter);
1035
+ this.#requestVideoRecovery('jitter-keyframe-incomplete');
1044
1036
  this.#resetFragmentedVideoFrame();
1045
1037
  this.#resetPendingVideoFrame();
1046
- break;
1038
+
1039
+ // releaseJitterGroups already removed only the failed timestamp.
1040
+ // Keep later groups: one may contain the complete recovery keyframe,
1041
+ // whereas clearing them here can turn one damaged IDR into a long gap.
1042
+ continue;
1047
1043
  }
1048
1044
 
1049
1045
  continue;
@@ -1131,6 +1127,7 @@ export default class WebRTC extends StreamTransport {
1131
1127
  let payloadInfo = undefined;
1132
1128
  let queueSequenceNumber = 0;
1133
1129
  let packetReceivedAt = 0;
1130
+ let learnedPrimarySsrc = false;
1134
1131
 
1135
1132
  // Ensure we have a valid RTP packet with a payload before processing video data
1136
1133
  if (
@@ -1154,7 +1151,7 @@ export default class WebRTC extends StreamTransport {
1154
1151
  let ssrc = Number.isInteger(header.ssrc) === true ? header.ssrc >>> 0 : undefined;
1155
1152
  packetReceivedAt = Number.isFinite(rtpPacket.receivedAt) === true ? rtpPacket.receivedAt : Date.now();
1156
1153
 
1157
- let video = this.#ensurePlaybackVideoTrack();
1154
+ let video = this.#tracks.video;
1158
1155
  let h264 = video.h264;
1159
1156
  let videoRtp = video.rtp;
1160
1157
  let deltaAudit = video.deltaAudit;
@@ -1192,6 +1189,7 @@ export default class WebRTC extends StreamTransport {
1192
1189
  // request a fresh IDR even while the first timestamp group is still queued.
1193
1190
  if (typeof video.ssrc !== 'number' && typeof ssrc === 'number') {
1194
1191
  video.ssrc = ssrc;
1192
+ learnedPrimarySsrc = true;
1195
1193
  }
1196
1194
 
1197
1195
  if (typeof video.ssrc === 'number' && typeof ssrc === 'number' && ssrc !== video.ssrc) {
@@ -1200,6 +1198,10 @@ export default class WebRTC extends StreamTransport {
1200
1198
 
1201
1199
  payloadInfo = RtpH264.getPayloadInfo(payload);
1202
1200
 
1201
+ if (learnedPrimarySsrc === true && this.ready !== true && payloadInfo.hasKeyFrame !== true) {
1202
+ this.#requestVideoKeyframe();
1203
+ }
1204
+
1203
1205
  if (
1204
1206
  this.pushJitterPacket(jitter, {
1205
1207
  rtpTimestamp: rtpTimestamp,
@@ -1253,7 +1255,7 @@ export default class WebRTC extends StreamTransport {
1253
1255
  }
1254
1256
 
1255
1257
  if (this.ready !== true && h264?.hasIDR !== true && typeof video.ssrc === 'number') {
1256
- this.#sendVideoPLI();
1258
+ this.#requestVideoKeyframe();
1257
1259
  }
1258
1260
 
1259
1261
  // Drop duplicate or clearly late/out-of-order packets before they touch assembly state.
@@ -1315,8 +1317,7 @@ export default class WebRTC extends StreamTransport {
1315
1317
  );
1316
1318
 
1317
1319
  if (h264.pendingKeyFrame === true || (Number.isFinite(pendingAgeMs) === true && pendingAgeMs >= 300)) {
1318
- this.#sendVideoPLI();
1319
- this.markMediaIssue('video', 'pending-incomplete');
1320
+ this.#requestVideoRecovery('pending-incomplete');
1320
1321
  }
1321
1322
 
1322
1323
  this.#resetPendingVideoFrame();
@@ -1353,8 +1354,7 @@ export default class WebRTC extends StreamTransport {
1353
1354
  );
1354
1355
 
1355
1356
  if (h264.fuNalType === H264.NALUS.TYPES.IDR || (Number.isFinite(fuAgeMs) === true && fuAgeMs >= 600)) {
1356
- this.#sendVideoPLI();
1357
- this.markMediaIssue('video', 'fu-incomplete');
1357
+ this.#requestVideoRecovery('fu-incomplete');
1358
1358
  }
1359
1359
  }
1360
1360
 
@@ -1422,44 +1422,30 @@ export default class WebRTC extends StreamTransport {
1422
1422
  });
1423
1423
 
1424
1424
  if (fuResult?.ok !== true) {
1425
- if (fuResult?.reason === 'orphan') {
1426
- if (fuResult?.nalType === H264.NALUS.TYPES.IDR) {
1427
- this.recordVideoDrop('fu-keyframe-incomplete');
1428
-
1429
- this?.log?.debug?.(
1430
- 'Dropping orphaned WebRTC FU-A keyframe fragment for uuid "%s": seq="%s" ts="%s"',
1431
- this.uuid,
1432
- sequenceNumber,
1433
- rtpTimestamp,
1434
- );
1435
-
1436
- this.#sendVideoPLI();
1437
- this.markMediaIssue('video', 'fu-incomplete');
1438
- }
1439
-
1440
- this.#resetFragmentedVideoFrame();
1441
- if (h264.pendingRtpTimestamp === rtpTimestamp && h264.pendingHasVcl !== true) {
1442
- this.#resetPendingVideoFrame();
1425
+ // Orphans and sequence gaps both invalidate the retained FU-A fragments.
1426
+ // Handle them together while preserving the stronger keyframe recovery:
1427
+ // gaps count delta losses, while IDR loss also requests a replacement.
1428
+ if (fuResult?.reason === 'orphan' || fuResult?.reason === 'gap') {
1429
+ let keyframeFragment = fuResult?.nalType === H264.NALUS.TYPES.IDR;
1430
+
1431
+ if (fuResult.reason === 'gap' || keyframeFragment === true) {
1432
+ this.recordVideoDrop(keyframeFragment === true ? 'fu-keyframe-incomplete' : 'fu-delta-incomplete');
1443
1433
  }
1444
- return;
1445
- }
1446
-
1447
- if (fuResult?.reason === 'gap') {
1448
- this.recordVideoDrop(fuResult?.nalType === H264.NALUS.TYPES.IDR ? 'fu-keyframe-incomplete' : 'fu-delta-incomplete');
1449
1434
 
1450
- if (fuResult?.nalType === H264.NALUS.TYPES.IDR) {
1451
- h264.pendingCorrupt = true;
1435
+ if (keyframeFragment === true) {
1436
+ if (fuResult.reason === 'gap') {
1437
+ h264.pendingCorrupt = true;
1438
+ }
1452
1439
 
1453
1440
  this?.log?.debug?.(
1454
- 'Dropping gapped WebRTC FU-A keyframe for uuid "%s": expectedSeq="%s" seq="%s" ts="%s"',
1441
+ 'Dropping %s WebRTC FU-A keyframe fragment for uuid "%s": expectedSeq="%s" seq="%s" ts="%s"',
1442
+ fuResult.reason,
1455
1443
  this.uuid,
1456
1444
  fuResult.expectedSequenceNumber,
1457
1445
  sequenceNumber,
1458
1446
  rtpTimestamp,
1459
1447
  );
1460
-
1461
- this.#sendVideoPLI();
1462
- this.markMediaIssue('video', 'fu-incomplete');
1448
+ this.#requestVideoRecovery('fu-incomplete');
1463
1449
  }
1464
1450
 
1465
1451
  this.#resetFragmentedVideoFrame();
@@ -1488,8 +1474,7 @@ export default class WebRTC extends StreamTransport {
1488
1474
  );
1489
1475
 
1490
1476
  if (fuResult.previousNalType === H264.NALUS.TYPES.IDR) {
1491
- this.#sendVideoPLI();
1492
- this.markMediaIssue('video', 'fu-incomplete');
1477
+ this.#requestVideoRecovery('fu-incomplete');
1493
1478
  }
1494
1479
  }
1495
1480
 
@@ -1656,8 +1641,7 @@ export default class WebRTC extends StreamTransport {
1656
1641
  keyframeByteLimit,
1657
1642
  );
1658
1643
 
1659
- this.#sendVideoPLI();
1660
- this.markMediaIssue('video', 'oversized-keyframe');
1644
+ this.#requestVideoRecovery('oversized-keyframe');
1661
1645
  this.#resetPendingVideoFrame();
1662
1646
  return;
1663
1647
  }
@@ -1689,8 +1673,7 @@ export default class WebRTC extends StreamTransport {
1689
1673
  );
1690
1674
 
1691
1675
  if (pendingKeyFrame === true) {
1692
- this.#sendVideoPLI();
1693
- this.markMediaIssue('video', 'corrupt-keyframe');
1676
+ this.#requestVideoRecovery('corrupt-keyframe');
1694
1677
  }
1695
1678
 
1696
1679
  this.#resetPendingVideoFrame();
@@ -1757,8 +1740,7 @@ export default class WebRTC extends StreamTransport {
1757
1740
  keyframeAssemblyLimitMs,
1758
1741
  );
1759
1742
 
1760
- this.#sendVideoPLI();
1761
- this.markMediaIssue('video', 'shock-keyframe');
1743
+ this.#requestVideoRecovery('shock-keyframe');
1762
1744
  this.#resetPendingVideoFrame();
1763
1745
  return;
1764
1746
  }
@@ -1913,18 +1895,18 @@ export default class WebRTC extends StreamTransport {
1913
1895
  return;
1914
1896
  }
1915
1897
 
1916
- if (now - this.#startupKeyframeStartedAt > STARTUP_KEYFRAME_PLI_MAX_MS) {
1898
+ if (now - this.#startupKeyframeStartedAt > STARTUP_KEYFRAME_REQUEST_MAX_MS) {
1917
1899
  this.#stopStartupKeyframeTimer();
1918
1900
  this?.log?.debug?.(
1919
1901
  'Stopped WebRTC startup keyframe requests for uuid "%s" after %sms without accepted keyframe',
1920
1902
  this.uuid,
1921
- STARTUP_KEYFRAME_PLI_MAX_MS,
1903
+ STARTUP_KEYFRAME_REQUEST_MAX_MS,
1922
1904
  );
1923
1905
  return;
1924
1906
  }
1925
1907
 
1926
- this.#sendVideoPLI();
1927
- }, STARTUP_KEYFRAME_PLI_INTERVAL_MS);
1908
+ this.#requestVideoKeyframe();
1909
+ }, STARTUP_KEYFRAME_REQUEST_INTERVAL_MS);
1928
1910
  }
1929
1911
 
1930
1912
  #stopStartupKeyframeTimer() {
@@ -1995,7 +1977,7 @@ export default class WebRTC extends StreamTransport {
1995
1977
  let ssrc = Number.isInteger(header.ssrc) === true ? header.ssrc >>> 0 : undefined;
1996
1978
  now = fromJitterBuffer === true && Number.isFinite(rtpPacket.receivedAt) === true ? rtpPacket.receivedAt : now;
1997
1979
 
1998
- let audio = this.#ensurePlaybackAudioTrack();
1980
+ let audio = this.#tracks.audio;
1999
1981
  let audioRtp = audio.rtp;
2000
1982
  let audioOutput = audio.output;
2001
1983
 
@@ -2122,12 +2104,20 @@ export default class WebRTC extends StreamTransport {
2122
2104
  });
2123
2105
  }
2124
2106
 
2125
- #sendVideoPLI() {
2107
+ #requestVideoRecovery(reason) {
2108
+ // A damaged keyframe needs both an upstream replacement request and a
2109
+ // health signal so StreamTransport can apply its existing recovery policy.
2110
+ this.#requestVideoKeyframe();
2111
+ this.markMediaIssue('video', reason);
2112
+ }
2113
+
2114
+ #requestVideoKeyframe() {
2126
2115
  let video = this.#tracks?.video;
2127
2116
  let now = Date.now();
2128
- let sendResult = undefined;
2117
+ let pliSendResult = undefined;
2118
+ let receiver = this.#videoTransceiver?.receiver;
2129
2119
 
2130
- if (this.#videoTransceiver === undefined || video === undefined || typeof video?.ssrc !== 'number') {
2120
+ if (receiver === undefined || video === undefined || typeof video?.ssrc !== 'number') {
2131
2121
  return;
2132
2122
  }
2133
2123
 
@@ -2135,19 +2125,19 @@ export default class WebRTC extends StreamTransport {
2135
2125
  return;
2136
2126
  }
2137
2127
 
2138
- if (typeof video.lastPLITime === 'number' && now - video.lastPLITime < 1500) {
2128
+ if (typeof video.lastKeyframeRequestTime === 'number' && now - video.lastKeyframeRequestTime < STARTUP_KEYFRAME_REQUEST_INTERVAL_MS) {
2139
2129
  return;
2140
2130
  }
2141
2131
 
2142
- video.lastPLITime = now;
2132
+ video.lastKeyframeRequestTime = now;
2143
2133
  video.keyframeRequestInFlight = true;
2144
2134
  this.recordKeyframeRequest();
2145
2135
 
2146
2136
  try {
2147
- sendResult = this.#videoTransceiver?.receiver?.sendRtcpPLI?.(video.ssrc);
2137
+ pliSendResult = receiver.sendRtcpPLI?.(video.ssrc);
2148
2138
 
2149
- if (sendResult instanceof Promise) {
2150
- sendResult
2139
+ if (pliSendResult instanceof Promise) {
2140
+ pliSendResult
2151
2141
  .catch((error) => {
2152
2142
  this?.log?.debug?.('Error sending WebRTC PLI for uuid "%s": %s', this.uuid, String(error));
2153
2143
  })
@@ -2156,13 +2146,60 @@ export default class WebRTC extends StreamTransport {
2156
2146
  video.keyframeRequestInFlight = false;
2157
2147
  }
2158
2148
  });
2159
- return;
2160
2149
  }
2161
2150
  } catch (error) {
2162
2151
  this?.log?.debug?.('Error sending WebRTC PLI for uuid "%s": %s', this.uuid, String(error));
2163
2152
  }
2164
2153
 
2165
- video.keyframeRequestInFlight = false;
2154
+ if (pliSendResult instanceof Promise === false) {
2155
+ video.keyframeRequestInFlight = false;
2156
+ }
2157
+
2158
+ if (typeof receiver?.rtcpSsrc !== 'number' || typeof receiver?.dtlsTransport?.sendRtcp !== 'function') {
2159
+ return;
2160
+ }
2161
+
2162
+ try {
2163
+ let firBuffer = Buffer.alloc(16);
2164
+ let rembExponent = 0;
2165
+ let rembMantissa = VIDEO_REMB_BITRATE;
2166
+
2167
+ firBuffer.writeUInt32BE(receiver.rtcpSsrc >>> 0, 0);
2168
+ firBuffer.writeUInt32BE(video.ssrc >>> 0, 4);
2169
+ firBuffer.writeUInt32BE(video.ssrc >>> 0, 8);
2170
+ firBuffer[12] = this.stats.media.keyframeRequests & 0xff;
2171
+
2172
+ while (rembMantissa > 0x3ffff) {
2173
+ rembMantissa = Math.floor(rembMantissa / 2);
2174
+ rembExponent++;
2175
+ }
2176
+
2177
+ receiver.dtlsTransport
2178
+ .sendRtcp([
2179
+ new werift.RtcpPayloadSpecificFeedback({
2180
+ feedback: {
2181
+ count: 4,
2182
+ length: firBuffer.length / 4 - 1,
2183
+ serialize: () => firBuffer,
2184
+ },
2185
+ }),
2186
+ new werift.RtcpPayloadSpecificFeedback({
2187
+ feedback: new werift.ReceiverEstimatedMaxBitrate({
2188
+ senderSsrc: receiver.rtcpSsrc,
2189
+ mediaSsrc: 0,
2190
+ ssrcNum: 1,
2191
+ brExp: rembExponent,
2192
+ brMantissa: rembMantissa,
2193
+ ssrcFeedbacks: [video.ssrc],
2194
+ }),
2195
+ }),
2196
+ ])
2197
+ .catch((error) => {
2198
+ this?.log?.debug?.('Error sending WebRTC FIR/REMB for uuid "%s": %s', this.uuid, String(error));
2199
+ });
2200
+ } catch (error) {
2201
+ this?.log?.debug?.('Error building WebRTC FIR/REMB for uuid "%s": %s', this.uuid, String(error));
2202
+ }
2166
2203
  }
2167
2204
 
2168
2205
  #requestReconnect(reason) {
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "homebridge-nest-accfactory",
3
3
  "displayName": "Nest Accfactory",
4
4
  "type": "module",
5
- "version": "0.4.3",
5
+ "version": "0.4.4-beta.2",
6
6
  "description": "Homebridge support for Nest/Google devices including HomeKit Secure Video (HKSV) support for doorbells and cameras",
7
7
  "author": "n0rt0nthec4t",
8
8
  "license": "Apache-2.0",
@@ -36,8 +36,8 @@
36
36
  ],
37
37
  "main": "dist/index.js",
38
38
  "engines": {
39
- "node": "^22 || ^24",
40
- "homebridge": "^1.11.4 || ^2.3.1"
39
+ "node": "^22 || ^24 || ^26",
40
+ "homebridge": "^1.11.4 || ^2.4.0"
41
41
  },
42
42
  "files": [
43
43
  "LICENSE",
@@ -48,27 +48,30 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "clean": "rimraf dist",
51
- "format": "prettier --write \"src/**/*.{js,mjs}\" && eslint --fix \"src/**/*.{js,mjs}\"",
52
- "lint": "eslint \"src/**/*.{js,mjs}\" --max-warnings=20",
51
+ "format": "prettier --write \"{src,test}/**/*.{js,mjs}\" && eslint --fix \"src/**/*.{js,mjs}\" \"test/**/*.js\"",
52
+ "lint": "eslint \"src/**/*.{js,mjs}\" \"test/**/*.js\" --max-warnings=20",
53
+ "test": "node --test",
54
+ "test:coverage": "node --test --experimental-test-coverage",
55
+ "check": "npm run lint && npm test && npm run build",
53
56
  "build": "npm run clean && copyfiles -u 1 src/*.js dist && copyfiles -u 2 src/HomeKitDevice/*.js dist && copyfiles -u 2 src/HomeKitHistory/*.js dist && copyfiles -u 1 src/res/*.h264 dist && copyfiles -u 1 src/res/*.jpg dist && copyfiles -u 1 'src/protobuf/**/*.proto' dist && copyfiles -u 1 src/plugins/*.js dist && copyfiles -u 1 src/plugins/*.mjs dist",
54
- "prepublishOnly": "npm run format && npm run lint && npm run build"
57
+ "prepublishOnly": "npm run format && npm run lint && npm test && npm run build"
55
58
  },
56
59
  "devDependencies": {
57
60
  "@eslint/js": "^10.0.1",
58
61
  "@stylistic/eslint-plugin": "^5.10.0",
59
- "@types/node": "^26.2.0",
60
- "@typescript-eslint/parser": "^8.66.0",
62
+ "@types/node": "^26.4.1",
63
+ "@typescript-eslint/parser": "^8.69.0",
61
64
  "copyfiles": "^2.4.1",
62
- "eslint": "^10.8.1",
63
- "homebridge": "^1.11.4 || ^2.3.1",
65
+ "eslint": "^10.10.0",
66
+ "homebridge": "^1.11.4 || ^2.4.0",
64
67
  "prettier": "^3.9.6",
65
- "prettier-eslint": "^17.1.1",
68
+ "prettier-eslint": "^17.1.2",
66
69
  "rimraf": "^6.1.3"
67
70
  },
68
71
  "dependencies": {
69
72
  "@evan/opus": "^1.0.3",
70
73
  "chalk": "^6.0.0",
71
- "protobufjs": "^8.7.2",
74
+ "protobufjs": "^8.8.0",
72
75
  "werift": "^0.24.4"
73
76
  }
74
77
  }