homebridge-ring-hksv 14.3.6 → 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)}`);
@@ -434,7 +417,7 @@ export class CameraSource {
434
417
  this.cachedSnapshot = undefined;
435
418
  logDebug(`Failed to cache snapshot for ${this.ringCamera.name} (${getDurationSeconds(start)}s), The camera currently reports that it is ${this.ringCamera.isOffline ? 'offline' : 'online'}`);
436
419
  // log additioanl snapshot error message if one is present
437
- if (e.message && e.message.includes('Snapshot')) {
420
+ if (e.message.includes('Snapshot')) {
438
421
  logDebug(e.message);
439
422
  }
440
423
  }
@@ -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/lib/intercom.js CHANGED
@@ -1,125 +1,7 @@
1
- import { RingCamera } from 'ring-client-api';
2
1
  import { hap } from "./hap.js";
3
2
  import { BaseDataAccessory } from "./base-data-accessory.js";
4
3
  import { logError, logInfo } from 'ring-client-api/util';
5
- import { filter, map, share, switchMap, throttleTime } from 'rxjs/operators';
6
- import { interval, merge, Subject } from 'rxjs';
7
- import { CameraSource } from "./camera-source.js";
8
- /**
9
- * Creates a RingCamera-compatible proxy for a Ring Intercom Video device.
10
- * Ring Intercom Video is classified as RingIntercom by ring-client-api but has a
11
- * real camera. We use Object.create(RingCamera.prototype) so that startLiveCall()
12
- * and createStreamingConnection() are inherited and work with the intercom's
13
- * restClient and device id (Ring's signalling server uses the same doorbot_id
14
- * scheme for video intercoms as for cameras).
15
- */
16
- function createIntercomVideoProxy(device) {
17
- const proxy = Object.create(RingCamera.prototype);
18
- const defineValue = (key, value) => Object.defineProperty(proxy, key, {
19
- value,
20
- writable: true,
21
- configurable: true,
22
- enumerable: true,
23
- });
24
- const defineGetter = (key, get) => Object.defineProperty(proxy, key, {
25
- get,
26
- configurable: true,
27
- enumerable: true,
28
- });
29
- defineValue('id', device.id);
30
- defineGetter('name', () => device.name);
31
- defineGetter('deviceType', () => device.deviceType);
32
- defineGetter('data', () => ({
33
- ...device.data,
34
- kind: device.deviceType,
35
- device_id: String(device.id),
36
- metadata: device.data.metadata ?? {},
37
- settings: {
38
- live_view_disabled: false,
39
- motion_detection_enabled: true,
40
- ...device.data.settings,
41
- },
42
- }));
43
- // restClient is technically private in TypeScript but public in compiled JS
44
- defineValue('restClient', device.restClient);
45
- defineGetter('isRingEdgeEnabled', () => false);
46
- defineGetter('hasBattery', () => device.batteryLevel !== null);
47
- defineGetter('isOffline', () => device.isOffline);
48
- defineGetter('snapshotsAreBlocked', () => false);
49
- defineGetter('canTakeSnapshotWhileRecording', () => false);
50
- defineGetter('latestNotificationSnapshotUuid', () => undefined);
51
- defineValue('onNewNotification', new Subject());
52
- defineValue('onMotionDetected', new Subject());
53
- defineValue('onDoorbellPressed', device.onDing);
54
- defineValue('onBatteryLevel', device.onBatteryLevel);
55
- defineValue('onInHomeDoorbellStatus', new Subject());
56
- defineGetter('hasLowBattery', () => Boolean(device.data.alerts?.battery === 'low'));
57
- defineGetter('isCharging', () => false);
58
- defineValue('isDoorbot', true);
59
- defineValue('hasLight', false);
60
- defineValue('hasSiren', false);
61
- defineValue('hasInHomeDoorbell', false);
62
- defineValue('model', 'Ring Intercom Video');
63
- defineValue('snapshotLifeTime', 55000); // camera stays active ~1 min after a ring
64
- // Pre-warm support: start the WebRTC call the moment a ding is detected so
65
- // the camera is already streaming when HKSV requests the recording.
66
- let preWarmedCall = null;
67
- let preWarmCleanup = null;
68
- let lastSnapshotAt = 0;
69
- const realStartLiveCall = () => RingCamera.prototype.startLiveCall.call(proxy);
70
- defineValue('startLiveCall', () => {
71
- if (preWarmedCall) {
72
- const call = preWarmedCall;
73
- preWarmedCall = null;
74
- if (preWarmCleanup) {
75
- clearTimeout(preWarmCleanup);
76
- preWarmCleanup = null;
77
- }
78
- logInfo(`${device.name}: using pre-warmed camera session for recording`);
79
- return call;
80
- }
81
- return realStartLiveCall();
82
- });
83
- defineValue('preWarmCamera', () => {
84
- if (preWarmedCall)
85
- return;
86
- logInfo(`${device.name}: pre-warming camera connection`);
87
- preWarmedCall = realStartLiveCall();
88
- // Clean up if HKSV doesn't claim it within 20 seconds
89
- preWarmCleanup = setTimeout(async () => {
90
- if (preWarmedCall) {
91
- try {
92
- const session = await preWarmedCall;
93
- session.stop();
94
- }
95
- catch (e) {
96
- logError(`Failed to stop pre-warmed camera session for ${device.name}`);
97
- logError(e);
98
- }
99
- finally {
100
- preWarmedCall = null;
101
- preWarmCleanup = null;
102
- }
103
- }
104
- }, 20000);
105
- });
106
- // Override getSnapshot: intercom uses the same doorbots snapshot endpoint as cameras.
107
- defineGetter('hasSnapshotWithinLifetime', () => Date.now() - lastSnapshotAt < proxy.snapshotLifeTime);
108
- defineValue('getSnapshot', async (_options) => {
109
- try {
110
- const snapshot = await device.restClient.request({
111
- url: device.doorbotUrl('snapshot'),
112
- responseType: 'buffer',
113
- });
114
- lastSnapshotAt = Date.now();
115
- return snapshot;
116
- }
117
- catch {
118
- throw new Error(`Snapshot not available for ${device.name}`);
119
- }
120
- });
121
- return proxy;
122
- }
4
+ import { map, throttleTime } from 'rxjs/operators';
123
5
  export class Intercom extends BaseDataAccessory {
124
6
  unlocking = false;
125
7
  unlockTimeout;
@@ -131,64 +13,7 @@ export class Intercom extends BaseDataAccessory {
131
13
  this.device = device;
132
14
  this.accessory = accessory;
133
15
  this.config = config;
134
- const { Characteristic, Service } = hap, isIntercomVideo = device.deviceType === 'intercom_handset_video';
135
- // Set up camera streaming for Ring Intercom Video before other services
136
- let intercomVideoProxy = null;
137
- if (isIntercomVideo) {
138
- const cameraProxy = createIntercomVideoProxy(device);
139
- intercomVideoProxy = cameraProxy;
140
- const cameraSource = new CameraSource(cameraProxy, config);
141
- accessory.configureController(cameraSource.controller);
142
- this.registerCharacteristic({
143
- characteristicType: Characteristic.Mute,
144
- serviceType: Service.Microphone,
145
- getValue: () => false,
146
- });
147
- this.registerCharacteristic({
148
- characteristicType: Characteristic.Mute,
149
- serviceType: Service.Speaker,
150
- getValue: () => false,
151
- });
152
- logInfo(`Camera streaming enabled for Ring Intercom Video: ${device.name}`);
153
- }
154
- const lockService = this.getService(Service.LockMechanism), { LockCurrentState, LockTargetState, ProgrammableSwitchEvent } = Characteristic,
155
- // Ring Intercom Video ding events may arrive as camera-style FCM push
156
- // notifications (different category than RingIntercom.onDing expects),
157
- // so onDing never fires. Poll Ring's active-dings endpoint as a reliable
158
- // fallback — it reflects the current ring in real time (unlike the history
159
- // endpoint which can lag by ~60 seconds).
160
- onDingDetected = (() => {
161
- if (!isIntercomVideo) {
162
- return device.onDing.pipe(share());
163
- }
164
- let lastDingId;
165
- const polled = interval(3000).pipe(switchMap(async () => {
166
- try {
167
- const active = await device.restClient.request({
168
- url: 'https://api.ring.com/clients_api/dings/active',
169
- });
170
- return Array.isArray(active) ? active : [];
171
- }
172
- catch {
173
- return [];
174
- }
175
- }), filter((active) => {
176
- const ding = active.find((d) => String(d.doorbot_id) === String(device.id) &&
177
- d.kind === 'ding');
178
- if (!ding)
179
- return false;
180
- if (String(ding.id) === lastDingId)
181
- return false;
182
- lastDingId = String(ding.id);
183
- return true;
184
- }), map(() => undefined));
185
- // share() makes this hot: one polling interval, one lastDingId,
186
- // emission fans out to all subscribers that rely on ding events.
187
- return merge(device.onDing, polled).pipe(throttleTime(3000), share());
188
- })(), onDoorbellPressed = onDingDetected.pipe(throttleTime(15000), map(() => {
189
- logInfo(`Doorbell pressed on ${device.name} — sending HomeKit event`);
190
- return ProgrammableSwitchEvent.SINGLE_PRESS;
191
- })), syncLockState = () => {
16
+ const { Characteristic, Service } = hap, lockService = this.getService(Service.LockMechanism), { LockCurrentState, LockTargetState, ProgrammableSwitchEvent } = Characteristic, programableSwitchService = this.getService(Service.StatelessProgrammableSwitch), onDoorbellPressed = device.onDing.pipe(throttleTime(15000), map(() => ProgrammableSwitchEvent.SINGLE_PRESS)), syncLockState = () => {
192
17
  const state = this.getLockState();
193
18
  lockService
194
19
  .getCharacteristic(Characteristic.LockCurrentState)
@@ -243,42 +68,25 @@ export class Intercom extends BaseDataAccessory {
243
68
  }
244
69
  },
245
70
  });
246
- // For audio-only intercom the lock is the main function; for video intercom
247
- // the camera controller owns the primary-service role so HomeKit routes
248
- // doorbell events (and HomePod chimes) correctly.
249
- if (!isIntercomVideo) {
250
- lockService.setPrimaryService(true);
251
- }
252
- // Pre-warm the camera WebRTC connection on every ding so HKSV recording
253
- // gets live video instead of black frames.
254
- if (intercomVideoProxy) {
255
- const proxy = intercomVideoProxy;
256
- onDingDetected.subscribe(() => {
257
- ;
258
- proxy.preWarmCamera?.();
259
- });
260
- }
71
+ lockService.setPrimaryService(true);
261
72
  // Doorbell Service
262
73
  this.registerObservableCharacteristic({
263
74
  characteristicType: ProgrammableSwitchEvent,
264
75
  serviceType: Service.Doorbell,
265
76
  onValue: onDoorbellPressed,
266
77
  });
267
- // Programmable Switch Service — only for audio-only intercom; the video
268
- // variant already exposes Service.Doorbell which covers this.
269
- if (!isIntercomVideo) {
270
- const programableSwitchService = this.getService(Service.StatelessProgrammableSwitch);
271
- this.registerObservableCharacteristic({
272
- characteristicType: ProgrammableSwitchEvent,
273
- serviceType: programableSwitchService,
274
- onValue: onDoorbellPressed,
275
- });
276
- programableSwitchService
277
- .getCharacteristic(ProgrammableSwitchEvent)
278
- .setProps({
279
- maxValue: ProgrammableSwitchEvent.SINGLE_PRESS,
280
- });
281
- }
78
+ // Programmable Switch Service
79
+ this.registerObservableCharacteristic({
80
+ characteristicType: ProgrammableSwitchEvent,
81
+ serviceType: programableSwitchService,
82
+ onValue: onDoorbellPressed,
83
+ });
84
+ // Hide long and double press events by setting max value
85
+ programableSwitchService
86
+ .getCharacteristic(ProgrammableSwitchEvent)
87
+ .setProps({
88
+ maxValue: ProgrammableSwitchEvent.SINGLE_PRESS,
89
+ });
282
90
  // Battery Service
283
91
  if (device.batteryLevel !== null) {
284
92
  this.registerObservableCharacteristic({
@@ -299,7 +107,7 @@ export class Intercom extends BaseDataAccessory {
299
107
  this.registerCharacteristic({
300
108
  characteristicType: Characteristic.Model,
301
109
  serviceType: Service.AccessoryInformation,
302
- getValue: () => isIntercomVideo ? 'Ring Intercom Video' : 'Ring Intercom',
110
+ getValue: () => 'Intercom Handset Audio',
303
111
  });
304
112
  this.registerCharacteristic({
305
113
  characteristicType: Characteristic.SerialNumber,
@@ -170,17 +170,10 @@ export class RingPlatform {
170
170
  });
171
171
  await Promise.all(locations.map(async (location) => {
172
172
  const devices = await location.getDevices(), { cameras, chimes, intercoms } = location, allDevices = [...devices, ...cameras, ...chimes, ...intercoms], securityPanel = devices.find((x) => x.deviceType === RingDeviceType.SecurityPanel), debugPrefix = debug ? 'TEST ' : '', hapDevices = allDevices.map((device) => {
173
- const isCamera = device instanceof RingCamera, isIntercomVideo = device instanceof RingIntercom &&
174
- device.deviceType === 'intercom_handset_video', cameraIdentitySalt = isCamera && config.externalCameraIdSalt
173
+ const isCamera = device instanceof RingCamera, cameraIdentitySalt = isCamera && config.externalCameraIdSalt
175
174
  ? `-${config.externalCameraIdSalt}`
176
- : '',
177
- // 'camera' forces bridged cameras from old plugin version to be seen as "stale"
178
- // 'intercam' gives Ring Intercom Video a distinct external-accessory UUID
179
- cameraIdDifferentiator = isCamera
180
- ? 'camera'
181
- : isIntercomVideo
182
- ? 'intercam'
183
- : '', AccessoryClass = (device instanceof RingCamera
175
+ : '', cameraIdDifferentiator = isCamera ? 'camera' : '', // this forces bridged cameras from old version of the plugin to be seen as "stale"
176
+ AccessoryClass = (device instanceof RingCamera
184
177
  ? Camera
185
178
  : device instanceof RingChime
186
179
  ? Chime
@@ -191,7 +184,6 @@ export class RingPlatform {
191
184
  deviceType: device.deviceType,
192
185
  device: device,
193
186
  isCamera,
194
- isIntercomVideo,
195
187
  id: device.id.toString() +
196
188
  cameraIdDifferentiator +
197
189
  cameraIdentitySalt +
@@ -207,7 +199,6 @@ export class RingPlatform {
207
199
  deviceType: securityPanel.deviceType,
208
200
  device: securityPanel,
209
201
  isCamera: false,
210
- isIntercomVideo: false,
211
202
  id: securityPanel.id.toString() + 'panic' + accessoryIdSuffix,
212
203
  name: 'Panic Buttons' + accessoryNameSuffix,
213
204
  AccessoryClass: PanicButtons,
@@ -219,14 +210,13 @@ export class RingPlatform {
219
210
  deviceType: 'location.mode',
220
211
  device: location,
221
212
  isCamera: false,
222
- isIntercomVideo: false,
223
213
  id: location.id + 'mode' + accessoryIdSuffix,
224
214
  name: location.name + ' Mode' + accessoryNameSuffix,
225
215
  AccessoryClass: LocationModeSwitch,
226
216
  });
227
217
  }
228
218
  logInfo(`Configuring ${cameras.length} cameras and ${hapDevices.length} devices for location "${location.name}" - locationId: ${location.id}`);
229
- hapDevices.forEach(({ deviceType, device, isCamera, isIntercomVideo, id, name, AccessoryClass, }) => {
219
+ hapDevices.forEach(({ deviceType, device, isCamera, id, name, AccessoryClass }) => {
230
220
  const uuid = hap.uuid.generate(debugPrefix + id), displayName = debugPrefix + name;
231
221
  if (!AccessoryClass ||
232
222
  (config.hideLightGroups &&
@@ -247,20 +237,13 @@ export class RingPlatform {
247
237
  delete this.homebridgeAccessories[uuid];
248
238
  }
249
239
  const createHomebridgeAccessory = () => {
250
- const category = isCamera
240
+ const accessory = new api.platformAccessory(displayName, uuid, isCamera
251
241
  ? 17 /* hap.Categories.CAMERA */
252
- : isIntercomVideo
253
- ? 18 /* hap.Categories.VIDEO_DOORBELL */
254
- : 11 /* hap.Categories.SECURITY_SYSTEM */;
255
- const accessory = new api.platformAccessory(displayName, uuid, category);
242
+ : 11 /* hap.Categories.SECURITY_SYSTEM */);
256
243
  if (isCamera) {
257
244
  logInfo(`Configured camera ${uuid} ${deviceType} ${displayName}`);
258
245
  externalAccessories.push(accessory);
259
246
  }
260
- else if (isIntercomVideo) {
261
- logInfo(`Configured Ring Intercom Video ${uuid} ${deviceType} ${displayName}`);
262
- externalAccessories.push(accessory);
263
- }
264
247
  else {
265
248
  logInfo(`Adding new accessory ${uuid} ${deviceType} ${displayName}`);
266
249
  platformAccessories.push(accessory);
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.6",
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
  }