homebridge-ring-secure-video 1.0.0-beta.3 → 1.0.0-beta.4
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 +52 -0
- package/README.md +21 -21
- package/config.schema.json +6 -7
- package/lib/camera-source.js +94 -41
- package/lib/camera.js +150 -51
- package/lib/managed-ffmpeg-process.js +9 -4
- package/lib/ring-platform.js +204 -64
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# homebridge-ring-secure-video
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.4 (2026-11-13)
|
|
4
|
+
|
|
5
|
+
More fixes from the full independent review of 1.0.0-beta.2.
|
|
6
|
+
|
|
7
|
+
**Changes in behaviour to check after updating:**
|
|
8
|
+
|
|
9
|
+
- `enableHksv` now means on when it isn't set, as the settings form shows. Configs without it
|
|
10
|
+
(written by hand, or copied from homebridge-ring) now offer HKSV recording: live view allows
|
|
11
|
+
one viewer at a time, and recording starts once turned on for the camera in the Home app. Set
|
|
12
|
+
`"enableHksv": false` to keep the old behaviour.
|
|
13
|
+
- `disableHksvOnBattery` now means on when it isn't set, and looks at whether a camera actually
|
|
14
|
+
runs on battery: battery models that are wired (power mode wired in the Ring app), like many
|
|
15
|
+
doorbells, keep HKSV; battery-only cameras don't get it. Configs without the setting therefore
|
|
16
|
+
lose HKSV on battery-only cameras; set `"disableHksvOnBattery": false` to keep it. Configs
|
|
17
|
+
saved by the old settings form have it set to true, so their wired battery doorbells now get
|
|
18
|
+
HKSV.
|
|
19
|
+
- Values the old settings form saved stay until removed: a saved `"media": { "profile":
|
|
20
|
+
"adaptive" }` still overrides `"hksvPerformanceMode": "rpi"`, and a saved
|
|
21
|
+
`"hksvVideoBitrateKbps": 3000` still overrides the profile's bitrate. Remove them if you didn't
|
|
22
|
+
choose them.
|
|
23
|
+
|
|
24
|
+
**Fixes:**
|
|
25
|
+
|
|
26
|
+
- One device or location that fails to set up no longer stops the others, the alarm included.
|
|
27
|
+
A failed device keeps its HomeKit accessory (rooms and automations stay) but shows "No
|
|
28
|
+
Response"; a location whose devices can't be loaded is logged, and its accessories are kept and
|
|
29
|
+
show "No Response". If
|
|
30
|
+
Ring can't be reached when Homebridge starts (an outage, a server error, a sign-in server
|
|
31
|
+
outage), connecting is retried after 30 s, 1 min, 2 min, ... up to every 10 minutes, with the
|
|
32
|
+
newest sign-in token and without forcing a new Ring registration.
|
|
33
|
+
- The motion fallback no longer asks Ring for each camera's event history every 20 s all day
|
|
34
|
+
(about 4,300 requests a day). Once motion events turn out to arrive by push too, it checks every
|
|
35
|
+
5 minutes as a safety net; a motion event no push reported switches back to frequent checks. A
|
|
36
|
+
history event counts as pushed if a motion push had its id or came within 90 s of it. If push
|
|
37
|
+
stops working, it is noticed at the next 5-minute check; a visit found then is only reported if
|
|
38
|
+
it is at most 3 minutes old, so it can be missed. Remembered events are forgotten an hour after
|
|
39
|
+
they leave Ring's history.
|
|
40
|
+
- ffmpeg errors no longer end the whole bridge (alarm included): further errors on a process and
|
|
41
|
+
errors on its output are logged, and a talk-back ffmpeg that fails to start is logged and the
|
|
42
|
+
live view is reported to HomeKit as not started.
|
|
43
|
+
- A snapshot that can't be refreshed is shown for at most Ring's snapshot lifetime plus 2
|
|
44
|
+
minutes, as in homebridge-ring, rather than indefinitely.
|
|
45
|
+
- The settings form no longer fills in a bitrate or media profile that overrode the media
|
|
46
|
+
profile and the older HKSV performance mode.
|
|
47
|
+
- README and settings text: the Raspberry Pi section shows the settings verified on a Pi 4
|
|
48
|
+
(`lowPower`, `libx264`, with a Ring Video Doorbell 3 Plus); `copy` and `h264_v4l2m2m` are
|
|
49
|
+
described as not yet verified. Upstream links point to dgreif/ring, and the install commands
|
|
50
|
+
no longer use `--unsafe-perm`.
|
|
51
|
+
- Development: GitHub Actions and the publishing npm are pinned to exact versions, the GitHub
|
|
52
|
+
token is kept out of dependency installs, Dependabot is set up, and locked dependencies include
|
|
53
|
+
the available security fixes.
|
|
54
|
+
|
|
3
55
|
## 1.0.0-beta.3 (2026-11-13)
|
|
4
56
|
|
|
5
57
|
Fixes from a full independent review of 1.0.0-beta.2.
|
package/README.md
CHANGED
|
@@ -13,22 +13,22 @@ It is meant to replace `homebridge-ring` on its own: one plugin, one Ring sign-i
|
|
|
13
13
|
|
|
14
14
|
## Origin and Attribution
|
|
15
15
|
|
|
16
|
-
This project is based on
|
|
16
|
+
This project is based on Dusty Greif's original Ring Homebridge ecosystem:
|
|
17
17
|
|
|
18
|
-
- Upstream plugin: [dgreif/
|
|
19
|
-
-
|
|
18
|
+
- Upstream plugin and API library: [dgreif/ring](https://github.com/dgreif/ring)
|
|
19
|
+
(`homebridge-ring` and `ring-client-api`)
|
|
20
20
|
|
|
21
21
|
- HKSV fork: [trinityhades/homebridge-ring-hksv](https://github.com/trinityhades/homebridge-ring-hksv), whose history this repository continues
|
|
22
22
|
|
|
23
|
-
Big thanks to
|
|
23
|
+
Big thanks to Dusty, trinityhades and all upstream contributors. This plugin reuses and extends that foundation.
|
|
24
24
|
|
|
25
25
|
## Important Plugin Options
|
|
26
26
|
|
|
27
27
|
| Option | Purpose |
|
|
28
28
|
| --- | --- |
|
|
29
|
-
| `enableHksv` | Enables experimental HKSV support for eligible cameras |
|
|
30
|
-
| `enableCameraMotionHistory` | Allows camera motion to fall back to recent Ring event history if
|
|
31
|
-
| `disableHksvOnBattery` | Disables HKSV on battery
|
|
29
|
+
| `enableHksv` | Enables experimental HKSV support for eligible cameras; on by default, set `false` to turn it off |
|
|
30
|
+
| `enableCameraMotionHistory` | Allows camera motion to fall back to recent Ring event history if push notifications don't arrive. The history is checked every `cameraStatusPollingSeconds` until motion events turn out to come by push too, then every 5 minutes as a safety net (and frequently again once a push is missed, so motion can go unreported for up to 5 minutes after push stops working); disable to only trust push-triggered motion |
|
|
31
|
+
| `disableHksvOnBattery` | Disables HKSV on cameras running on battery to reduce battery/network usage; wired battery models keep it. On by default |
|
|
32
32
|
| `hksvPrebufferLengthMs` | HKSV prebuffer duration (minimum 4000ms) |
|
|
33
33
|
| `hksvFragmentLengthMs` | HKSV fragment duration target |
|
|
34
34
|
| `hksvMaxRecordingSeconds` | Optional safety cap for a recording session (default 180 s; 0 disables) |
|
|
@@ -61,13 +61,13 @@ normally ends a recording itself when motion stops), or to `0` to disable it.
|
|
|
61
61
|
If Homebridge is installed globally:
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
|
-
npm i -g
|
|
64
|
+
npm i -g homebridge-ring-secure-video
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
To try a beta, install the `beta` tag instead:
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
|
-
npm i -g
|
|
70
|
+
npm i -g homebridge-ring-secure-video@beta
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
If running from source:
|
|
@@ -115,7 +115,8 @@ Changing `homeKitAccessoryTag` updates both the exposed accessory name and the g
|
|
|
115
115
|
|
|
116
116
|
HKSV support is experimental and actively evolving. Behavior may vary by camera model, wired vs battery power, Ring API changes, and FFmpeg environment.
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
Verified so far: complete HomeKit clips from a Ring Video Doorbell 3 Plus on a Raspberry Pi 4
|
|
119
|
+
(Node 22, Homebridge 2) with the settings below. The HKSV fork this continues reported 3 cameras on an M4 Mac mini.
|
|
119
120
|
Please report your experience and setup details to help improve support.
|
|
120
121
|
|
|
121
122
|
Recordings follow the frame rate and audio format HomeKit asks for, and include audio only when
|
|
@@ -133,24 +134,23 @@ For fast-motion pixelation or stuttering in HKSV recordings, try increasing
|
|
|
133
134
|
`cameraVideoCodec: "h264_videotoolbox"` can reduce CPU load by using hardware
|
|
134
135
|
encoding.
|
|
135
136
|
|
|
136
|
-
For Raspberry Pi 4/5 and other small computers, start with:
|
|
137
|
+
For Raspberry Pi 4/5 and other small computers, start with what has been verified on a Pi 4:
|
|
137
138
|
|
|
138
139
|
```json
|
|
139
140
|
{
|
|
140
141
|
"enableHksv": true,
|
|
141
|
-
"
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
"media": {
|
|
143
|
+
"profile": "lowPower",
|
|
144
|
+
"recording": { "codec": "libx264", "bitrateKbps": 800 }
|
|
145
|
+
}
|
|
144
146
|
}
|
|
145
147
|
```
|
|
146
148
|
|
|
147
|
-
The `
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
`
|
|
152
|
-
or fall back to `cameraVideoCodec: "libx264"` with `hksvVideoPreset:
|
|
153
|
-
"ultrafast"`.
|
|
149
|
+
The `lowPower` profile (also chosen by `"hksvPerformanceMode": "rpi"`) records one clip at a
|
|
150
|
+
time with conservative settings. Lighter codecs exist but complete HomeKit clips with them are
|
|
151
|
+
not verified yet: `copy` (the `lowPower` default for `auto`) remuxes Ring's H.264 without
|
|
152
|
+
re-encoding, which only works when Ring's stream meets HomeKit's recording requirements, and
|
|
153
|
+
`h264_v4l2m2m` uses the Pi's hardware encoder when FFmpeg has it.
|
|
154
154
|
|
|
155
155
|
### Minimum specifications for HKSV:
|
|
156
156
|
[TBD - will be added as more users test and report their setups]
|
package/config.schema.json
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"profile": {
|
|
37
37
|
"title": "Media profile",
|
|
38
38
|
"type": "string",
|
|
39
|
-
"
|
|
39
|
+
"description": "Defaults to the HKSV Performance Mode (rpi gives lowPower, quality gives quality), otherwise adaptive.",
|
|
40
40
|
"oneOf": [
|
|
41
41
|
{ "title": "adaptive", "enum": ["adaptive"] },
|
|
42
42
|
{ "title": "lowPower", "enum": ["lowPower"] },
|
|
@@ -155,13 +155,13 @@
|
|
|
155
155
|
"enableCameraMotionHistory": {
|
|
156
156
|
"title": "Enable Camera Motion History Fallback",
|
|
157
157
|
"type": "boolean",
|
|
158
|
-
"description": "If enabled, cameras can synthesize motion from recent Ring event history when
|
|
158
|
+
"description": "If enabled, cameras can synthesize motion from recent Ring event history when push notifications don't arrive. The history is checked frequently until push is seen to work, then every 5 minutes as a safety net. Disable this if you only want motion triggered by Ring notifications.",
|
|
159
159
|
"default": true
|
|
160
160
|
},
|
|
161
161
|
"disableHksvOnBattery": {
|
|
162
162
|
"title": "Disable HKSV On Battery Cameras",
|
|
163
163
|
"type": "boolean",
|
|
164
|
-
"description": "If enabled, HKSV is disabled on battery
|
|
164
|
+
"description": "If enabled, HKSV is disabled on cameras running on battery, to reduce battery and network impact. Battery models that are wired (power mode wired in the Ring app) keep HKSV.",
|
|
165
165
|
"default": true
|
|
166
166
|
},
|
|
167
167
|
"hksvPrebufferLengthMs": {
|
|
@@ -189,7 +189,7 @@
|
|
|
189
189
|
"hksvPerformanceMode": {
|
|
190
190
|
"title": "HKSV Performance Mode",
|
|
191
191
|
"type": "string",
|
|
192
|
-
"description": "High-level HKSV tuning profile. Use rpi for Raspberry Pi and other small computers
|
|
192
|
+
"description": "High-level HKSV tuning profile. Use rpi for Raspberry Pi and other small computers: lower advertised recording tiers, one recording at a time, and remuxing (copy) unless a codec other than auto is set. Verified on a Pi 4 with the codec set to libx264.",
|
|
193
193
|
"default": "balanced",
|
|
194
194
|
"oneOf": [
|
|
195
195
|
{
|
|
@@ -225,8 +225,7 @@
|
|
|
225
225
|
"type": "integer",
|
|
226
226
|
"minimum": 256,
|
|
227
227
|
"maximum": 12000,
|
|
228
|
-
"
|
|
229
|
-
"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."
|
|
228
|
+
"description": "Target video bitrate for HKSV recording transcodes. Defaults to the media profile's (1000 lowPower, 3000 adaptive, 5000 quality). Increase this first if fast motion becomes blocky or pixelated. 4000-6000 is a good starting range for wired cameras."
|
|
230
229
|
},
|
|
231
230
|
"hksvVideoMaxBitrateKbps": {
|
|
232
231
|
"title": "HKSV Max Video Bitrate (kbps)",
|
|
@@ -308,7 +307,7 @@
|
|
|
308
307
|
"cameraVideoCodec": {
|
|
309
308
|
"title": "Camera Video Codec",
|
|
310
309
|
"type": "string",
|
|
311
|
-
"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.",
|
|
310
|
+
"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. Complete clips are verified with libx264; copy and h264_v4l2m2m are not verified yet.",
|
|
312
311
|
"oneOf": [
|
|
313
312
|
{
|
|
314
313
|
"title": "auto",
|
package/lib/camera-source.js
CHANGED
|
@@ -225,46 +225,79 @@ class StreamingSessionWrapper {
|
|
|
225
225
|
// deSerialize will sometimes fail, but the errors can be ignored
|
|
226
226
|
}
|
|
227
227
|
return null;
|
|
228
|
-
}), releaseReturnAudioFfmpeg = hksvRecordingQueue.trackFfmpegProcess(), returnAudioTranscoder = new ReturnAudioTranscoder({
|
|
229
|
-
prepareStreamRequest: getReturnAudioPrepareStreamRequest(this.prepareStreamRequest),
|
|
230
|
-
startStreamRequest: request,
|
|
231
|
-
incomingAudioOptions: {
|
|
232
|
-
ssrc: this.audioSsrc,
|
|
233
|
-
rtcpPort: 0, // we don't care about rtcp for incoming audio
|
|
234
|
-
},
|
|
235
|
-
outputArgs: [
|
|
236
|
-
'-acodec',
|
|
237
|
-
'libopus',
|
|
238
|
-
'-application',
|
|
239
|
-
'lowdelay',
|
|
240
|
-
'-frame_duration',
|
|
241
|
-
'60',
|
|
242
|
-
'-flags',
|
|
243
|
-
'+global_header',
|
|
244
|
-
'-ar',
|
|
245
|
-
'48k',
|
|
246
|
-
'-b:a',
|
|
247
|
-
'48k',
|
|
248
|
-
'-bufsize',
|
|
249
|
-
'192k',
|
|
250
|
-
'-ac',
|
|
251
|
-
'2',
|
|
252
|
-
'-f',
|
|
253
|
-
'rtp',
|
|
254
|
-
`rtp://127.0.0.1:${await returnAudioTranscodedSplitter.portPromise}`,
|
|
255
|
-
],
|
|
256
|
-
ffmpegPath: getFfmpegPath(),
|
|
257
|
-
exitCallback: () => {
|
|
258
|
-
releaseReturnAudioFfmpeg();
|
|
259
|
-
this.stop();
|
|
260
|
-
},
|
|
261
|
-
logger: {
|
|
262
|
-
info: logDebug,
|
|
263
|
-
error: logError,
|
|
264
|
-
},
|
|
265
|
-
logLabel: `Return Audio (${this.ringCamera.name})`,
|
|
266
|
-
returnAudioSplitter: this.audioSplitter,
|
|
267
228
|
});
|
|
229
|
+
// Port first, so a failure here leaves nothing counted or open.
|
|
230
|
+
let returnAudioPort;
|
|
231
|
+
try {
|
|
232
|
+
returnAudioPort = await returnAudioTranscodedSplitter.portPromise;
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
returnAudioTranscodedSplitter.close();
|
|
236
|
+
throw e;
|
|
237
|
+
}
|
|
238
|
+
if (this.stopped) {
|
|
239
|
+
returnAudioTranscodedSplitter.close();
|
|
240
|
+
throw getLiveStreamAbortError();
|
|
241
|
+
}
|
|
242
|
+
const releaseReturnAudioFfmpeg = hksvRecordingQueue.trackFfmpegProcess();
|
|
243
|
+
let returnAudioTranscoder;
|
|
244
|
+
try {
|
|
245
|
+
returnAudioTranscoder = new ReturnAudioTranscoder({
|
|
246
|
+
prepareStreamRequest: getReturnAudioPrepareStreamRequest(this.prepareStreamRequest),
|
|
247
|
+
startStreamRequest: request,
|
|
248
|
+
incomingAudioOptions: {
|
|
249
|
+
ssrc: this.audioSsrc,
|
|
250
|
+
rtcpPort: 0, // we don't care about rtcp for incoming audio
|
|
251
|
+
},
|
|
252
|
+
outputArgs: [
|
|
253
|
+
'-acodec',
|
|
254
|
+
'libopus',
|
|
255
|
+
'-application',
|
|
256
|
+
'lowdelay',
|
|
257
|
+
'-frame_duration',
|
|
258
|
+
'60',
|
|
259
|
+
'-flags',
|
|
260
|
+
'+global_header',
|
|
261
|
+
'-ar',
|
|
262
|
+
'48k',
|
|
263
|
+
'-b:a',
|
|
264
|
+
'48k',
|
|
265
|
+
'-bufsize',
|
|
266
|
+
'192k',
|
|
267
|
+
'-ac',
|
|
268
|
+
'2',
|
|
269
|
+
'-f',
|
|
270
|
+
'rtp',
|
|
271
|
+
`rtp://127.0.0.1:${returnAudioPort}`,
|
|
272
|
+
],
|
|
273
|
+
ffmpegPath: getFfmpegPath(),
|
|
274
|
+
exitCallback: () => {
|
|
275
|
+
releaseReturnAudioFfmpeg();
|
|
276
|
+
this.stop();
|
|
277
|
+
},
|
|
278
|
+
logger: {
|
|
279
|
+
info: logDebug,
|
|
280
|
+
error: logError,
|
|
281
|
+
},
|
|
282
|
+
logLabel: `Return Audio (${this.ringCamera.name})`,
|
|
283
|
+
returnAudioSplitter: this.audioSplitter,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
releaseReturnAudioFfmpeg();
|
|
288
|
+
returnAudioTranscodedSplitter.close();
|
|
289
|
+
throw e;
|
|
290
|
+
}
|
|
291
|
+
// @homebridge/camera-utils' ffmpeg has no 'error' listener: a failed start (no ffmpeg, too
|
|
292
|
+
// many processes) would be an unhandled 'error' event and end the whole bridge, alarm
|
|
293
|
+
// included. Its spawn errors arrive on the next tick, so listening now is in time.
|
|
294
|
+
const returnAudioFfmpeg = returnAudioTranscoder.ffmpegProcess?.ff;
|
|
295
|
+
for (const emitter of [returnAudioFfmpeg, returnAudioFfmpeg?.stdout, returnAudioFfmpeg?.stderr]) {
|
|
296
|
+
emitter?.on('error', (error) => {
|
|
297
|
+
logError(`Return Audio (${this.ringCamera.name}): ${error.message}`);
|
|
298
|
+
this.stop();
|
|
299
|
+
});
|
|
300
|
+
}
|
|
268
301
|
this.streamingSession.onCallEnded.pipe(take(1)).subscribe(() => {
|
|
269
302
|
returnAudioTranscoder.stop();
|
|
270
303
|
returnAudioTranscodedSplitter.close();
|
|
@@ -277,6 +310,9 @@ class StreamingSessionWrapper {
|
|
|
277
310
|
});
|
|
278
311
|
this.listenForAudioPackets(request);
|
|
279
312
|
await returnAudioTranscoder.start();
|
|
313
|
+
// A talk-back ffmpeg that failed to start stopped the stream meanwhile: report it to HomeKit.
|
|
314
|
+
if (this.stopped)
|
|
315
|
+
throw getLiveStreamAbortError();
|
|
280
316
|
}
|
|
281
317
|
stop() {
|
|
282
318
|
if (this.stopped) {
|
|
@@ -329,10 +365,20 @@ export function getHksvRecordingFormatArguments(configuration, videoArguments, c
|
|
|
329
365
|
// Raspberry Pi's V4L2 encoder) otherwise leave an empty avcC, and HomeKit can't decode the
|
|
330
366
|
// recording. The first media fragment arrives no later than before.
|
|
331
367
|
export const HKSV_RECORDING_MOVFLAGS = 'frag_keyframe+empty_moov+default_base_moof+delay_moov';
|
|
368
|
+
/**
|
|
369
|
+
* Whether a camera gets HomeKit Secure Video. Unset settings mean what the settings form shows:
|
|
370
|
+
* HKSV on, except for cameras running on battery (each recording opens a Ring live call, which
|
|
371
|
+
* drains it). A battery model that is wired, like many doorbells, counts as wired.
|
|
372
|
+
*/
|
|
373
|
+
export function isHksvEnabled(config, operatingOnBattery) {
|
|
374
|
+
return (config.enableHksv !== false &&
|
|
375
|
+
!(config.disableHksvOnBattery !== false && operatingOnBattery));
|
|
376
|
+
}
|
|
332
377
|
export class CameraSource {
|
|
333
378
|
controller;
|
|
334
379
|
sessions = {};
|
|
335
380
|
cachedSnapshot;
|
|
381
|
+
cachedSnapshotAt = 0;
|
|
336
382
|
ringCamera;
|
|
337
383
|
config;
|
|
338
384
|
mediaIngress;
|
|
@@ -345,7 +391,7 @@ export class CameraSource {
|
|
|
345
391
|
this.ringCamera = ringCamera;
|
|
346
392
|
this.config = config;
|
|
347
393
|
this.mediaIngress = new RingMediaIngress(ringCamera);
|
|
348
|
-
const enableHksv =
|
|
394
|
+
const enableHksv = isHksvEnabled(config, ringCamera.operatingOnBattery);
|
|
349
395
|
const controllerOptions = {
|
|
350
396
|
cameraStreamCount: enableHksv ? 1 : 10,
|
|
351
397
|
delegate: this,
|
|
@@ -512,6 +558,7 @@ export class CameraSource {
|
|
|
512
558
|
try {
|
|
513
559
|
const previousSnapshot = this.cachedSnapshot, newSnapshot = await this.ringCamera.getSnapshot({ uuid: imageUuid });
|
|
514
560
|
this.cachedSnapshot = newSnapshot;
|
|
561
|
+
this.cachedSnapshotAt = Date.now();
|
|
515
562
|
logDebug(`Snapshot cached for ${this.ringCamera.name}${imageUuid ? ' by uuid' : ''} (${getDurationSeconds(start)}s)`);
|
|
516
563
|
}
|
|
517
564
|
catch (e) {
|
|
@@ -535,7 +582,13 @@ export class CameraSource {
|
|
|
535
582
|
if (!this.ringCamera.hasSnapshotWithinLifetime) {
|
|
536
583
|
this.loadSnapshot().catch(logError);
|
|
537
584
|
}
|
|
538
|
-
//
|
|
585
|
+
// May or may not have a snapshot cached. A failed load keeps the last one for a while, but
|
|
586
|
+
// not past Ring's snapshot lifetime plus 2 minutes (as upstream): an hours-old image would
|
|
587
|
+
// look current in the Home app.
|
|
588
|
+
if (this.cachedSnapshot &&
|
|
589
|
+
Date.now() - this.cachedSnapshotAt > this.ringCamera.snapshotLifeTime + 2 * 60_000) {
|
|
590
|
+
return undefined;
|
|
591
|
+
}
|
|
539
592
|
return this.cachedSnapshot;
|
|
540
593
|
}
|
|
541
594
|
async handleSnapshotRequest(request, callback) {
|
package/lib/camera.js
CHANGED
|
@@ -1,12 +1,155 @@
|
|
|
1
1
|
import { hap } from "./hap.js";
|
|
2
2
|
import { PushNotificationAction } from 'ring-client-api';
|
|
3
3
|
import { BaseDataAccessory } from "./base-data-accessory.js";
|
|
4
|
-
import { catchError, exhaustMap, filter, map, mergeMap, share, switchMap, throttleTime, } from 'rxjs/operators';
|
|
4
|
+
import { catchError, exhaustMap, filter, finalize, map, mergeMap, share, switchMap, throttleTime, } from 'rxjs/operators';
|
|
5
5
|
import { CameraSource } from "./camera-source.js";
|
|
6
6
|
import { TargetValueTimer } from "./target-value-timer.js";
|
|
7
7
|
import { delay, logError, logInfo } from 'ring-client-api/util';
|
|
8
|
-
import { defer, EMPTY, firstValueFrom,
|
|
8
|
+
import { defer, EMPTY, firstValueFrom, merge, of, timer } from 'rxjs';
|
|
9
9
|
export const MOTION_WATCHDOG_MS = 30_000;
|
|
10
|
+
/** How often Ring's event history is checked once its push notifications are known to arrive. */
|
|
11
|
+
export const HISTORY_CHECK_WHILE_PUSH_WORKS_MS = 5 * 60_000;
|
|
12
|
+
/** A push motion this close to a history event's time is taken as that event's notification. */
|
|
13
|
+
export const PUSH_MATCH_WINDOW_MS = 90_000;
|
|
14
|
+
/** After a gap in checking, a history event older than this no longer gives motion. */
|
|
15
|
+
export const HISTORY_MOTION_MAX_AGE_MS = 180_000;
|
|
16
|
+
/** A history event first seen less than this ago may still get its push; judged at a later check. */
|
|
17
|
+
const PUSH_GRACE_MS = 30_000;
|
|
18
|
+
/** Checks in a row that found only pushed events (no misses in between) before push is trusted. */
|
|
19
|
+
const MATCHES_TO_TRUST_PUSH = 2;
|
|
20
|
+
/**
|
|
21
|
+
* Ids no longer in the history are forgotten after this. Ids still in it are kept, however old:
|
|
22
|
+
* on a quiet camera the 10 newest events can be days old, and must not count as new again.
|
|
23
|
+
*/
|
|
24
|
+
const ID_MEMORY_MS = 60 * 60_000;
|
|
25
|
+
/**
|
|
26
|
+
* Motion from Ring's event history, for when its push notifications don't arrive.
|
|
27
|
+
*
|
|
28
|
+
* Checking every pollSeconds (20 s by default) costs some 4,300 Ring requests a day per camera,
|
|
29
|
+
* on the same session as everything else. So the history is checked that often only until motion
|
|
30
|
+
* events in it (two in a row) turn out to have come by push too; from then on push is trusted
|
|
31
|
+
* and the history is checked every 5 minutes as a safety net. A motion event no push reported
|
|
32
|
+
* switches back to frequent checks. While checking frequently every new event gives motion; after
|
|
33
|
+
* a gap (5-minute checks, a failure) only events from the last 3 minutes do, as one found later
|
|
34
|
+
* would only record an empty doorway.
|
|
35
|
+
*/
|
|
36
|
+
export function getHistoryMotionEvents(device, pollSeconds) {
|
|
37
|
+
return defer(() => {
|
|
38
|
+
const startedAt = Date.now() - 5000, historySeen = new Map(),
|
|
39
|
+
// Events that already gave motion from here (a young one is judged again later).
|
|
40
|
+
shownAsMotion = new Map(),
|
|
41
|
+
// When an event not judged yet was first seen in the history (by the Pi's clock).
|
|
42
|
+
firstSeen = new Map(), pushIds = new Map(), pushMotionTimes = [], baseRetryMs = Math.max(pollSeconds * 1000, 30_000), forget = (ids, now) => {
|
|
43
|
+
for (const [id, at] of ids) {
|
|
44
|
+
if (now - at > ID_MEMORY_MS)
|
|
45
|
+
ids.delete(id);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
let pushWorks = false, matchedInARow = 0, lastCheckAt = 0, lastSuccessAt = 0, failureCount = 0, nextCheckAt = 0;
|
|
49
|
+
const notifications = device.onNewNotification.subscribe((notification) => {
|
|
50
|
+
if (notification.android_config?.category !== PushNotificationAction.Motion)
|
|
51
|
+
return;
|
|
52
|
+
const id = notification.data?.event?.ding?.id, now = Date.now();
|
|
53
|
+
if (id !== undefined)
|
|
54
|
+
pushIds.set(String(id), now);
|
|
55
|
+
pushMotionTimes.push(now);
|
|
56
|
+
if (pushMotionTimes.length > 50)
|
|
57
|
+
pushMotionTimes.shift();
|
|
58
|
+
});
|
|
59
|
+
return timer(pollSeconds * 1000, pollSeconds * 1000).pipe(exhaustMap(() => {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
if (now < nextCheckAt)
|
|
62
|
+
return EMPTY;
|
|
63
|
+
if (pushWorks && now - lastCheckAt < HISTORY_CHECK_WHILE_PUSH_WORKS_MS) {
|
|
64
|
+
return EMPTY;
|
|
65
|
+
}
|
|
66
|
+
lastCheckAt = now;
|
|
67
|
+
// Checked recently and frequently: anything new is recent, whatever the clocks say
|
|
68
|
+
// (a Pi clock ahead of Ring's would otherwise make every event look too old).
|
|
69
|
+
const checkedJustBefore = !pushWorks && now - lastSuccessAt <= pollSeconds * 1000 + 30_000;
|
|
70
|
+
return defer(() => device.getEvents({ limit: 10, kind: 'motion' })).pipe(mergeMap(({ events }) => {
|
|
71
|
+
const checkedAt = Date.now();
|
|
72
|
+
failureCount = 0;
|
|
73
|
+
nextCheckAt = 0;
|
|
74
|
+
lastSuccessAt = checkedAt;
|
|
75
|
+
// Still in the history: remembered as long as it stays there.
|
|
76
|
+
for (const event of events) {
|
|
77
|
+
const id = event.ding_id_str || String(event.ding_id);
|
|
78
|
+
for (const ids of [historySeen, shownAsMotion, firstSeen, pushIds]) {
|
|
79
|
+
if (ids.has(id) && ids !== firstSeen)
|
|
80
|
+
ids.set(id, checkedAt);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
forget(historySeen, checkedAt);
|
|
84
|
+
forget(shownAsMotion, checkedAt);
|
|
85
|
+
forget(firstSeen, checkedAt);
|
|
86
|
+
forget(pushIds, checkedAt);
|
|
87
|
+
const isPushed = (event) => {
|
|
88
|
+
const id = event.ding_id_str || String(event.ding_id), at = Date.parse(event.created_at);
|
|
89
|
+
return (pushIds.has(id) ||
|
|
90
|
+
pushMotionTimes.some((t) => Math.abs(t - at) <= PUSH_MATCH_WINDOW_MS));
|
|
91
|
+
};
|
|
92
|
+
const newEvents = events.filter((event) => {
|
|
93
|
+
const id = event.ding_id_str || String(event.ding_id);
|
|
94
|
+
return (event.kind === 'motion' &&
|
|
95
|
+
Date.parse(event.created_at) >= startedAt &&
|
|
96
|
+
!historySeen.has(id));
|
|
97
|
+
});
|
|
98
|
+
// Too young to know whether its push is still coming: judged at a later check, but
|
|
99
|
+
// it can still give motion now (once). Young by when it was first seen here, so a
|
|
100
|
+
// Pi clock behind Ring's doesn't keep it young.
|
|
101
|
+
const young = (event) => {
|
|
102
|
+
const id = event.ding_id_str || String(event.ding_id);
|
|
103
|
+
if (!firstSeen.has(id))
|
|
104
|
+
firstSeen.set(id, checkedAt);
|
|
105
|
+
return !isPushed(event) && checkedAt - (firstSeen.get(id) ?? checkedAt) < PUSH_GRACE_MS;
|
|
106
|
+
};
|
|
107
|
+
const judged = newEvents.filter((event) => !young(event)), missed = judged.filter((event) => !isPushed(event));
|
|
108
|
+
judged.forEach((event) => {
|
|
109
|
+
const id = event.ding_id_str || String(event.ding_id);
|
|
110
|
+
historySeen.set(id, checkedAt);
|
|
111
|
+
firstSeen.delete(id);
|
|
112
|
+
});
|
|
113
|
+
if (pushWorks && judged.length < newEvents.length) {
|
|
114
|
+
// An event to judge soon: check again shortly rather than in 5 minutes.
|
|
115
|
+
lastCheckAt = checkedAt - HISTORY_CHECK_WHILE_PUSH_WORKS_MS + PUSH_GRACE_MS + 10_000;
|
|
116
|
+
}
|
|
117
|
+
if (missed.length) {
|
|
118
|
+
matchedInARow = 0;
|
|
119
|
+
if (pushWorks) {
|
|
120
|
+
pushWorks = false;
|
|
121
|
+
logInfo(`${device.name}: Ring event history shows motion no push notification reported; checking it every ${pollSeconds} s again`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else if (judged.length) {
|
|
125
|
+
// One per check: a single push can match several events.
|
|
126
|
+
matchedInARow += 1;
|
|
127
|
+
if (!pushWorks && matchedInARow >= MATCHES_TO_TRUST_PUSH) {
|
|
128
|
+
pushWorks = true;
|
|
129
|
+
logInfo(`${device.name}: Ring push notifications are arriving; checking Ring event history every ${HISTORY_CHECK_WHILE_PUSH_WORKS_MS / 60_000} minutes instead of every ${pollSeconds} s`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const unpushedNow = newEvents.filter((event) => !isPushed(event)), givesMotion = unpushedNow.some((event) => !shownAsMotion.has(event.ding_id_str || String(event.ding_id)) &&
|
|
133
|
+
(checkedJustBefore ||
|
|
134
|
+
checkedAt - Date.parse(event.created_at) <= HISTORY_MOTION_MAX_AGE_MS));
|
|
135
|
+
unpushedNow.forEach((event) => shownAsMotion.set(event.ding_id_str || String(event.ding_id), checkedAt));
|
|
136
|
+
if (!givesMotion)
|
|
137
|
+
return EMPTY;
|
|
138
|
+
logInfo(`${device.name} Detected Motion from Ring event history fallback`);
|
|
139
|
+
return of(true);
|
|
140
|
+
}), catchError(() => {
|
|
141
|
+
failureCount++;
|
|
142
|
+
const retryDelayMs = Math.min(5 * 60 * 1000, baseRetryMs * 2 ** Math.min(failureCount - 1, 4));
|
|
143
|
+
nextCheckAt = Date.now() + retryDelayMs;
|
|
144
|
+
const nextInMs = pushWorks
|
|
145
|
+
? Math.max(retryDelayMs, lastCheckAt + HISTORY_CHECK_WHILE_PUSH_WORKS_MS - Date.now())
|
|
146
|
+
: retryDelayMs;
|
|
147
|
+
logError(`${device.name} failed to poll Ring event history for motion fallback; retrying in ${Math.ceil(nextInMs / 1000)} seconds`);
|
|
148
|
+
return EMPTY;
|
|
149
|
+
}));
|
|
150
|
+
}), finalize(() => notifications.unsubscribe()));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
10
153
|
/**
|
|
11
154
|
* Motion from Ring's push notifications, as HomeKit needs it: true for every new motion alert.
|
|
12
155
|
*
|
|
@@ -217,56 +360,12 @@ export class Camera extends BaseDataAccessory {
|
|
|
217
360
|
}
|
|
218
361
|
}
|
|
219
362
|
getMotionDetectedObservable() {
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const baseHistoryRetryMs = Math.max(pollSeconds * 1000, 30_000), polledNewMotionEvents = timer(pollSeconds * 1000, pollSeconds * 1000).pipe(exhaustMap(() => {
|
|
224
|
-
if (Date.now() < nextHistoryPollAt) {
|
|
225
|
-
return EMPTY;
|
|
226
|
-
}
|
|
227
|
-
return from(this.device.getEvents({
|
|
228
|
-
limit: 10,
|
|
229
|
-
kind: 'motion',
|
|
230
|
-
})).pipe(mergeMap(({ events }) => {
|
|
231
|
-
historyFailureCount = 0;
|
|
232
|
-
nextHistoryPollAt = 0;
|
|
233
|
-
const newMotionEvents = events
|
|
234
|
-
.filter((event) => {
|
|
235
|
-
const eventId = event.ding_id_str || String(event.ding_id);
|
|
236
|
-
return (event.kind === 'motion' &&
|
|
237
|
-
Date.parse(event.created_at) >= startedAt &&
|
|
238
|
-
!seenEventIds.has(eventId));
|
|
239
|
-
})
|
|
240
|
-
.sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
|
|
241
|
-
newMotionEvents.forEach((event) => {
|
|
242
|
-
seenEventIds.add(event.ding_id_str || String(event.ding_id));
|
|
243
|
-
});
|
|
244
|
-
if (!newMotionEvents.length)
|
|
245
|
-
return EMPTY;
|
|
246
|
-
logInfo(`${this.device.name} Detected Motion from Ring event history fallback`);
|
|
247
|
-
return of(true);
|
|
248
|
-
}), catchError(() => {
|
|
249
|
-
historyFailureCount++;
|
|
250
|
-
const retryDelayMs = Math.min(5 * 60 * 1000, baseHistoryRetryMs * 2 ** Math.min(historyFailureCount - 1, 4));
|
|
251
|
-
nextHistoryPollAt = Date.now() + retryDelayMs;
|
|
252
|
-
logError(`${this.device.name} failed to poll Ring event history for motion fallback; retrying in ${Math.ceil(retryDelayMs / 1000)} seconds`);
|
|
253
|
-
return EMPTY;
|
|
254
|
-
}));
|
|
255
|
-
})),
|
|
256
|
-
// No separate "motion off" for these: the watchdog below turns motion off 30 s after the
|
|
257
|
-
// last event, and a 65 s one from here could cut a later push-started visit short.
|
|
258
|
-
polledMotionEvents = polledNewMotionEvents;
|
|
259
|
-
if (enableMotionHistoryFallback) {
|
|
260
|
-
this.device.onNewNotification.subscribe((notification) => {
|
|
261
|
-
const dingId = notification.data?.event?.ding?.id;
|
|
262
|
-
if (dingId) {
|
|
263
|
-
seenEventIds.add(String(dingId));
|
|
264
|
-
}
|
|
265
|
-
});
|
|
363
|
+
const pushMotionEvents = getPushMotionEvents(this.device);
|
|
364
|
+
if (this.config.enableCameraMotionHistory === false) {
|
|
365
|
+
return addMotionWatchdog(pushMotionEvents);
|
|
266
366
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
: pushMotionEvents);
|
|
367
|
+
const pollSeconds = Math.max(this.config.cameraStatusPollingSeconds ?? 20, 10);
|
|
368
|
+
return addMotionWatchdog(merge(pushMotionEvents, getHistoryMotionEvents(this.device, pollSeconds)));
|
|
270
369
|
}
|
|
271
370
|
async loadSnapshotForEvent(eventDescription, characteristicValue) {
|
|
272
371
|
let imageUuid = this.device.latestNotificationSnapshotUuid;
|
|
@@ -37,6 +37,10 @@ export class ManagedFfmpegProcess {
|
|
|
37
37
|
if (options.stdoutCallback) {
|
|
38
38
|
this.child.stdout.on('data', options.stdoutCallback);
|
|
39
39
|
}
|
|
40
|
+
// An unhandled 'error' event would crash the whole bridge (alarm included).
|
|
41
|
+
for (const stream of [this.child.stdout, this.child.stderr]) {
|
|
42
|
+
stream.on('error', (error) => options.logger.error(prefix + error.message));
|
|
43
|
+
}
|
|
40
44
|
this.child.stderr.on('data', (data) => {
|
|
41
45
|
options.logger.info(prefix + data);
|
|
42
46
|
});
|
|
@@ -45,11 +49,12 @@ export class ManagedFfmpegProcess {
|
|
|
45
49
|
options.logger.error(prefix + error.message);
|
|
46
50
|
}
|
|
47
51
|
});
|
|
48
|
-
this.child.
|
|
49
|
-
if (this.didExit)
|
|
50
|
-
return;
|
|
52
|
+
this.child.on('error', (error) => {
|
|
51
53
|
options.logger.error(prefix + error.message);
|
|
52
|
-
|
|
54
|
+
// Without a pid it never started, and no 'exit' will follow. A running process (say a
|
|
55
|
+
// failed kill) still exits on its own and must stay tracked until then.
|
|
56
|
+
if (this.child.pid === undefined)
|
|
57
|
+
this.finish(null, null, false);
|
|
53
58
|
});
|
|
54
59
|
this.child.once('exit', (code, signal) => {
|
|
55
60
|
this.finish(code, signal, true);
|
package/lib/ring-platform.js
CHANGED
|
@@ -182,6 +182,47 @@ export function createRefreshTokenSaver(api, tokenInConfig, clearRefreshFlags) {
|
|
|
182
182
|
}
|
|
183
183
|
};
|
|
184
184
|
}
|
|
185
|
+
/** An error as a short text; ring-client-api's HTTP errors have no message, only a status. */
|
|
186
|
+
export function describeError(error) {
|
|
187
|
+
if (error instanceof Error && error.message)
|
|
188
|
+
return error.message;
|
|
189
|
+
const status = error?.response?.status;
|
|
190
|
+
if (status)
|
|
191
|
+
return `Ring returned HTTP ${status}`;
|
|
192
|
+
return error instanceof Error ? error.name : String(error);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* A Ring error that retrying can't fix. Only an outdated Node.js: ring-client-api reports a Ring
|
|
196
|
+
* sign-in server outage (5xx, 429) as "Refresh token is not valid", the same as a revoked token,
|
|
197
|
+
* so that is retried (every 10 minutes at most, a single request each time).
|
|
198
|
+
*/
|
|
199
|
+
export function isPermanentRingError(error) {
|
|
200
|
+
return describeError(error).includes('too old to support');
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Keeps an accessory that couldn't be set up in HomeKit (its room, name and automations stay)
|
|
204
|
+
* but makes it show "No Response", rather than old values and controls that silently do nothing
|
|
205
|
+
* (an alarm that seems to arm).
|
|
206
|
+
*/
|
|
207
|
+
function markNotResponding(accessory) {
|
|
208
|
+
const failure = () => {
|
|
209
|
+
throw new hap.HapStatusError(-70402 /* hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
|
|
210
|
+
};
|
|
211
|
+
for (const service of accessory.services) {
|
|
212
|
+
if (service.UUID === hap.Service.AccessoryInformation.UUID)
|
|
213
|
+
continue;
|
|
214
|
+
for (const characteristic of service.characteristics) {
|
|
215
|
+
characteristic.onGet(failure);
|
|
216
|
+
if (characteristic.props.perms.includes("pw" /* hap.Perms.PAIRED_WRITE */)) {
|
|
217
|
+
characteristic.onSet(failure);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** 30 s, 1 min, 2 min, ... up to 10 min between attempts to reach Ring at startup. */
|
|
223
|
+
export function getRetryDelayMs(attempt) {
|
|
224
|
+
return Math.min(30_000 * 2 ** attempt, 600_000);
|
|
225
|
+
}
|
|
185
226
|
export class RingPlatform {
|
|
186
227
|
homebridgeAccessories = {};
|
|
187
228
|
log;
|
|
@@ -224,19 +265,52 @@ export class RingPlatform {
|
|
|
224
265
|
queueTimeoutMs: 30_000,
|
|
225
266
|
});
|
|
226
267
|
logInfo(`Media profile ${media.profile}: recordingConcurrency=${media.recording.maxConcurrentRecordings}, codec=${media.recording.codec}`);
|
|
268
|
+
this.api.on('shutdown', () => {
|
|
269
|
+
this.shuttingDown = true;
|
|
270
|
+
clearTimeout(this.retryTimer);
|
|
271
|
+
});
|
|
227
272
|
this.api.on('didFinishLaunching', () => {
|
|
228
273
|
this.log.debug('didFinishLaunching');
|
|
229
274
|
if (config.refreshToken) {
|
|
230
|
-
this.
|
|
231
|
-
this.log.error('Error connecting to API');
|
|
232
|
-
this.log.error(e);
|
|
233
|
-
});
|
|
275
|
+
void this.connectWithRetry();
|
|
234
276
|
}
|
|
235
277
|
else {
|
|
236
278
|
this.log.warn('Plugin is not configured. See the README of homebridge-ring-secure-video for setup instructions.');
|
|
237
279
|
}
|
|
238
280
|
});
|
|
239
281
|
}
|
|
282
|
+
retryTimer;
|
|
283
|
+
shuttingDown = false;
|
|
284
|
+
devicesSetUp = false;
|
|
285
|
+
/**
|
|
286
|
+
* Connects to Ring, retrying later if Ring can't be reached when Homebridge starts (an outage,
|
|
287
|
+
* a server error), with a fresh connection each time: until then the alarm and every other
|
|
288
|
+
* accessory would stay unconnected. Not retried: an outdated Node.js, a failure once devices
|
|
289
|
+
* were being set up (which would set them up twice), and anything after shutdown.
|
|
290
|
+
*/
|
|
291
|
+
async connectWithRetry(attempt = 0) {
|
|
292
|
+
try {
|
|
293
|
+
await this.connectToApi();
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
const message = describeError(e);
|
|
297
|
+
if (this.shuttingDown || this.devicesSetUp || isPermanentRingError(e)) {
|
|
298
|
+
this.log.error(`Error connecting to Ring: ${message}`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const delay = getRetryDelayMs(attempt);
|
|
302
|
+
this.log.error(`Could not connect to Ring (${message}). Trying again in ${Math.round(delay / 1000)} s.${message.includes('Refresh token is not valid')
|
|
303
|
+
? ' If this keeps happening, sign in again in the plugin settings.'
|
|
304
|
+
: ''}`);
|
|
305
|
+
this.retryTimer = setTimeout(() => {
|
|
306
|
+
void this.connectWithRetry(attempt + 1);
|
|
307
|
+
}, delay);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// A separate method so tests can stand in for Ring.
|
|
311
|
+
createRingApi(options) {
|
|
312
|
+
return new RingApi(options);
|
|
313
|
+
}
|
|
240
314
|
configureAccessory(accessory) {
|
|
241
315
|
logInfo(`Configuring cached accessory ${accessory.UUID} ${accessory.displayName}`);
|
|
242
316
|
this.log.debug('%j', accessory);
|
|
@@ -260,7 +334,7 @@ export class RingPlatform {
|
|
|
260
334
|
if (refreshTokenWithoutPushCredentials) {
|
|
261
335
|
logInfo('Forcing Ring push credential refresh because Ring push credentials were removed from the startup token');
|
|
262
336
|
}
|
|
263
|
-
const ringApi =
|
|
337
|
+
const ringApi = this.createRingApi({
|
|
264
338
|
controlCenterDisplayName,
|
|
265
339
|
...config,
|
|
266
340
|
...(refreshTokenWithoutPushCredentials
|
|
@@ -269,13 +343,48 @@ export class RingPlatform {
|
|
|
269
343
|
systemId,
|
|
270
344
|
});
|
|
271
345
|
ringApi.onRefreshTokenUpdated.subscribe(createRefreshTokenSaver(this.api, config.refreshToken, Boolean(refreshTokenWithoutPushCredentials)));
|
|
272
|
-
|
|
346
|
+
// A retry must sign in with the newest token (Ring may have replaced the old one) and must
|
|
347
|
+
// not force a new identity or push registration again.
|
|
348
|
+
ringApi.onRefreshTokenUpdated.subscribe(({ newRefreshToken }) => {
|
|
349
|
+
config.refreshToken = newRefreshToken;
|
|
350
|
+
config.forceRefreshRingPushCredentials = false;
|
|
351
|
+
config.forceRefreshRingApiSession = false;
|
|
352
|
+
});
|
|
353
|
+
let locations, loadedDevices;
|
|
354
|
+
try {
|
|
355
|
+
locations = await ringApi.getLocations();
|
|
356
|
+
// Load the devices of every location now: if none can be loaded (a Ring error while
|
|
357
|
+
// connecting to the alarm, say), a retry with a new connection may do better, and nothing
|
|
358
|
+
// has been set up yet. A location that fails while others work is reported below.
|
|
359
|
+
// Loaded once: after a failed connection, ring-client-api's getDevices() waits forever.
|
|
360
|
+
loadedDevices = await Promise.allSettled(locations.map((location) => location.getDevices()));
|
|
361
|
+
const firstFailure = loadedDevices.find((result) => result.status === 'rejected');
|
|
362
|
+
if (firstFailure && loadedDevices.every((result) => result.status === 'rejected')) {
|
|
363
|
+
throw firstFailure.reason;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
// A retry starts over with a new connection.
|
|
368
|
+
ringApi.disconnect();
|
|
369
|
+
throw e;
|
|
370
|
+
}
|
|
371
|
+
if (this.shuttingDown) {
|
|
372
|
+
ringApi.disconnect();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
this.devicesSetUp = true;
|
|
376
|
+
const cachedAccessoryIds = Object.keys(this.homebridgeAccessories), platformAccessories = [], externalAccessories = [], activeAccessoryIds = [];
|
|
273
377
|
logInfo('Found the following locations:');
|
|
274
378
|
locations.forEach((location) => {
|
|
275
379
|
logInfo(` locationId: ${location.id} - ${location.name}`);
|
|
276
380
|
});
|
|
277
|
-
|
|
278
|
-
|
|
381
|
+
// One location (or device) that fails to set up must not stop the others, the alarm
|
|
382
|
+
// included; its cached accessories are kept rather than removed as stale.
|
|
383
|
+
const locationResults = await Promise.allSettled(locations.map(async (location, locationIndex) => {
|
|
384
|
+
const loaded = loadedDevices[locationIndex];
|
|
385
|
+
if (loaded.status === 'rejected')
|
|
386
|
+
throw loaded.reason;
|
|
387
|
+
const devices = loaded.value, { cameras, chimes, intercoms } = location, allDevices = [...devices, ...cameras, ...chimes, ...intercoms], securityPanel = devices.find((x) => x.deviceType === RingDeviceType.SecurityPanel), debugPrefix = debug ? 'TEST ' : '', hapDevices = allDevices.map((device) => {
|
|
279
388
|
const isCamera = device instanceof RingCamera, isIntercomVideo = device instanceof RingIntercom &&
|
|
280
389
|
device.deviceType === 'intercom_handset_video', cameraIdentitySalt = isCamera && config.externalCameraIdSalt
|
|
281
390
|
? `-${config.externalCameraIdSalt}`
|
|
@@ -320,7 +429,7 @@ export class RingPlatform {
|
|
|
320
429
|
});
|
|
321
430
|
}
|
|
322
431
|
if (config.locationModePollingSeconds &&
|
|
323
|
-
(await location.supportsLocationModeSwitching())) {
|
|
432
|
+
(await location.supportsLocationModeSwitching().catch(() => false))) {
|
|
324
433
|
hapDevices.push({
|
|
325
434
|
deviceType: 'location.mode',
|
|
326
435
|
device: location,
|
|
@@ -334,73 +443,104 @@ export class RingPlatform {
|
|
|
334
443
|
logInfo(`Configuring ${cameras.length} cameras and ${hapDevices.length} devices for location "${location.name}" - locationId: ${location.id}`);
|
|
335
444
|
hapDevices.forEach(({ deviceType, device, isCamera, isIntercomVideo, id, name, AccessoryClass, }) => {
|
|
336
445
|
const uuid = hap.uuid.generate(debugPrefix + id), displayName = debugPrefix + name;
|
|
337
|
-
|
|
338
|
-
(
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
446
|
+
try {
|
|
447
|
+
if (!AccessoryClass ||
|
|
448
|
+
(config.hideLightGroups &&
|
|
449
|
+
deviceType === RingDeviceType.BeamsLightGroupSwitch) ||
|
|
450
|
+
hideDeviceIds.includes(uuid) ||
|
|
451
|
+
(onlyDeviceTypes && !onlyDeviceTypes.includes(deviceType))) {
|
|
452
|
+
if (!ignoreHiddenDeviceTypes.includes(deviceType)) {
|
|
453
|
+
logInfo(`Hidden accessory ${uuid} ${deviceType} ${displayName}`);
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
344
456
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
this.homebridgeAccessories[uuid]
|
|
352
|
-
]);
|
|
353
|
-
delete this.homebridgeAccessories[uuid];
|
|
354
|
-
}
|
|
355
|
-
const createHomebridgeAccessory = () => {
|
|
356
|
-
const category = isCamera
|
|
357
|
-
? 17 /* hap.Categories.CAMERA */
|
|
358
|
-
: isIntercomVideo
|
|
359
|
-
? 18 /* hap.Categories.VIDEO_DOORBELL */
|
|
360
|
-
: 11 /* hap.Categories.SECURITY_SYSTEM */;
|
|
361
|
-
const accessory = new api.platformAccessory(displayName, uuid, category);
|
|
362
|
-
if (isCamera) {
|
|
363
|
-
logInfo(`Configured camera ${uuid} ${deviceType} ${displayName}`);
|
|
364
|
-
externalAccessories.push(accessory);
|
|
365
|
-
}
|
|
366
|
-
else if (isIntercomVideo) {
|
|
367
|
-
logInfo(`Configured Ring Intercom Video ${uuid} ${deviceType} ${displayName}`);
|
|
368
|
-
externalAccessories.push(accessory);
|
|
369
|
-
}
|
|
370
|
-
else {
|
|
371
|
-
logInfo(`Adding new accessory ${uuid} ${deviceType} ${displayName}`);
|
|
372
|
-
platformAccessories.push(accessory);
|
|
457
|
+
if (isCamera && this.homebridgeAccessories[uuid]) {
|
|
458
|
+
// Camera was previously bridged. Remove it from the platform so that it can be added as an external accessory
|
|
459
|
+
this.log.warn(`Camera ${displayName} was previously bridged. You will need to manually pair it as a new accessory.`);
|
|
460
|
+
this.api.unregisterPlatformAccessories(pluginName, platformName, [
|
|
461
|
+
this.homebridgeAccessories[uuid],
|
|
462
|
+
]);
|
|
463
|
+
delete this.homebridgeAccessories[uuid];
|
|
373
464
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
465
|
+
let addTo;
|
|
466
|
+
const createHomebridgeAccessory = () => {
|
|
467
|
+
const category = isCamera
|
|
468
|
+
? 17 /* hap.Categories.CAMERA */
|
|
469
|
+
: isIntercomVideo
|
|
470
|
+
? 18 /* hap.Categories.VIDEO_DOORBELL */
|
|
471
|
+
: 11 /* hap.Categories.SECURITY_SYSTEM */;
|
|
472
|
+
const accessory = new api.platformAccessory(displayName, uuid, category);
|
|
473
|
+
if (isCamera) {
|
|
474
|
+
logInfo(`Configured camera ${uuid} ${deviceType} ${displayName}`);
|
|
475
|
+
addTo = externalAccessories;
|
|
476
|
+
}
|
|
477
|
+
else if (isIntercomVideo) {
|
|
478
|
+
logInfo(`Configured Ring Intercom Video ${uuid} ${deviceType} ${displayName}`);
|
|
479
|
+
addTo = externalAccessories;
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
logInfo(`Adding new accessory ${uuid} ${deviceType} ${displayName}`);
|
|
483
|
+
addTo = platformAccessories;
|
|
484
|
+
}
|
|
485
|
+
return accessory;
|
|
486
|
+
}, homebridgeAccessory = this.homebridgeAccessories[uuid] || createHomebridgeAccessory(), accessory = new AccessoryClass(device, homebridgeAccessory, config);
|
|
487
|
+
accessory.initBase();
|
|
488
|
+
// Registered only once set up: a half-built accessory isn't added to HomeKit.
|
|
489
|
+
addTo?.push(homebridgeAccessory);
|
|
490
|
+
this.homebridgeAccessories[uuid] = homebridgeAccessory;
|
|
491
|
+
activeAccessoryIds.push(uuid);
|
|
492
|
+
if (config.cameraMotionNotificationSensor &&
|
|
493
|
+
accessory instanceof Camera &&
|
|
494
|
+
accessory.motionDetected) {
|
|
495
|
+
const sensorUuid = getMotionNotificationSensorUuid(debugPrefix + id), sensorName = `${displayName} Motion`;
|
|
496
|
+
let sensorAccessory = this.homebridgeAccessories[sensorUuid];
|
|
497
|
+
if (!sensorAccessory) {
|
|
498
|
+
sensorAccessory = new api.platformAccessory(sensorName, sensorUuid, 10 /* hap.Categories.SENSOR */);
|
|
499
|
+
logInfo(`Adding motion notification sensor ${sensorUuid} ${sensorName}`);
|
|
500
|
+
}
|
|
501
|
+
setUpMotionNotificationSensor(sensorAccessory, sensorName, `${device.id}-motion`, accessory.motionDetected);
|
|
502
|
+
if (!this.homebridgeAccessories[sensorUuid]) {
|
|
503
|
+
platformAccessories.push(sensorAccessory);
|
|
504
|
+
}
|
|
505
|
+
this.homebridgeAccessories[sensorUuid] = sensorAccessory;
|
|
506
|
+
activeAccessoryIds.push(sensorUuid);
|
|
388
507
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
508
|
+
}
|
|
509
|
+
catch (e) {
|
|
510
|
+
this.log.error(`Could not set up ${displayName} (${deviceType}): ${describeError(e)}. The other accessories are unaffected; restart Homebridge to try again.`);
|
|
511
|
+
const cached = this.homebridgeAccessories[uuid];
|
|
512
|
+
if (cached)
|
|
513
|
+
markNotResponding(cached);
|
|
514
|
+
// Keep it (and a motion sensor it had) in HomeKit rather than removing it as stale.
|
|
515
|
+
activeAccessoryIds.push(uuid, getMotionNotificationSensorUuid(debugPrefix + id));
|
|
392
516
|
}
|
|
393
517
|
});
|
|
394
518
|
}));
|
|
519
|
+
const failedLocations = locations.filter((_, i) => locationResults[i].status === 'rejected');
|
|
520
|
+
locationResults.forEach((result, i) => {
|
|
521
|
+
if (result.status === 'rejected') {
|
|
522
|
+
const reason = result.reason;
|
|
523
|
+
this.log.error(`Could not set up the devices of location "${locations[i].name}": ${describeError(reason)}. Restart Homebridge to try again.`);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
395
526
|
if (platformAccessories.length) {
|
|
396
527
|
api.registerPlatformAccessories(pluginName, platformName, platformAccessories);
|
|
397
528
|
}
|
|
398
529
|
if (externalAccessories.length) {
|
|
399
530
|
api.publishExternalAccessories(pluginName, externalAccessories);
|
|
400
531
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
532
|
+
// With a location that failed, which cached accessories belong to it is unknown: keep all,
|
|
533
|
+
// showing "No Response" until a restart sets them up.
|
|
534
|
+
if (failedLocations.length) {
|
|
535
|
+
cachedAccessoryIds
|
|
536
|
+
.filter((cachedId) => !activeAccessoryIds.includes(cachedId))
|
|
537
|
+
.forEach((id) => markNotResponding(this.homebridgeAccessories[id]));
|
|
538
|
+
}
|
|
539
|
+
const staleAccessories = failedLocations.length
|
|
540
|
+
? []
|
|
541
|
+
: cachedAccessoryIds
|
|
542
|
+
.filter((cachedId) => !activeAccessoryIds.includes(cachedId))
|
|
543
|
+
.map((id) => this.homebridgeAccessories[id]);
|
|
404
544
|
staleAccessories.forEach((staleAccessory) => {
|
|
405
545
|
logInfo(`Removing stale cached accessory ${staleAccessory.UUID} ${staleAccessory.displayName}`);
|
|
406
546
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-ring-secure-video",
|
|
3
3
|
"displayName": "Ring Secure Video",
|
|
4
|
-
"version": "1.0.0-beta.
|
|
4
|
+
"version": "1.0.0-beta.4",
|
|
5
5
|
"description": "Homebridge plugin for Ring devices with HomeKit Secure Video recording, based on homebridge-ring and homebridge-ring-hksv",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./lib/index.js",
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"category-outdoor"
|
|
86
86
|
],
|
|
87
87
|
"allowScripts": {
|
|
88
|
-
"ffmpeg-for-homebridge@2.2.
|
|
89
|
-
"protobufjs@7.6.
|
|
88
|
+
"ffmpeg-for-homebridge@2.2.2": true,
|
|
89
|
+
"protobufjs@7.6.6": true
|
|
90
90
|
}
|
|
91
91
|
}
|