homebridge-ring-secure-video 1.0.0-beta.2 → 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 +75 -0
- package/README.md +21 -21
- package/config.schema.json +6 -7
- package/lib/camera-source.js +94 -41
- package/lib/camera.js +184 -51
- package/lib/config.js +116 -6
- package/lib/managed-ffmpeg-process.js +9 -4
- package/lib/media-config.js +56 -3
- package/lib/ring-platform.js +241 -77
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,80 @@
|
|
|
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
|
+
|
|
55
|
+
## 1.0.0-beta.3 (2026-11-13)
|
|
56
|
+
|
|
57
|
+
Fixes from a full independent review of 1.0.0-beta.2.
|
|
58
|
+
|
|
59
|
+
- A second visit 30–65 s after the first now turns HomeKit motion on again, so it is recorded and
|
|
60
|
+
notified. Ring keeps a motion alert active for 65 s and only reports changes, while the plugin
|
|
61
|
+
turns motion off after 30 s; a new alert in between gave nothing. A repeat of the same alert
|
|
62
|
+
(Ring's follow-up with the snapshot, a resend) is not a new visit, and the event-history
|
|
63
|
+
fallback no longer adds its own "no motion" after 65 s, which could cut a later visit short.
|
|
64
|
+
- A media setting the plugin can't use (for example a maximum bitrate below the bitrate, which
|
|
65
|
+
the settings form allows, or a stray key) no longer stops the plugin's bridge from starting
|
|
66
|
+
(which took the alarm down with it). It is ignored with a warning naming it, and the older
|
|
67
|
+
setting or the default is used. Default maximum bitrate and buffer size derived from a high
|
|
68
|
+
bitrate stay within their limits.
|
|
69
|
+
- After a forced Ring refresh ("Force Ring Push Credential Refresh" or "Force Ring API Session
|
|
70
|
+
Refresh"), every new Ring token is saved, not only the first; before, the next start
|
|
71
|
+
registered with Ring again. The force settings are cleared once, and a token that can't be
|
|
72
|
+
saved is logged.
|
|
73
|
+
- config.json and .ring.json are written to a temporary file and then renamed into place, so a
|
|
74
|
+
power cut or a full disk during a token renewal can't leave them cut short (Homebridge would not
|
|
75
|
+
start). Permissions, group and symlinks are kept; where that can't work (a bind-mounted file,
|
|
76
|
+
a file owned by another user, ...) they are written in place as before.
|
|
77
|
+
|
|
3
78
|
## 1.0.0-beta.2 (2026-11-13)
|
|
4
79
|
|
|
5
80
|
- The Homebridge UI shows the plugin as "Ring Secure Video" (was "Homebridge Ring Secure Video"),
|
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,11 +1,188 @@
|
|
|
1
1
|
import { hap } from "./hap.js";
|
|
2
|
+
import { PushNotificationAction } from 'ring-client-api';
|
|
2
3
|
import { BaseDataAccessory } from "./base-data-accessory.js";
|
|
3
|
-
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';
|
|
4
5
|
import { CameraSource } from "./camera-source.js";
|
|
5
6
|
import { TargetValueTimer } from "./target-value-timer.js";
|
|
6
7
|
import { delay, logError, logInfo } from 'ring-client-api/util';
|
|
7
|
-
import { EMPTY, firstValueFrom,
|
|
8
|
+
import { defer, EMPTY, firstValueFrom, merge, of, timer } from 'rxjs';
|
|
8
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
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Motion from Ring's push notifications, as HomeKit needs it: true for every new motion alert.
|
|
155
|
+
*
|
|
156
|
+
* ring-client-api's onMotionDetected is true while any motion alert is active (each lasts 65 s)
|
|
157
|
+
* and only reports changes, so a new alert while an earlier one is still active gives nothing.
|
|
158
|
+
* The watchdog below turns motion off after 30 s, so a second visit 30-65 s after the first
|
|
159
|
+
* would never turn it on again: no recording and no notification. So every new motion
|
|
160
|
+
* notification gives a true (restarting the watchdog), and onMotionDetected only adds its
|
|
161
|
+
* false once all alerts have expired. A notification repeating an alert seen in the last two
|
|
162
|
+
* minutes (Ring sends a second one with the snapshot; a reconnect can resend) is not a new visit.
|
|
163
|
+
*/
|
|
164
|
+
export const REPEATED_ALERT_WINDOW_MS = 120_000;
|
|
165
|
+
export function getPushMotionEvents(camera) {
|
|
166
|
+
return defer(() => {
|
|
167
|
+
const seenAt = new Map();
|
|
168
|
+
return merge(camera.onMotionDetected.pipe(filter((motion) => !motion)), camera.onNewNotification.pipe(filter((notification) => {
|
|
169
|
+
if (notification.android_config?.category !== PushNotificationAction.Motion) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
const now = Date.now(), id = notification.data?.event?.ding?.id;
|
|
173
|
+
for (const [seenId, at] of seenAt) {
|
|
174
|
+
if (now - at > REPEATED_ALERT_WINDOW_MS)
|
|
175
|
+
seenAt.delete(seenId);
|
|
176
|
+
}
|
|
177
|
+
if (id === undefined)
|
|
178
|
+
return true;
|
|
179
|
+
if (seenAt.has(String(id)))
|
|
180
|
+
return false;
|
|
181
|
+
seenAt.set(String(id), now);
|
|
182
|
+
return true;
|
|
183
|
+
}), map(() => true)));
|
|
184
|
+
});
|
|
185
|
+
}
|
|
9
186
|
export function addMotionWatchdog(motionEvents, watchdogMs = MOTION_WATCHDOG_MS) {
|
|
10
187
|
return motionEvents.pipe(switchMap((motion) => {
|
|
11
188
|
if (!motion) {
|
|
@@ -183,56 +360,12 @@ export class Camera extends BaseDataAccessory {
|
|
|
183
360
|
}
|
|
184
361
|
}
|
|
185
362
|
getMotionDetectedObservable() {
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const baseHistoryRetryMs = Math.max(pollSeconds * 1000, 30_000), polledNewMotionEvents = timer(pollSeconds * 1000, pollSeconds * 1000).pipe(exhaustMap(() => {
|
|
190
|
-
if (Date.now() < nextHistoryPollAt) {
|
|
191
|
-
return EMPTY;
|
|
192
|
-
}
|
|
193
|
-
return from(this.device.getEvents({
|
|
194
|
-
limit: 10,
|
|
195
|
-
kind: 'motion',
|
|
196
|
-
})).pipe(mergeMap(({ events }) => {
|
|
197
|
-
historyFailureCount = 0;
|
|
198
|
-
nextHistoryPollAt = 0;
|
|
199
|
-
const newMotionEvents = events
|
|
200
|
-
.filter((event) => {
|
|
201
|
-
const eventId = event.ding_id_str || String(event.ding_id);
|
|
202
|
-
return (event.kind === 'motion' &&
|
|
203
|
-
Date.parse(event.created_at) >= startedAt &&
|
|
204
|
-
!seenEventIds.has(eventId));
|
|
205
|
-
})
|
|
206
|
-
.sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
|
|
207
|
-
newMotionEvents.forEach((event) => {
|
|
208
|
-
seenEventIds.add(event.ding_id_str || String(event.ding_id));
|
|
209
|
-
});
|
|
210
|
-
if (!newMotionEvents.length)
|
|
211
|
-
return EMPTY;
|
|
212
|
-
logInfo(`${this.device.name} Detected Motion from Ring event history fallback`);
|
|
213
|
-
return of(true);
|
|
214
|
-
}), catchError(() => {
|
|
215
|
-
historyFailureCount++;
|
|
216
|
-
const retryDelayMs = Math.min(5 * 60 * 1000, baseHistoryRetryMs * 2 ** Math.min(historyFailureCount - 1, 4));
|
|
217
|
-
nextHistoryPollAt = Date.now() + retryDelayMs;
|
|
218
|
-
logError(`${this.device.name} failed to poll Ring event history for motion fallback; retrying in ${Math.ceil(retryDelayMs / 1000)} seconds`);
|
|
219
|
-
return EMPTY;
|
|
220
|
-
}));
|
|
221
|
-
}),
|
|
222
|
-
// The false timer below has a second subscriber. Sharing keeps one
|
|
223
|
-
// history request per poll instead of duplicating Ring API traffic.
|
|
224
|
-
share()), polledMotionEvents = merge(polledNewMotionEvents, polledNewMotionEvents.pipe(switchMap(() => timer(65000).pipe(map(() => false)))));
|
|
225
|
-
if (enableMotionHistoryFallback) {
|
|
226
|
-
this.device.onNewNotification.subscribe((notification) => {
|
|
227
|
-
const dingId = notification.data?.event?.ding?.id;
|
|
228
|
-
if (dingId) {
|
|
229
|
-
seenEventIds.add(String(dingId));
|
|
230
|
-
}
|
|
231
|
-
});
|
|
363
|
+
const pushMotionEvents = getPushMotionEvents(this.device);
|
|
364
|
+
if (this.config.enableCameraMotionHistory === false) {
|
|
365
|
+
return addMotionWatchdog(pushMotionEvents);
|
|
232
366
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
: pushMotionEvents);
|
|
367
|
+
const pollSeconds = Math.max(this.config.cameraStatusPollingSeconds ?? 20, 10);
|
|
368
|
+
return addMotionWatchdog(merge(pushMotionEvents, getHistoryMotionEvents(this.device, pollSeconds)));
|
|
236
369
|
}
|
|
237
370
|
async loadSnapshotForEvent(eventDescription, characteristicValue) {
|
|
238
371
|
let imageUuid = this.device.latestNotificationSnapshotUuid;
|