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 +24 -4
- package/config.schema.json +50 -1
- package/lib/camera-source.js +65 -100
- 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/lib/intercom.js +16 -208
- package/lib/ring-platform.js +6 -23
- 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)}`);
|
|
@@ -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
|
|
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,
|
|
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/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 {
|
|
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,
|
|
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
|
-
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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: () =>
|
|
110
|
+
getValue: () => 'Intercom Handset Audio',
|
|
303
111
|
});
|
|
304
112
|
this.registerCharacteristic({
|
|
305
113
|
characteristicType: Characteristic.SerialNumber,
|
package/lib/ring-platform.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
240
|
+
const accessory = new api.platformAccessory(displayName, uuid, isCamera
|
|
251
241
|
? 17 /* hap.Categories.CAMERA */
|
|
252
|
-
:
|
|
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.
|
|
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
|
}
|