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.
- package/dist/HomeKitDevice.js +28 -13
- package/dist/ffmpeg.js +25 -1
- package/dist/streamer.js +67 -103
- package/dist/streamtransport.js +18 -4
- package/dist/webrtc.js +158 -121
- package/package.json +15 -12
package/dist/HomeKitDevice.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
|
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.
|
|
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
|
|
897
|
-
stats:
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
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
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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 (
|
|
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
|
-
|
|
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.
|
package/dist/streamtransport.js
CHANGED
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
// - CLOSING
|
|
67
67
|
// - CLOSED
|
|
68
68
|
//
|
|
69
|
-
// Code version 2026.
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
|
106
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
//
|
|
855
|
-
//
|
|
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
|
|
867
|
-
video.rtxId
|
|
868
|
-
video.
|
|
869
|
-
video.
|
|
870
|
-
video.
|
|
871
|
-
video.
|
|
872
|
-
video.
|
|
873
|
-
video.
|
|
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
|
-
//
|
|
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
|
|
910
|
-
audio.codec
|
|
911
|
-
audio.sampleRate
|
|
912
|
-
audio.channels
|
|
913
|
-
audio.packetTime
|
|
914
|
-
audio.rtp
|
|
915
|
-
audio.output
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
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 (
|
|
1451
|
-
|
|
1435
|
+
if (keyframeFragment === true) {
|
|
1436
|
+
if (fuResult.reason === 'gap') {
|
|
1437
|
+
h264.pendingCorrupt = true;
|
|
1438
|
+
}
|
|
1452
1439
|
|
|
1453
1440
|
this?.log?.debug?.(
|
|
1454
|
-
'Dropping
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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 >
|
|
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
|
-
|
|
1903
|
+
STARTUP_KEYFRAME_REQUEST_MAX_MS,
|
|
1922
1904
|
);
|
|
1923
1905
|
return;
|
|
1924
1906
|
}
|
|
1925
1907
|
|
|
1926
|
-
this.#
|
|
1927
|
-
},
|
|
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.#
|
|
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
|
-
#
|
|
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
|
|
2117
|
+
let pliSendResult = undefined;
|
|
2118
|
+
let receiver = this.#videoTransceiver?.receiver;
|
|
2129
2119
|
|
|
2130
|
-
if (
|
|
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.
|
|
2128
|
+
if (typeof video.lastKeyframeRequestTime === 'number' && now - video.lastKeyframeRequestTime < STARTUP_KEYFRAME_REQUEST_INTERVAL_MS) {
|
|
2139
2129
|
return;
|
|
2140
2130
|
}
|
|
2141
2131
|
|
|
2142
|
-
video.
|
|
2132
|
+
video.lastKeyframeRequestTime = now;
|
|
2143
2133
|
video.keyframeRequestInFlight = true;
|
|
2144
2134
|
this.recordKeyframeRequest();
|
|
2145
2135
|
|
|
2146
2136
|
try {
|
|
2147
|
-
|
|
2137
|
+
pliSendResult = receiver.sendRtcpPLI?.(video.ssrc);
|
|
2148
2138
|
|
|
2149
|
-
if (
|
|
2150
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
60
|
-
"@typescript-eslint/parser": "^8.
|
|
62
|
+
"@types/node": "^26.4.1",
|
|
63
|
+
"@typescript-eslint/parser": "^8.69.0",
|
|
61
64
|
"copyfiles": "^2.4.1",
|
|
62
|
-
"eslint": "^10.
|
|
63
|
-
"homebridge": "^1.11.4 || ^2.
|
|
65
|
+
"eslint": "^10.10.0",
|
|
66
|
+
"homebridge": "^1.11.4 || ^2.4.0",
|
|
64
67
|
"prettier": "^3.9.6",
|
|
65
|
-
"prettier-eslint": "^17.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.
|
|
74
|
+
"protobufjs": "^8.8.0",
|
|
72
75
|
"werift": "^0.24.4"
|
|
73
76
|
}
|
|
74
77
|
}
|