homebridge-ring-hksv 14.3.7 → 14.3.8
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/README.md +24 -4
- package/config.schema.json +50 -1
- package/lib/camera-source.js +64 -99
- package/lib/ffmpeg-capabilities.js +46 -0
- package/lib/fragmented-mp4-parser.js +80 -0
- package/lib/hksv-options.js +35 -0
- package/lib/hksv-work-queue.js +33 -0
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -31,11 +31,13 @@ Big thanks to Dustin and all upstream contributors. This fork reuses and extends
|
|
|
31
31
|
| `hksvPrebufferLengthMs` | HKSV prebuffer duration (minimum 4000ms) |
|
|
32
32
|
| `hksvFragmentLengthMs` | HKSV fragment duration target |
|
|
33
33
|
| `hksvMaxRecordingSeconds` | Optional safety cap for a recording session |
|
|
34
|
+
| `hksvPerformanceMode` | HKSV tuning profile (`balanced`, `rpi`, or `quality`) |
|
|
35
|
+
| `hksvMaxConcurrentRecordings` / `hksvMaxQueuedBytes` | HKSV safeguards for small systems under load |
|
|
34
36
|
| `hksvVideoBitrateKbps` / `hksvVideoMaxBitrateKbps` / `hksvVideoBufferSizeKbps` | HKSV recording bitrate controls for improving fast-motion quality |
|
|
35
37
|
| `hksvVideoCrf` / `hksvVideoPreset` | Optional libx264 quality and CPU tuning controls |
|
|
36
38
|
| `hksvVideoKeyframeInterval` | HKSV recording keyframe interval |
|
|
37
39
|
| `homeKitAccessoryTag` | Appends a tag to accessory names and HomeKit IDs so the same Ring device can be exposed as a distinct HomeKit accessory for debugging/testing |
|
|
38
|
-
| `cameraVideoCodec` | Preferred H.264
|
|
40
|
+
| `cameraVideoCodec` | Preferred H.264 handling (`auto`, `copy`, `h264_v4l2m2m`, `h264_videotoolbox`, or `libx264`) |
|
|
39
41
|
| `hideDoorbellSwitch` / `hideCameraMotionSensor` / `hideCameraSirenSwitch` | Hides specific HomeKit-exposed services |
|
|
40
42
|
| `showPanicButtons` | Adds panic switches (use with caution) |
|
|
41
43
|
| `ffmpegPath` | Override FFmpeg binary path |
|
|
@@ -90,10 +92,28 @@ I currently am able to run 3 cameras with HKSV enabled on a Homebridge instance
|
|
|
90
92
|
Please report your experience and setup details to help improve support.
|
|
91
93
|
|
|
92
94
|
For fast-motion pixelation or stuttering in HKSV recordings, try increasing
|
|
93
|
-
`hksvVideoBitrateKbps` first. On Apple Silicon Macs,
|
|
95
|
+
`hksvVideoBitrateKbps` first if you are transcoding. On Apple Silicon Macs,
|
|
94
96
|
`cameraVideoCodec: "h264_videotoolbox"` can reduce CPU load by using hardware
|
|
95
|
-
encoding.
|
|
96
|
-
|
|
97
|
+
encoding.
|
|
98
|
+
|
|
99
|
+
For Raspberry Pi 4/5 and other small computers, start with:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"enableHksv": true,
|
|
104
|
+
"hksvPerformanceMode": "rpi",
|
|
105
|
+
"cameraVideoCodec": "auto",
|
|
106
|
+
"hksvMaxConcurrentRecordings": 1
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The `rpi` profile favors remuxing Ring's H.264 video into HomeKit Secure Video
|
|
111
|
+
fragmented MP4 instead of re-encoding it. This is much lighter than libx264, but
|
|
112
|
+
it depends on Ring's current stream being compatible with HomeKit's H.264
|
|
113
|
+
recording requirements. If recording fails in this mode, try
|
|
114
|
+
`cameraVideoCodec: "h264_v4l2m2m"` when your FFmpeg build exposes that encoder,
|
|
115
|
+
or fall back to `cameraVideoCodec: "libx264"` with `hksvVideoPreset:
|
|
116
|
+
"ultrafast"`.
|
|
97
117
|
|
|
98
118
|
### Minimum specifications for HKSV:
|
|
99
119
|
[TBD - will be added as more users test and report their setups]
|
package/config.schema.json
CHANGED
|
@@ -53,6 +53,40 @@
|
|
|
53
53
|
"maximum": 300,
|
|
54
54
|
"description": "Optional safety cap for a single HKSV recording stream duration."
|
|
55
55
|
},
|
|
56
|
+
"hksvPerformanceMode": {
|
|
57
|
+
"title": "HKSV Performance Mode",
|
|
58
|
+
"type": "string",
|
|
59
|
+
"description": "High-level HKSV tuning profile. Use rpi for Raspberry Pi and other small computers; it favors remuxing, lower advertised recording tiers, and conservative concurrency.",
|
|
60
|
+
"default": "balanced",
|
|
61
|
+
"oneOf": [
|
|
62
|
+
{
|
|
63
|
+
"title": "balanced",
|
|
64
|
+
"enum": ["balanced"]
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"title": "rpi",
|
|
68
|
+
"enum": ["rpi"]
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"title": "quality",
|
|
72
|
+
"enum": ["quality"]
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
},
|
|
76
|
+
"hksvMaxQueuedBytes": {
|
|
77
|
+
"title": "HKSV Max Queued Bytes",
|
|
78
|
+
"type": "integer",
|
|
79
|
+
"minimum": 1048576,
|
|
80
|
+
"maximum": 67108864,
|
|
81
|
+
"description": "Maximum fragmented MP4 data queued in memory per HKSV recording stream before the stream is closed to protect Homebridge."
|
|
82
|
+
},
|
|
83
|
+
"hksvMaxConcurrentRecordings": {
|
|
84
|
+
"title": "HKSV Max Concurrent Recordings",
|
|
85
|
+
"type": "integer",
|
|
86
|
+
"minimum": 1,
|
|
87
|
+
"maximum": 4,
|
|
88
|
+
"description": "Maximum number of simultaneous HKSV recording pipelines. Raspberry Pi users should keep this at 1."
|
|
89
|
+
},
|
|
56
90
|
"hksvVideoBitrateKbps": {
|
|
57
91
|
"title": "HKSV Video Bitrate (kbps)",
|
|
58
92
|
"type": "integer",
|
|
@@ -141,8 +175,20 @@
|
|
|
141
175
|
"cameraVideoCodec": {
|
|
142
176
|
"title": "Camera Video Codec",
|
|
143
177
|
"type": "string",
|
|
144
|
-
"description": "Preferred FFmpeg H.264
|
|
178
|
+
"description": "Preferred FFmpeg H.264 handling for HKSV recordings. auto chooses the lowest-cost option for the configured performance mode; copy remuxes Ring video without re-encoding.",
|
|
145
179
|
"oneOf": [
|
|
180
|
+
{
|
|
181
|
+
"title": "auto",
|
|
182
|
+
"enum": ["auto"]
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"title": "copy",
|
|
186
|
+
"enum": ["copy"]
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
"title": "h264_v4l2m2m",
|
|
190
|
+
"enum": ["h264_v4l2m2m"]
|
|
191
|
+
},
|
|
146
192
|
{
|
|
147
193
|
"title": "h264_videotoolbox",
|
|
148
194
|
"enum": ["h264_videotoolbox"]
|
|
@@ -295,6 +341,9 @@
|
|
|
295
341
|
"hksvPrebufferLengthMs",
|
|
296
342
|
"hksvFragmentLengthMs",
|
|
297
343
|
"hksvMaxRecordingSeconds",
|
|
344
|
+
"hksvPerformanceMode",
|
|
345
|
+
"hksvMaxQueuedBytes",
|
|
346
|
+
"hksvMaxConcurrentRecordings",
|
|
298
347
|
"hksvVideoBitrateKbps",
|
|
299
348
|
"hksvVideoMaxBitrateKbps",
|
|
300
349
|
"hksvVideoBufferSizeKbps",
|
package/lib/camera-source.js
CHANGED
|
@@ -8,6 +8,10 @@ import { promisify } from 'util';
|
|
|
8
8
|
import { getFfmpegPath } from 'ring-client-api/ffmpeg';
|
|
9
9
|
import { RtcpSenderInfo, RtcpSrPacket, RtpPacket, SrtpSession, SrtcpSession, } from 'werift';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import { FragmentedMp4Parser } from "./fragmented-mp4-parser.js";
|
|
12
|
+
import { getFfmpegCapabilities, } from "./ffmpeg-capabilities.js";
|
|
13
|
+
import { getHksvPerformanceMode, getHksvRecordingResolutions, selectHksvVideoCodec, } from "./hksv-options.js";
|
|
14
|
+
import { hksvRecordingQueue } from "./hksv-work-queue.js";
|
|
11
15
|
const __dirname = new URL('.', import.meta.url).pathname, mediaDirectory = path.join(__dirname.replace(/\/lib\/?$/, ''), 'media'), readFileAsync = promisify(readFile), cameraOfflinePath = path.join(mediaDirectory, 'camera-offline.jpg'), snapshotsBlockedPath = path.join(mediaDirectory, 'snapshots-blocked.jpg');
|
|
12
16
|
function getDurationSeconds(start) {
|
|
13
17
|
return (Date.now() - start) / 1000;
|
|
@@ -29,30 +33,6 @@ function getIntegerConfigValue(value, defaultValue, min, max) {
|
|
|
29
33
|
}
|
|
30
34
|
return Math.min(Math.max(Math.round(value), min), max);
|
|
31
35
|
}
|
|
32
|
-
function* parseMp4Boxes(data) {
|
|
33
|
-
let offset = 0;
|
|
34
|
-
while (offset + 8 <= data.length) {
|
|
35
|
-
const shortLength = data.readUInt32BE(offset);
|
|
36
|
-
let boxLength = shortLength;
|
|
37
|
-
let headerLength = 8;
|
|
38
|
-
if (shortLength === 1) {
|
|
39
|
-
if (offset + 16 > data.length) {
|
|
40
|
-
break;
|
|
41
|
-
}
|
|
42
|
-
boxLength = Number(data.readBigUInt64BE(offset + 8));
|
|
43
|
-
headerLength = 16;
|
|
44
|
-
}
|
|
45
|
-
else if (shortLength === 0) {
|
|
46
|
-
break;
|
|
47
|
-
}
|
|
48
|
-
if (boxLength < headerLength || offset + boxLength > data.length) {
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
const box = data.subarray(offset, offset + boxLength), type = box.subarray(4, 8).toString('ascii');
|
|
52
|
-
yield { type, box };
|
|
53
|
-
offset += boxLength;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
36
|
class StreamingSessionWrapper {
|
|
57
37
|
audioSsrc = hap.CameraController.generateSynchronisationSource();
|
|
58
38
|
videoSsrc = hap.CameraController.generateSynchronisationSource();
|
|
@@ -269,6 +249,7 @@ export class CameraSource {
|
|
|
269
249
|
constructor(ringCamera, config) {
|
|
270
250
|
this.ringCamera = ringCamera;
|
|
271
251
|
this.config = config;
|
|
252
|
+
hksvRecordingQueue.setConcurrency(getIntegerConfigValue(config.hksvMaxConcurrentRecordings, getHksvPerformanceMode(config) === 'rpi' ? 1 : 2, 1, 4));
|
|
272
253
|
const enableHksv = config.enableHksv && !(config.disableHksvOnBattery && ringCamera.hasBattery);
|
|
273
254
|
const controllerOptions = {
|
|
274
255
|
cameraStreamCount: enableHksv ? 1 : 10,
|
|
@@ -340,12 +321,7 @@ export class CameraSource {
|
|
|
340
321
|
2 /* H264Level.LEVEL4_0 */,
|
|
341
322
|
],
|
|
342
323
|
},
|
|
343
|
-
resolutions:
|
|
344
|
-
[1920, 1080, 30],
|
|
345
|
-
[1280, 720, 30],
|
|
346
|
-
[640, 480, 30],
|
|
347
|
-
[320, 240, 15],
|
|
348
|
-
],
|
|
324
|
+
resolutions: getHksvRecordingResolutions(config),
|
|
349
325
|
},
|
|
350
326
|
audio: {
|
|
351
327
|
codecs: {
|
|
@@ -366,26 +342,33 @@ export class CameraSource {
|
|
|
366
342
|
}
|
|
367
343
|
this.controller = new hap.CameraController(controllerOptions);
|
|
368
344
|
}
|
|
369
|
-
getHksvVideoArguments() {
|
|
370
|
-
const { cameraVideoCodec
|
|
371
|
-
'
|
|
372
|
-
cameraVideoCodec,
|
|
373
|
-
'-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
345
|
+
getHksvVideoArguments(capabilities) {
|
|
346
|
+
const { cameraVideoCodec, hksvVideoCrf, hksvVideoPreset = getHksvPerformanceMode(this.config) === 'rpi'
|
|
347
|
+
? 'ultrafast'
|
|
348
|
+
: 'veryfast', } = this.config, selectedCodec = selectHksvVideoCodec(cameraVideoCodec, capabilities, this.config), bitrateKbps = getIntegerConfigValue(this.config.hksvVideoBitrateKbps, getHksvPerformanceMode(this.config) === 'rpi' ? 1000 : 3000, 256, 12000), maxBitrateKbps = getIntegerConfigValue(this.config.hksvVideoMaxBitrateKbps, bitrateKbps * 2, bitrateKbps, 20000), bufferSizeKbps = getIntegerConfigValue(this.config.hksvVideoBufferSizeKbps, maxBitrateKbps * 2, maxBitrateKbps, 40000), keyframeInterval = getIntegerConfigValue(this.config.hksvVideoKeyframeInterval, 30, 5, 240), videoArguments = selectedCodec === 'copy'
|
|
349
|
+
? ['-vcodec', 'copy']
|
|
350
|
+
: [
|
|
351
|
+
'-vcodec',
|
|
352
|
+
selectedCodec,
|
|
353
|
+
'-b:v',
|
|
354
|
+
`${bitrateKbps}k`,
|
|
355
|
+
'-maxrate',
|
|
356
|
+
`${maxBitrateKbps}k`,
|
|
357
|
+
'-bufsize',
|
|
358
|
+
`${bufferSizeKbps}k`,
|
|
359
|
+
'-pix_fmt',
|
|
360
|
+
'yuv420p',
|
|
361
|
+
'-profile:v',
|
|
362
|
+
'baseline',
|
|
363
|
+
'-level:v',
|
|
364
|
+
'3.1',
|
|
365
|
+
'-g',
|
|
366
|
+
`${keyframeInterval}`,
|
|
367
|
+
];
|
|
368
|
+
if (selectedCodec === 'copy') {
|
|
369
|
+
return videoArguments;
|
|
370
|
+
}
|
|
371
|
+
if (selectedCodec === 'libx264') {
|
|
389
372
|
videoArguments.push('-preset', hksvVideoPreset, '-tune', 'zerolatency', '-keyint_min', `${keyframeInterval}`, '-sc_threshold', '0');
|
|
390
373
|
if (Number.isFinite(hksvVideoCrf)) {
|
|
391
374
|
videoArguments.push('-crf', `${getIntegerConfigValue(hksvVideoCrf, 23, 18, 35)}`);
|
|
@@ -547,15 +530,15 @@ export class CameraSource {
|
|
|
547
530
|
}
|
|
548
531
|
this.closedRecordingStreams.delete(streamId);
|
|
549
532
|
const packetQueue = [];
|
|
550
|
-
let waitForPacket,
|
|
551
|
-
const fragmentLengthMs = this.recordingConfiguration.mediaContainerConfiguration.fragmentLength
|
|
552
|
-
|
|
533
|
+
let waitForPacket, queuedBytes = 0, closed = false;
|
|
534
|
+
const fragmentLengthMs = this.recordingConfiguration.mediaContainerConfiguration.fragmentLength, parser = new FragmentedMp4Parser(), maxQueuedBytes = getIntegerConfigValue(this.config.hksvMaxQueuedBytes, getHksvPerformanceMode(this.config) === 'rpi'
|
|
535
|
+
? 6 * 1024 * 1024
|
|
536
|
+
: 16 * 1024 * 1024, 1024 * 1024, 64 * 1024 * 1024), start = Date.now();
|
|
537
|
+
function wake() {
|
|
553
538
|
waitForPacket?.();
|
|
554
539
|
waitForPacket = undefined;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
wake();
|
|
558
|
-
}, closeSession = () => {
|
|
540
|
+
}
|
|
541
|
+
const closeSession = () => {
|
|
559
542
|
if (closed) {
|
|
560
543
|
return;
|
|
561
544
|
}
|
|
@@ -563,13 +546,26 @@ export class CameraSource {
|
|
|
563
546
|
this.closedRecordingStreams.add(streamId);
|
|
564
547
|
this.activeRecordingSessions.delete(streamId);
|
|
565
548
|
wake();
|
|
549
|
+
}, enqueuePacket = (packet) => {
|
|
550
|
+
queuedBytes += packet.data.length;
|
|
551
|
+
if (queuedBytes > maxQueuedBytes) {
|
|
552
|
+
logInfo(`HKSV recording queue limit reached for ${this.ringCamera.name} (streamId=${streamId}, queuedBytes=${queuedBytes}, maxQueuedBytes=${maxQueuedBytes})`);
|
|
553
|
+
closeSession();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
packetQueue.push(packet);
|
|
557
|
+
wake();
|
|
566
558
|
};
|
|
567
559
|
this.recordingWaiters.set(streamId, closeSession);
|
|
568
560
|
this.activeRecordingSessions.set(streamId, closeSession);
|
|
569
561
|
let liveCall;
|
|
570
562
|
let keyFrameTimer;
|
|
571
563
|
let maxRecordingTimer;
|
|
564
|
+
let releaseQueueSlot;
|
|
572
565
|
try {
|
|
566
|
+
releaseQueueSlot = await hksvRecordingQueue.acquire();
|
|
567
|
+
const capabilities = await getFfmpegCapabilities(), videoArguments = this.getHksvVideoArguments(capabilities), selectedVideoCodec = String(videoArguments[1]);
|
|
568
|
+
logInfo(`Starting HKSV recording pipeline for ${this.ringCamera.name} (streamId=${streamId}, mode=${getHksvPerformanceMode(this.config)}, video=${selectedVideoCodec})`);
|
|
573
569
|
liveCall = await this.ringCamera.startLiveCall();
|
|
574
570
|
liveCall.onCallEnded.pipe(take(1)).subscribe(() => {
|
|
575
571
|
closeSession();
|
|
@@ -582,7 +578,7 @@ export class CameraSource {
|
|
|
582
578
|
}, maxRecordingSeconds * 1000);
|
|
583
579
|
}
|
|
584
580
|
await liveCall.startTranscoding({
|
|
585
|
-
video:
|
|
581
|
+
video: videoArguments,
|
|
586
582
|
output: [
|
|
587
583
|
'-movflags',
|
|
588
584
|
'frag_keyframe+empty_moov+default_base_moof',
|
|
@@ -600,50 +596,15 @@ export class CameraSource {
|
|
|
600
596
|
if (closed) {
|
|
601
597
|
return;
|
|
602
598
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
for (const { type, box } of parseMp4Boxes(pendingData)) {
|
|
606
|
-
consumed += box.length;
|
|
607
|
-
if (!initSent) {
|
|
608
|
-
initBoxes.push(box);
|
|
609
|
-
if (type === 'moov') {
|
|
610
|
-
enqueuePacket({
|
|
611
|
-
data: Buffer.concat(initBoxes),
|
|
612
|
-
isLast: false,
|
|
613
|
-
});
|
|
614
|
-
initBoxes = [];
|
|
615
|
-
initSent = true;
|
|
616
|
-
}
|
|
617
|
-
continue;
|
|
618
|
-
}
|
|
619
|
-
if (type === 'styp') {
|
|
620
|
-
fragmentBoxes = [box];
|
|
621
|
-
continue;
|
|
622
|
-
}
|
|
623
|
-
if (type === 'moof') {
|
|
624
|
-
fragmentBoxes = fragmentBoxes.length ? [...fragmentBoxes, box] : [box];
|
|
625
|
-
continue;
|
|
626
|
-
}
|
|
627
|
-
if (!fragmentBoxes.length) {
|
|
628
|
-
continue;
|
|
629
|
-
}
|
|
630
|
-
fragmentBoxes.push(box);
|
|
631
|
-
if (type === 'mdat') {
|
|
632
|
-
enqueuePacket({
|
|
633
|
-
data: Buffer.concat(fragmentBoxes),
|
|
634
|
-
isLast: false,
|
|
635
|
-
});
|
|
636
|
-
fragmentBoxes = [];
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
if (consumed > 0) {
|
|
640
|
-
pendingData = pendingData.subarray(consumed);
|
|
599
|
+
for (const packet of parser.append(data)) {
|
|
600
|
+
enqueuePacket(packet);
|
|
641
601
|
}
|
|
642
602
|
},
|
|
643
603
|
});
|
|
644
604
|
liveCall.requestKeyFrame();
|
|
645
605
|
keyFrameTimer = setInterval(() => {
|
|
646
|
-
if (closed ||
|
|
606
|
+
if (closed ||
|
|
607
|
+
(selectedVideoCodec !== 'copy' && parser.hasInitializationSegment)) {
|
|
647
608
|
if (keyFrameTimer) {
|
|
648
609
|
clearInterval(keyFrameTimer);
|
|
649
610
|
keyFrameTimer = undefined;
|
|
@@ -651,10 +612,12 @@ export class CameraSource {
|
|
|
651
612
|
return;
|
|
652
613
|
}
|
|
653
614
|
liveCall?.requestKeyFrame();
|
|
654
|
-
}, 2000);
|
|
615
|
+
}, selectedVideoCodec === 'copy' ? Math.max(fragmentLengthMs, 1000) : 2000);
|
|
655
616
|
while (!closed) {
|
|
656
617
|
if (packetQueue.length) {
|
|
657
|
-
|
|
618
|
+
const packet = packetQueue.shift();
|
|
619
|
+
queuedBytes -= packet.data.length;
|
|
620
|
+
yield packet;
|
|
658
621
|
continue;
|
|
659
622
|
}
|
|
660
623
|
await new Promise((resolve) => {
|
|
@@ -669,6 +632,7 @@ export class CameraSource {
|
|
|
669
632
|
closeSession();
|
|
670
633
|
}
|
|
671
634
|
finally {
|
|
635
|
+
logInfo(`HKSV recording pipeline ended for ${this.ringCamera.name} (streamId=${streamId}, duration=${getDurationSeconds(start)}s, queuedBytes=${queuedBytes})`);
|
|
672
636
|
this.recordingWaiters.delete(streamId);
|
|
673
637
|
this.activeRecordingSessions.delete(streamId);
|
|
674
638
|
if (keyFrameTimer) {
|
|
@@ -680,6 +644,7 @@ export class CameraSource {
|
|
|
680
644
|
if (liveCall) {
|
|
681
645
|
liveCall.stop();
|
|
682
646
|
}
|
|
647
|
+
releaseQueueSlot?.();
|
|
683
648
|
}
|
|
684
649
|
}
|
|
685
650
|
closeRecordingStream(streamId, reason) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { getFfmpegPath } from 'ring-client-api/ffmpeg';
|
|
4
|
+
import { logDebug } from 'ring-client-api/util';
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
let cachedCapabilities;
|
|
7
|
+
function parseNames(output) {
|
|
8
|
+
return new Set(output
|
|
9
|
+
.split(/\s+/)
|
|
10
|
+
.map((entry) => entry.trim())
|
|
11
|
+
.filter(Boolean));
|
|
12
|
+
}
|
|
13
|
+
async function getFfmpegOutput(args) {
|
|
14
|
+
const ffmpegPath = getFfmpegPath() || 'ffmpeg';
|
|
15
|
+
try {
|
|
16
|
+
const { stdout, stderr } = await execFileAsync(ffmpegPath, args, {
|
|
17
|
+
maxBuffer: 1024 * 1024,
|
|
18
|
+
});
|
|
19
|
+
return `${stdout}\n${stderr}`;
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
return `${e.stdout ?? ''}\n${e.stderr ?? ''}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function loadCapabilities() {
|
|
26
|
+
const [encodersOutput, hwaccelsOutput] = await Promise.all([
|
|
27
|
+
getFfmpegOutput(['-hide_banner', '-encoders']),
|
|
28
|
+
getFfmpegOutput(['-hide_banner', '-hwaccels']),
|
|
29
|
+
]);
|
|
30
|
+
const capabilities = {
|
|
31
|
+
encoders: parseNames(encodersOutput),
|
|
32
|
+
hwaccels: parseNames(hwaccelsOutput),
|
|
33
|
+
};
|
|
34
|
+
logDebug(`FFmpeg HKSV capabilities: encoders=${[
|
|
35
|
+
'h264_v4l2m2m',
|
|
36
|
+
'h264_videotoolbox',
|
|
37
|
+
'libx264',
|
|
38
|
+
]
|
|
39
|
+
.filter((encoder) => capabilities.encoders.has(encoder))
|
|
40
|
+
.join(',') || 'none detected'}`);
|
|
41
|
+
return capabilities;
|
|
42
|
+
}
|
|
43
|
+
export function getFfmpegCapabilities() {
|
|
44
|
+
cachedCapabilities ??= loadCapabilities();
|
|
45
|
+
return cachedCapabilities;
|
|
46
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
function* parseMp4Boxes(data) {
|
|
2
|
+
let offset = 0;
|
|
3
|
+
while (offset + 8 <= data.length) {
|
|
4
|
+
const shortLength = data.readUInt32BE(offset);
|
|
5
|
+
let boxLength = shortLength;
|
|
6
|
+
let headerLength = 8;
|
|
7
|
+
if (shortLength === 1) {
|
|
8
|
+
if (offset + 16 > data.length) {
|
|
9
|
+
break;
|
|
10
|
+
}
|
|
11
|
+
boxLength = Number(data.readBigUInt64BE(offset + 8));
|
|
12
|
+
headerLength = 16;
|
|
13
|
+
}
|
|
14
|
+
else if (shortLength === 0) {
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
if (boxLength < headerLength || offset + boxLength > data.length) {
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
const box = data.subarray(offset, offset + boxLength), type = box.subarray(4, 8).toString('ascii');
|
|
21
|
+
yield { type, box };
|
|
22
|
+
offset += boxLength;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class FragmentedMp4Parser {
|
|
26
|
+
pendingData = Buffer.alloc(0);
|
|
27
|
+
initBoxes = [];
|
|
28
|
+
initSent = false;
|
|
29
|
+
fragmentBoxes = [];
|
|
30
|
+
get hasInitializationSegment() {
|
|
31
|
+
return this.initSent;
|
|
32
|
+
}
|
|
33
|
+
append(data) {
|
|
34
|
+
const packets = [];
|
|
35
|
+
this.pendingData = this.pendingData.length
|
|
36
|
+
? Buffer.concat([this.pendingData, data])
|
|
37
|
+
: data;
|
|
38
|
+
let consumed = 0;
|
|
39
|
+
for (const { type, box } of parseMp4Boxes(this.pendingData)) {
|
|
40
|
+
consumed += box.length;
|
|
41
|
+
if (!this.initSent) {
|
|
42
|
+
this.initBoxes.push(box);
|
|
43
|
+
if (type === 'moov') {
|
|
44
|
+
packets.push({
|
|
45
|
+
data: Buffer.concat(this.initBoxes),
|
|
46
|
+
isLast: false,
|
|
47
|
+
});
|
|
48
|
+
this.initBoxes = [];
|
|
49
|
+
this.initSent = true;
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (type === 'styp') {
|
|
54
|
+
this.fragmentBoxes = [box];
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (type === 'moof') {
|
|
58
|
+
this.fragmentBoxes = this.fragmentBoxes.length
|
|
59
|
+
? [...this.fragmentBoxes, box]
|
|
60
|
+
: [box];
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!this.fragmentBoxes.length) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
this.fragmentBoxes.push(box);
|
|
67
|
+
if (type === 'mdat') {
|
|
68
|
+
packets.push({
|
|
69
|
+
data: Buffer.concat(this.fragmentBoxes),
|
|
70
|
+
isLast: false,
|
|
71
|
+
});
|
|
72
|
+
this.fragmentBoxes = [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (consumed > 0) {
|
|
76
|
+
this.pendingData = this.pendingData.subarray(consumed);
|
|
77
|
+
}
|
|
78
|
+
return packets;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function getHksvPerformanceMode(config) {
|
|
2
|
+
return config.hksvPerformanceMode ?? 'balanced';
|
|
3
|
+
}
|
|
4
|
+
export function getHksvRecordingResolutions(config) {
|
|
5
|
+
if (getHksvPerformanceMode(config) === 'rpi') {
|
|
6
|
+
return [
|
|
7
|
+
[1280, 720, 30],
|
|
8
|
+
[640, 480, 30],
|
|
9
|
+
[640, 360, 30],
|
|
10
|
+
[320, 240, 15],
|
|
11
|
+
];
|
|
12
|
+
}
|
|
13
|
+
return [
|
|
14
|
+
[1920, 1080, 30],
|
|
15
|
+
[1280, 720, 30],
|
|
16
|
+
[640, 480, 30],
|
|
17
|
+
[320, 240, 15],
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
export function selectHksvVideoCodec(configuredCodec, capabilities, config) {
|
|
21
|
+
const codec = configuredCodec ?? 'auto';
|
|
22
|
+
if (codec !== 'auto') {
|
|
23
|
+
return codec;
|
|
24
|
+
}
|
|
25
|
+
if (getHksvPerformanceMode(config) === 'rpi') {
|
|
26
|
+
return 'copy';
|
|
27
|
+
}
|
|
28
|
+
if (capabilities?.encoders.has('h264_v4l2m2m')) {
|
|
29
|
+
return 'h264_v4l2m2m';
|
|
30
|
+
}
|
|
31
|
+
if (capabilities?.encoders.has('h264_videotoolbox')) {
|
|
32
|
+
return 'h264_videotoolbox';
|
|
33
|
+
}
|
|
34
|
+
return 'libx264';
|
|
35
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class WorkQueue {
|
|
2
|
+
active = 0;
|
|
3
|
+
waiters = [];
|
|
4
|
+
concurrency;
|
|
5
|
+
constructor(concurrency) {
|
|
6
|
+
this.concurrency = concurrency;
|
|
7
|
+
}
|
|
8
|
+
setConcurrency(concurrency) {
|
|
9
|
+
this.concurrency = Math.max(1, Math.floor(concurrency));
|
|
10
|
+
this.drain();
|
|
11
|
+
}
|
|
12
|
+
async acquire() {
|
|
13
|
+
if (this.active < this.concurrency) {
|
|
14
|
+
this.active++;
|
|
15
|
+
return () => this.release();
|
|
16
|
+
}
|
|
17
|
+
await new Promise((resolve) => {
|
|
18
|
+
this.waiters.push(resolve);
|
|
19
|
+
});
|
|
20
|
+
this.active++;
|
|
21
|
+
return () => this.release();
|
|
22
|
+
}
|
|
23
|
+
release() {
|
|
24
|
+
this.active = Math.max(0, this.active - 1);
|
|
25
|
+
this.drain();
|
|
26
|
+
}
|
|
27
|
+
drain() {
|
|
28
|
+
while (this.active < this.concurrency && this.waiters.length) {
|
|
29
|
+
this.waiters.shift()?.();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export const hksvRecordingQueue = new WorkQueue(1);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-ring-hksv",
|
|
3
3
|
"displayName": "Homebridge Ring HKSV",
|
|
4
|
-
"version": "14.3.
|
|
4
|
+
"version": "14.3.8",
|
|
5
5
|
"description": "Homebridge plugin for Ring devices with HomeKit Secure Video support",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./lib/index.js",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@homebridge/camera-utils": "^3.0.0",
|
|
19
19
|
"@homebridge/plugin-ui-utils": "^2.1.2",
|
|
20
|
+
"homebridge-config-ui-x": "^5.24.0",
|
|
20
21
|
"ring-client-api": "14.3.0",
|
|
21
22
|
"werift": "0.22.4"
|
|
22
23
|
},
|
|
@@ -26,12 +27,12 @@
|
|
|
26
27
|
"concurrently": "^9.2.1",
|
|
27
28
|
"eslint": "9.39.2",
|
|
28
29
|
"globals": "17.3.0",
|
|
29
|
-
"homebridge": "1.
|
|
30
|
+
"homebridge": "^2.1.0",
|
|
30
31
|
"typescript": "5.9.3",
|
|
31
32
|
"typescript-eslint": "8.54.0"
|
|
32
33
|
},
|
|
33
34
|
"engines": {
|
|
34
|
-
"node": "^20 || ^22 || ^24",
|
|
35
|
+
"node": "^20 || ^22 || ^24 || ^26",
|
|
35
36
|
"homebridge": ">=1.4.0 || ^2.0.0-beta.0"
|
|
36
37
|
},
|
|
37
38
|
"homebridge": {
|
|
@@ -84,5 +85,9 @@
|
|
|
84
85
|
"category-video",
|
|
85
86
|
"category-hubs",
|
|
86
87
|
"category-outdoor"
|
|
87
|
-
]
|
|
88
|
+
],
|
|
89
|
+
"allowScripts": {
|
|
90
|
+
"ffmpeg-for-homebridge@2.2.1": true,
|
|
91
|
+
"protobufjs@7.6.4": true
|
|
92
|
+
}
|
|
88
93
|
}
|