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 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 encoder (`h264_videotoolbox` or `libx264`) |
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. A good starting point is 4000-6000 kbps target bitrate, 8000-12000
96
- kbps max bitrate, and the default keyframe interval of 30.
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]
@@ -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 encoder for HKSV recording transcodes. On Apple Silicon or newer Intel Macs, h264_videotoolbox is the recommended first choice because it uses hardware acceleration.",
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",
@@ -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 = 'libx264', hksvVideoCrf, hksvVideoPreset = 'veryfast', } = this.config, bitrateKbps = getIntegerConfigValue(this.config.hksvVideoBitrateKbps, 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 = [
371
- '-vcodec',
372
- cameraVideoCodec,
373
- '-b:v',
374
- `${bitrateKbps}k`,
375
- '-maxrate',
376
- `${maxBitrateKbps}k`,
377
- '-bufsize',
378
- `${bufferSizeKbps}k`,
379
- '-pix_fmt',
380
- 'yuv420p',
381
- '-profile:v',
382
- 'baseline',
383
- '-level:v',
384
- '3.1',
385
- '-g',
386
- `${keyframeInterval}`,
387
- ];
388
- if (cameraVideoCodec === 'libx264') {
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, pendingData = Buffer.alloc(0), initBoxes = [], initSent = false, fragmentBoxes = [], closed = false;
551
- const fragmentLengthMs = this.recordingConfiguration.mediaContainerConfiguration.fragmentLength;
552
- const wake = () => {
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
- }, enqueuePacket = (packet) => {
556
- packetQueue.push(packet);
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: this.getHksvVideoArguments(),
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
- pendingData = Buffer.concat([pendingData, data]);
604
- let consumed = 0;
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 || initSent) {
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
- yield packetQueue.shift();
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.7",
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.11.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
  }