homebridge-ring-hksv 14.3.4 → 14.3.6
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/CHANGELOG.md +4 -0
- package/README.md +9 -0
- package/config.schema.json +87 -1
- package/lib/camera-source.js +48 -21
- package/lib/intercom.js +208 -16
- package/lib/lock.js +10 -1
- package/lib/ring-platform.js +23 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Prevent lock accessories from reporting invalid `LockTargetState` values when Ring reports a jammed or unknown current state. This removes repeated Homebridge warnings while preserving the correct current lock state in HomeKit.
|
|
8
|
+
|
|
5
9
|
## 14.3.4
|
|
6
10
|
|
|
7
11
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -31,6 +31,9 @@ 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
|
+
| `hksvVideoBitrateKbps` / `hksvVideoMaxBitrateKbps` / `hksvVideoBufferSizeKbps` | HKSV recording bitrate controls for improving fast-motion quality |
|
|
35
|
+
| `hksvVideoCrf` / `hksvVideoPreset` | Optional libx264 quality and CPU tuning controls |
|
|
36
|
+
| `hksvVideoKeyframeInterval` | HKSV recording keyframe interval |
|
|
34
37
|
| `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 |
|
|
35
38
|
| `cameraVideoCodec` | Preferred H.264 encoder (`h264_videotoolbox` or `libx264`) |
|
|
36
39
|
| `hideDoorbellSwitch` / `hideCameraMotionSensor` / `hideCameraSirenSwitch` | Hides specific HomeKit-exposed services |
|
|
@@ -86,6 +89,12 @@ HKSV support is experimental and actively evolving. Behavior may vary by camera
|
|
|
86
89
|
I currently am able to run 3 cameras with HKSV enabled on a Homebridge instance ran on a M4 Mac Mini 32GB of RAM.
|
|
87
90
|
Please report your experience and setup details to help improve support.
|
|
88
91
|
|
|
92
|
+
For fast-motion pixelation or stuttering in HKSV recordings, try increasing
|
|
93
|
+
`hksvVideoBitrateKbps` first. On Apple Silicon Macs,
|
|
94
|
+
`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
|
+
|
|
89
98
|
### Minimum specifications for HKSV:
|
|
90
99
|
[TBD - will be added as more users test and report their setups]
|
|
91
100
|
|
package/config.schema.json
CHANGED
|
@@ -53,6 +53,86 @@
|
|
|
53
53
|
"maximum": 300,
|
|
54
54
|
"description": "Optional safety cap for a single HKSV recording stream duration."
|
|
55
55
|
},
|
|
56
|
+
"hksvVideoBitrateKbps": {
|
|
57
|
+
"title": "HKSV Video Bitrate (kbps)",
|
|
58
|
+
"type": "integer",
|
|
59
|
+
"minimum": 256,
|
|
60
|
+
"maximum": 12000,
|
|
61
|
+
"default": 3000,
|
|
62
|
+
"description": "Target video bitrate for HKSV recording transcodes. Increase this first if fast motion becomes blocky or pixelated. 4000-6000 is a good starting range for wired cameras."
|
|
63
|
+
},
|
|
64
|
+
"hksvVideoMaxBitrateKbps": {
|
|
65
|
+
"title": "HKSV Max Video Bitrate (kbps)",
|
|
66
|
+
"type": "integer",
|
|
67
|
+
"minimum": 256,
|
|
68
|
+
"maximum": 20000,
|
|
69
|
+
"description": "Maximum burst bitrate for HKSV recording transcodes. Defaults to 2x the target bitrate. A practical starting range is 8000-12000 when target bitrate is 4000-6000."
|
|
70
|
+
},
|
|
71
|
+
"hksvVideoBufferSizeKbps": {
|
|
72
|
+
"title": "HKSV Video Buffer Size (kbps)",
|
|
73
|
+
"type": "integer",
|
|
74
|
+
"minimum": 256,
|
|
75
|
+
"maximum": 40000,
|
|
76
|
+
"description": "FFmpeg VBV buffer size for HKSV recording transcodes. Defaults to 2x the max video bitrate. Leave unset unless you are tuning bitrate stability."
|
|
77
|
+
},
|
|
78
|
+
"hksvVideoCrf": {
|
|
79
|
+
"title": "HKSV x264 Quality (CRF)",
|
|
80
|
+
"type": "integer",
|
|
81
|
+
"minimum": 18,
|
|
82
|
+
"maximum": 35,
|
|
83
|
+
"description": "Optional libx264 quality value. Lower values improve quality and use more bitrate/CPU. Try 20-23 if you stay on libx264. Ignored when using VideoToolbox."
|
|
84
|
+
},
|
|
85
|
+
"hksvVideoKeyframeInterval": {
|
|
86
|
+
"title": "HKSV Keyframe Interval",
|
|
87
|
+
"type": "integer",
|
|
88
|
+
"minimum": 5,
|
|
89
|
+
"maximum": 240,
|
|
90
|
+
"default": 30,
|
|
91
|
+
"description": "Maximum number of frames between video keyframes in HKSV recordings. 30 is the recommended default. Shorter intervals can improve fragmenting at the cost of bitrate."
|
|
92
|
+
},
|
|
93
|
+
"hksvVideoPreset": {
|
|
94
|
+
"title": "HKSV x264 Preset",
|
|
95
|
+
"type": "string",
|
|
96
|
+
"description": "libx264 speed/quality preset for HKSV recordings. veryfast is the recommended default. Move toward fast or medium for quality, or superfast for lower CPU. Ignored when using VideoToolbox.",
|
|
97
|
+
"oneOf": [
|
|
98
|
+
{
|
|
99
|
+
"title": "ultrafast",
|
|
100
|
+
"enum": ["ultrafast"]
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"title": "superfast",
|
|
104
|
+
"enum": ["superfast"]
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"title": "veryfast",
|
|
108
|
+
"enum": ["veryfast"]
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"title": "faster",
|
|
112
|
+
"enum": ["faster"]
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"title": "fast",
|
|
116
|
+
"enum": ["fast"]
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"title": "medium",
|
|
120
|
+
"enum": ["medium"]
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"title": "slow",
|
|
124
|
+
"enum": ["slow"]
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"title": "slower",
|
|
128
|
+
"enum": ["slower"]
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"title": "veryslow",
|
|
132
|
+
"enum": ["veryslow"]
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
},
|
|
56
136
|
"homeKitAccessoryTag": {
|
|
57
137
|
"title": "HomeKit Accessory Tag",
|
|
58
138
|
"type": "string",
|
|
@@ -61,7 +141,7 @@
|
|
|
61
141
|
"cameraVideoCodec": {
|
|
62
142
|
"title": "Camera Video Codec",
|
|
63
143
|
"type": "string",
|
|
64
|
-
"description": "Preferred
|
|
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.",
|
|
65
145
|
"oneOf": [
|
|
66
146
|
{
|
|
67
147
|
"title": "h264_videotoolbox",
|
|
@@ -215,6 +295,12 @@
|
|
|
215
295
|
"hksvPrebufferLengthMs",
|
|
216
296
|
"hksvFragmentLengthMs",
|
|
217
297
|
"hksvMaxRecordingSeconds",
|
|
298
|
+
"hksvVideoBitrateKbps",
|
|
299
|
+
"hksvVideoMaxBitrateKbps",
|
|
300
|
+
"hksvVideoBufferSizeKbps",
|
|
301
|
+
"hksvVideoCrf",
|
|
302
|
+
"hksvVideoKeyframeInterval",
|
|
303
|
+
"hksvVideoPreset",
|
|
218
304
|
"homeKitAccessoryTag",
|
|
219
305
|
"cameraVideoCodec",
|
|
220
306
|
"hideLightGroups",
|
package/lib/camera-source.js
CHANGED
|
@@ -23,6 +23,12 @@ function getSessionConfig(srtpOptions) {
|
|
|
23
23
|
profile: 1,
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
+
function getIntegerConfigValue(value, defaultValue, min, max) {
|
|
27
|
+
if (!Number.isFinite(value)) {
|
|
28
|
+
return defaultValue;
|
|
29
|
+
}
|
|
30
|
+
return Math.min(Math.max(Math.round(value), min), max);
|
|
31
|
+
}
|
|
26
32
|
function* parseMp4Boxes(data) {
|
|
27
33
|
let offset = 0;
|
|
28
34
|
while (offset + 8 <= data.length) {
|
|
@@ -254,6 +260,7 @@ export class CameraSource {
|
|
|
254
260
|
sessions = {};
|
|
255
261
|
cachedSnapshot;
|
|
256
262
|
ringCamera;
|
|
263
|
+
config;
|
|
257
264
|
recordingActive = false;
|
|
258
265
|
recordingConfiguration;
|
|
259
266
|
closedRecordingStreams = new Set();
|
|
@@ -261,6 +268,7 @@ export class CameraSource {
|
|
|
261
268
|
activeRecordingSessions = new Map();
|
|
262
269
|
constructor(ringCamera, config) {
|
|
263
270
|
this.ringCamera = ringCamera;
|
|
271
|
+
this.config = config;
|
|
264
272
|
const enableHksv = config.enableHksv && !(config.disableHksvOnBattery && ringCamera.hasBattery);
|
|
265
273
|
const controllerOptions = {
|
|
266
274
|
cameraStreamCount: enableHksv ? 1 : 10,
|
|
@@ -358,6 +366,33 @@ export class CameraSource {
|
|
|
358
366
|
}
|
|
359
367
|
this.controller = new hap.CameraController(controllerOptions);
|
|
360
368
|
}
|
|
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') {
|
|
389
|
+
videoArguments.push('-preset', hksvVideoPreset, '-tune', 'zerolatency', '-keyint_min', `${keyframeInterval}`, '-sc_threshold', '0');
|
|
390
|
+
if (Number.isFinite(hksvVideoCrf)) {
|
|
391
|
+
videoArguments.push('-crf', `${getIntegerConfigValue(hksvVideoCrf, 23, 18, 35)}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return videoArguments;
|
|
395
|
+
}
|
|
361
396
|
previousLoadSnapshotPromise;
|
|
362
397
|
async loadSnapshot(imageUuid) {
|
|
363
398
|
// cache a promise of the snapshot load
|
|
@@ -399,7 +434,7 @@ export class CameraSource {
|
|
|
399
434
|
this.cachedSnapshot = undefined;
|
|
400
435
|
logDebug(`Failed to cache snapshot for ${this.ringCamera.name} (${getDurationSeconds(start)}s), The camera currently reports that it is ${this.ringCamera.isOffline ? 'offline' : 'online'}`);
|
|
401
436
|
// log additioanl snapshot error message if one is present
|
|
402
|
-
if (e.message.includes('Snapshot')) {
|
|
437
|
+
if (e.message && e.message.includes('Snapshot')) {
|
|
403
438
|
logDebug(e.message);
|
|
404
439
|
}
|
|
405
440
|
}
|
|
@@ -533,32 +568,21 @@ export class CameraSource {
|
|
|
533
568
|
this.activeRecordingSessions.set(streamId, closeSession);
|
|
534
569
|
let liveCall;
|
|
535
570
|
let keyFrameTimer;
|
|
571
|
+
let maxRecordingTimer;
|
|
536
572
|
try {
|
|
537
573
|
liveCall = await this.ringCamera.startLiveCall();
|
|
538
574
|
liveCall.onCallEnded.pipe(take(1)).subscribe(() => {
|
|
539
575
|
closeSession();
|
|
540
576
|
});
|
|
577
|
+
const maxRecordingSeconds = getIntegerConfigValue(this.config.hksvMaxRecordingSeconds, 0, 0, 300);
|
|
578
|
+
if (maxRecordingSeconds) {
|
|
579
|
+
maxRecordingTimer = setTimeout(() => {
|
|
580
|
+
logInfo(`HKSV recording stream reached max duration for ${this.ringCamera.name} (streamId=${streamId}, maxSeconds=${maxRecordingSeconds})`);
|
|
581
|
+
closeSession();
|
|
582
|
+
}, maxRecordingSeconds * 1000);
|
|
583
|
+
}
|
|
541
584
|
await liveCall.startTranscoding({
|
|
542
|
-
video:
|
|
543
|
-
'-vcodec',
|
|
544
|
-
'libx264',
|
|
545
|
-
'-preset',
|
|
546
|
-
'veryfast',
|
|
547
|
-
'-tune',
|
|
548
|
-
'zerolatency',
|
|
549
|
-
'-pix_fmt',
|
|
550
|
-
'yuv420p',
|
|
551
|
-
'-profile:v',
|
|
552
|
-
'baseline',
|
|
553
|
-
'-level:v',
|
|
554
|
-
'3.1',
|
|
555
|
-
'-g',
|
|
556
|
-
'30',
|
|
557
|
-
'-keyint_min',
|
|
558
|
-
'30',
|
|
559
|
-
'-sc_threshold',
|
|
560
|
-
'0',
|
|
561
|
-
],
|
|
585
|
+
video: this.getHksvVideoArguments(),
|
|
562
586
|
output: [
|
|
563
587
|
'-movflags',
|
|
564
588
|
'frag_keyframe+empty_moov+default_base_moof',
|
|
@@ -650,6 +674,9 @@ export class CameraSource {
|
|
|
650
674
|
if (keyFrameTimer) {
|
|
651
675
|
clearInterval(keyFrameTimer);
|
|
652
676
|
}
|
|
677
|
+
if (maxRecordingTimer) {
|
|
678
|
+
clearTimeout(maxRecordingTimer);
|
|
679
|
+
}
|
|
653
680
|
if (liveCall) {
|
|
654
681
|
liveCall.stop();
|
|
655
682
|
}
|
package/lib/intercom.js
CHANGED
|
@@ -1,7 +1,125 @@
|
|
|
1
|
+
import { RingCamera } from 'ring-client-api';
|
|
1
2
|
import { hap } from "./hap.js";
|
|
2
3
|
import { BaseDataAccessory } from "./base-data-accessory.js";
|
|
3
4
|
import { logError, logInfo } from 'ring-client-api/util';
|
|
4
|
-
import { map, throttleTime } from 'rxjs/operators';
|
|
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
|
+
}
|
|
5
123
|
export class Intercom extends BaseDataAccessory {
|
|
6
124
|
unlocking = false;
|
|
7
125
|
unlockTimeout;
|
|
@@ -13,7 +131,64 @@ export class Intercom extends BaseDataAccessory {
|
|
|
13
131
|
this.device = device;
|
|
14
132
|
this.accessory = accessory;
|
|
15
133
|
this.config = config;
|
|
16
|
-
const { Characteristic, Service } = hap,
|
|
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 = () => {
|
|
17
192
|
const state = this.getLockState();
|
|
18
193
|
lockService
|
|
19
194
|
.getCharacteristic(Characteristic.LockCurrentState)
|
|
@@ -68,25 +243,42 @@ export class Intercom extends BaseDataAccessory {
|
|
|
68
243
|
}
|
|
69
244
|
},
|
|
70
245
|
});
|
|
71
|
-
|
|
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
|
+
}
|
|
72
261
|
// Doorbell Service
|
|
73
262
|
this.registerObservableCharacteristic({
|
|
74
263
|
characteristicType: ProgrammableSwitchEvent,
|
|
75
264
|
serviceType: Service.Doorbell,
|
|
76
265
|
onValue: onDoorbellPressed,
|
|
77
266
|
});
|
|
78
|
-
// Programmable Switch Service
|
|
79
|
-
this.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
+
}
|
|
90
282
|
// Battery Service
|
|
91
283
|
if (device.batteryLevel !== null) {
|
|
92
284
|
this.registerObservableCharacteristic({
|
|
@@ -107,7 +299,7 @@ export class Intercom extends BaseDataAccessory {
|
|
|
107
299
|
this.registerCharacteristic({
|
|
108
300
|
characteristicType: Characteristic.Model,
|
|
109
301
|
serviceType: Service.AccessoryInformation,
|
|
110
|
-
getValue: () => 'Intercom
|
|
302
|
+
getValue: () => isIntercomVideo ? 'Ring Intercom Video' : 'Ring Intercom',
|
|
111
303
|
});
|
|
112
304
|
this.registerCharacteristic({
|
|
113
305
|
characteristicType: Characteristic.SerialNumber,
|
package/lib/lock.js
CHANGED
|
@@ -55,6 +55,15 @@ export class Lock extends BaseDeviceAccessory {
|
|
|
55
55
|
return this.device.sendCommand(`lock.${command}`);
|
|
56
56
|
}
|
|
57
57
|
getTargetState(data) {
|
|
58
|
-
|
|
58
|
+
if (this.targetState !== undefined) {
|
|
59
|
+
return this.targetState;
|
|
60
|
+
}
|
|
61
|
+
const { Characteristic: { LockCurrentState, LockTargetState }, } = hap;
|
|
62
|
+
const currentState = getCurrentState(data);
|
|
63
|
+
if (currentState === LockCurrentState.JAMMED ||
|
|
64
|
+
currentState === LockCurrentState.UNKNOWN) {
|
|
65
|
+
return LockTargetState.SECURED;
|
|
66
|
+
}
|
|
67
|
+
return currentState;
|
|
59
68
|
}
|
|
60
69
|
}
|
package/lib/ring-platform.js
CHANGED
|
@@ -170,10 +170,17 @@ 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,
|
|
173
|
+
const isCamera = device instanceof RingCamera, isIntercomVideo = device instanceof RingIntercom &&
|
|
174
|
+
device.deviceType === 'intercom_handset_video', cameraIdentitySalt = isCamera && config.externalCameraIdSalt
|
|
174
175
|
? `-${config.externalCameraIdSalt}`
|
|
175
|
-
: '',
|
|
176
|
-
|
|
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
|
|
177
184
|
? Camera
|
|
178
185
|
: device instanceof RingChime
|
|
179
186
|
? Chime
|
|
@@ -184,6 +191,7 @@ export class RingPlatform {
|
|
|
184
191
|
deviceType: device.deviceType,
|
|
185
192
|
device: device,
|
|
186
193
|
isCamera,
|
|
194
|
+
isIntercomVideo,
|
|
187
195
|
id: device.id.toString() +
|
|
188
196
|
cameraIdDifferentiator +
|
|
189
197
|
cameraIdentitySalt +
|
|
@@ -199,6 +207,7 @@ export class RingPlatform {
|
|
|
199
207
|
deviceType: securityPanel.deviceType,
|
|
200
208
|
device: securityPanel,
|
|
201
209
|
isCamera: false,
|
|
210
|
+
isIntercomVideo: false,
|
|
202
211
|
id: securityPanel.id.toString() + 'panic' + accessoryIdSuffix,
|
|
203
212
|
name: 'Panic Buttons' + accessoryNameSuffix,
|
|
204
213
|
AccessoryClass: PanicButtons,
|
|
@@ -210,13 +219,14 @@ export class RingPlatform {
|
|
|
210
219
|
deviceType: 'location.mode',
|
|
211
220
|
device: location,
|
|
212
221
|
isCamera: false,
|
|
222
|
+
isIntercomVideo: false,
|
|
213
223
|
id: location.id + 'mode' + accessoryIdSuffix,
|
|
214
224
|
name: location.name + ' Mode' + accessoryNameSuffix,
|
|
215
225
|
AccessoryClass: LocationModeSwitch,
|
|
216
226
|
});
|
|
217
227
|
}
|
|
218
228
|
logInfo(`Configuring ${cameras.length} cameras and ${hapDevices.length} devices for location "${location.name}" - locationId: ${location.id}`);
|
|
219
|
-
hapDevices.forEach(({ deviceType, device, isCamera, id, name, AccessoryClass }) => {
|
|
229
|
+
hapDevices.forEach(({ deviceType, device, isCamera, isIntercomVideo, id, name, AccessoryClass, }) => {
|
|
220
230
|
const uuid = hap.uuid.generate(debugPrefix + id), displayName = debugPrefix + name;
|
|
221
231
|
if (!AccessoryClass ||
|
|
222
232
|
(config.hideLightGroups &&
|
|
@@ -237,13 +247,20 @@ export class RingPlatform {
|
|
|
237
247
|
delete this.homebridgeAccessories[uuid];
|
|
238
248
|
}
|
|
239
249
|
const createHomebridgeAccessory = () => {
|
|
240
|
-
const
|
|
250
|
+
const category = isCamera
|
|
241
251
|
? 17 /* hap.Categories.CAMERA */
|
|
242
|
-
:
|
|
252
|
+
: isIntercomVideo
|
|
253
|
+
? 18 /* hap.Categories.VIDEO_DOORBELL */
|
|
254
|
+
: 11 /* hap.Categories.SECURITY_SYSTEM */;
|
|
255
|
+
const accessory = new api.platformAccessory(displayName, uuid, category);
|
|
243
256
|
if (isCamera) {
|
|
244
257
|
logInfo(`Configured camera ${uuid} ${deviceType} ${displayName}`);
|
|
245
258
|
externalAccessories.push(accessory);
|
|
246
259
|
}
|
|
260
|
+
else if (isIntercomVideo) {
|
|
261
|
+
logInfo(`Configured Ring Intercom Video ${uuid} ${deviceType} ${displayName}`);
|
|
262
|
+
externalAccessories.push(accessory);
|
|
263
|
+
}
|
|
247
264
|
else {
|
|
248
265
|
logInfo(`Adding new accessory ${uuid} ${deviceType} ${displayName}`);
|
|
249
266
|
platformAccessories.push(accessory);
|
package/package.json
CHANGED